SOURCE / PINNED RELEASE
Made of little things.
Wizard Duel
- Release
- 48b4ed933a32…
- Author-recorded commit
- 3ed8fcbbf62c…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-86005d75eb4
Archive hash verified: 4196bfb69e536a86…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Tiny pixel-art helpers: a 3×5 bitmap font, arrow runes and sprite drawing.
// Everything is drawn with fillRect on a small canvas that is scaled up with
// nearest-neighbour filtering, so every shape stays crisp and blocky.
export type Dir = 'up' | 'down' | 'left' | 'right';
const FONT: Record<string, string[]> = {
A: ['.#.', '#.#', '###', '#.#', '#.#'],
B: ['##.', '#.#', '##.', '#.#', '##.'],
C: ['.##', '#..', '#..', '#..', '.##'],
D: ['##.', '#.#', '#.#', '#.#', '##.'],
E: ['###', '#..', '##.', '#..', '###'],
F: ['###', '#..', '##.', '#..', '#..'],
G: ['.##', '#..', '#.#', '#.#', '.##'],
H: ['#.#', '#.#', '###', '#.#', '#.#'],
I: ['###', '.#.', '.#.', '.#.', '###'],
J: ['..#', '..#', '..#', '#.#', '.#.'],
K: ['#.#', '#.#', '##.', '#.#', '#.#'],
L: ['#..', '#..', '#..', '#..', '###'],
M: ['#.#', '###', '###', '#.#', '#.#'],
N: ['##.', '#.#', '#.#', '#.#', '#.#'],
O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
P: ['##.', '#.#', '##.', '#..', '#..'],
Q: ['.#.', '#.#', '#.#', '##.', '.##'],
R: ['##.', '#.#', '##.', '#.#', '#.#'],
S: ['.##', '#..', '.#.', '..#', '##.'],
T: ['###', '.#.', '.#.', '.#.', '.#.'],
U: ['#.#', '#.#', '#.#', '#.#', '###'],
V: ['#.#', '#.#', '#.#', '#.#', '.#.'],
W: ['#.#', '#.#', '###', '###', '#.#'],
X: ['#.#', '#.#', '.#.', '#.#', '#.#'],
Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
Z: ['###', '..#', '.#.', '#..', '###'],
'0': ['###', '#.#', '#.#', '#.#', '###'],
'1': ['.#.', '##.', '.#.', '.#.', '###'],
'2': ['##.', '..#', '.#.', '#..', '###'],
'3': ['##.', '..#', '.#.', '..#', '##.'],
'4': ['#.#', '#.#', '###', '..#', '..#'],
'5': ['###', '#..', '##.', '..#', '##.'],
'6': ['.##', '#..', '###', '#.#', '###'],
'7': ['###', '..#', '.#.', '.#.', '.#.'],
'8': ['###', '#.#', '###', '#.#', '###'],
'9': ['###', '#.#', '###', '..#', '##.'],
'!': ['.#.', '.#.', '.#.', '...', '.#.'],
'?': ['##.', '..#', '.#.', '...', '.#.'],
'.': ['...', '...', '...', '...', '.#.'],
',': ['...', '...', '...', '.#.', '#..'],
"'": ['.#.', '.#.', '...', '...', '...'],
'-': ['...', '...', '###', '...', '...'],
':': ['...', '.#.', '...', '.#.', '...'],
'/': ['..#', '..#', '.#.', '#..', '#..'],
'+': ['...', '.#.', '###', '.#.', '...'],
'(': ['.#.', '#..', '#..', '#..', '.#.'],
')': ['.#.', '..#', '..#', '..#', '.#.'],
' ': ['...', '...', '...', '...', '...'],
};
export function textWidth(text: string, scale = 1): number {
return text.length === 0 ? 0 : (text.length * 4 - 1) * scale;
}
/** Draw text with the bitmap font. A dark shadow keeps it readable on any background. */
export function drawText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
color: string,
options: { scale?: number; align?: 'left' | 'center' | 'right'; shadow?: string | null } = {},
): void {
const scale = options.scale ?? 1;
const upper = text.toUpperCase();
let left = Math.round(x);
if (options.align === 'center') left = Math.round(x - textWidth(upper, scale) / 2);
if (options.align === 'right') left = Math.round(x - textWidth(upper, scale));
const shadow = options.shadow === undefined ? '#120a1f' : options.shadow;
const passes: Array<[string, number]> = shadow ? [[shadow, scale], [color, 0]] : [[color, 0]];
for (const [fill, offset] of passes) {
ctx.fillStyle = fill;
for (let i = 0; i < upper.length; i++) {
const glyph = FONT[upper[i]] ?? FONT['?'];
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 3; col++) {
if (glyph[row][col] === '#') {
ctx.fillRect(left + (i * 4 + col) * scale + offset, Math.round(y) + row * scale + offset, scale, scale);
}
}
}
}
}
}
const ARROW_UP = ['..#..', '.###.', '#.#.#', '..#..', '..#..'];
function rotateArrow(dir: Dir): string[] {
const rows: string[] = [];
for (let r = 0; r < 5; r++) {
let line = '';
for (let c = 0; c < 5; c++) {
if (dir === 'up') line += ARROW_UP[r][c];
else if (dir === 'down') line += ARROW_UP[4 - r][c];
else if (dir === 'left') line += ARROW_UP[c][r];
else line += ARROW_UP[4 - c][r];
}
rows.push(line);
}
return rows;
}
const ARROWS: Record<Dir, string[]> = {
up: rotateArrow('up'),
down: rotateArrow('down'),
left: rotateArrow('left'),
right: rotateArrow('right'),
};
/** Draw one arrow rune (5×5) on a small dark tile. */
export function drawArrow(
ctx: CanvasRenderingContext2D,
dir: Dir,
x: number,
y: number,
color: string,
tile: string | null = '#120a1f',
): void {
const left = Math.round(x);
const top = Math.round(y);
if (tile) {
ctx.fillStyle = tile;
ctx.fillRect(left - 1, top - 1, 7, 7);
}
ctx.fillStyle = color;
const rows = ARROWS[dir];
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
if (rows[r][c] === '#') ctx.fillRect(left + c, top + r, 1, 1);
}
}
}
/**
* Draw a sprite described by rows of characters. Each character is looked up in
* the palette; '.' (or any character missing from the palette) is transparent.
*/
export function drawSprite(
ctx: CanvasRenderingContext2D,
rows: string[],
palette: Record<string, string>,
x: number,
y: number,
options: { flipX?: boolean; flipY?: boolean; scale?: number } = {},
): void {
const scale = options.scale ?? 1;
const height = rows.length;
const width = rows[0]?.length ?? 0;
const left = Math.round(x);
const top = Math.round(y);
for (let r = 0; r < height; r++) {
const row = rows[r];
for (let c = 0; c < width; c++) {
const color = palette[row[c]];
if (!color) continue;
const dc = options.flipX ? width - 1 - c : c;
const dr = options.flipY ? height - 1 - r : r;
ctx.fillStyle = color;
ctx.fillRect(left + dc * scale, top + dr * scale, scale, scale);
}
}
}
