You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
252 lines
8.8 KiB
252 lines
8.8 KiB
|
|
import {
|
|
ElementType,
|
|
SectionType,
|
|
type MonoFormatAnimation,
|
|
type MonoFormatClippedText,
|
|
type MonoFormatElement,
|
|
type MonoFormatElementsAlways,
|
|
type MonoFormatElementsTimespan,
|
|
type MonoFormatFile,
|
|
type MonoFormatHScroll,
|
|
type MonoFormatHScrollText,
|
|
type MonoFormatLine,
|
|
type MonoFormatPixelImage,
|
|
type MonoFormatVScroll
|
|
} from "./types.js";
|
|
import { type MonoDisplayDriverOptions } from "./driver";
|
|
/**
|
|
* Renders a MonoFormatFile onto an HTMLCanvasElement.
|
|
* Uses setInterval at 1000/fps ms per tick. All element state (animation
|
|
* frame counters, scroll positions) is keyed by element index within the file.
|
|
*
|
|
* PLAN: ClippedText and HScrollText currently render via canvas fillText (stub).
|
|
* Replace with U8G2 bitmap font renderer once font loading is implemented.
|
|
* PLAN: Line element uses Bresenham stub; lineStyle field is reserved and ignored.
|
|
*/
|
|
export class MonoDisplayRenderer {
|
|
private ctx: CanvasRenderingContext2D;
|
|
private opts: Required<MonoDisplayDriverOptions>;
|
|
|
|
private tickTimer: number | null = null;
|
|
private tickCount: number = 0;
|
|
// Per-element state: keyed by stable element index
|
|
private animState: Map<number, { frame: number; counter: number }> = new Map();
|
|
private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional)
|
|
private vScrollPos: Map<number, number> = new Map();
|
|
|
|
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) {
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Cannot get 2D canvas context");
|
|
this.ctx = ctx;
|
|
this.opts = opts;
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.tickTimer !== null) {
|
|
clearInterval(this.tickTimer);
|
|
this.tickTimer = null;
|
|
}
|
|
this.tickCount = 0;
|
|
this.animState.clear();
|
|
this.hScrollPos.clear();
|
|
this.vScrollPos.clear();
|
|
}
|
|
|
|
render(file: MonoFormatFile): void {
|
|
this.stop();
|
|
|
|
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts;
|
|
this.ctx.canvas.width = dw * s;
|
|
this.ctx.canvas.height = dh * s;
|
|
|
|
const tick = () => {
|
|
this.#renderFile(file);
|
|
this.tickCount++;
|
|
};
|
|
|
|
tick(); // immediate first frame
|
|
this.tickTimer = setInterval(tick, 1000 / this.opts.fps) as unknown as number;
|
|
}
|
|
|
|
#renderFile(file: MonoFormatFile): void {
|
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
let cleared = false;
|
|
|
|
let elementId = 0;
|
|
|
|
for (const section of file.sections) {
|
|
if (section.sectionType === SectionType.ElementsTimespan) {
|
|
if (now < section.startTimestamp || now >= section.endTimestamp) {
|
|
// Count elements so IDs stay stable even for skipped sections
|
|
elementId += section.elements.length;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (section.sectionType !== SectionType.ElementsAlways &&
|
|
section.sectionType !== SectionType.ElementsTimespan) {
|
|
continue;
|
|
}
|
|
|
|
const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan;
|
|
|
|
if (elSection.flags.clearBuffer && !cleared) {
|
|
this.ctx.fillStyle = this.opts.offColor;
|
|
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
|
cleared = true;
|
|
}
|
|
|
|
for (const el of elSection.elements) {
|
|
this.#renderElement(el, elementId++);
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderElement(el: MonoFormatElement, id: number): void {
|
|
switch (el.type) {
|
|
case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break;
|
|
case ElementType.Animation: this.#renderAnimation(el, id); break;
|
|
case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break;
|
|
case ElementType.VerticalScroll: this.#renderVScroll(el, id); break;
|
|
case ElementType.Line: this.#renderLine(el); break;
|
|
case ElementType.ClippedText: this.#renderClippedText(el); break;
|
|
case ElementType.HScrollText: this.#renderHScrollText(el, id); break;
|
|
}
|
|
}
|
|
|
|
/** Blit a pixel image onto the canvas at (ox, oy) in display coordinates */
|
|
#blitImage(img: MonoFormatPixelImage, ox: number, oy: number): void {
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.fillStyle = onColor;
|
|
for (let y = 0; y < img.height; y++) {
|
|
for (let x = 0; x < img.width; x++) {
|
|
if (img.pixels[y * img.width + x]) {
|
|
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Blit a clipped sub-region of a pixel image. srcX is fractional scroll offset. */
|
|
#blitClipped(
|
|
img: MonoFormatPixelImage, ox: number, oy: number,
|
|
viewW: number, viewH: number, srcX: number, srcY: number,
|
|
): void {
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.fillStyle = onColor;
|
|
for (let y = 0; y < viewH; y++) {
|
|
for (let x = 0; x < viewW; x++) {
|
|
const sx = Math.floor(srcX) + x;
|
|
const sy = Math.floor(srcY) + y;
|
|
if (sx < 0 || sx >= img.width || sy < 0 || sy >= img.height) continue;
|
|
if (img.pixels[sy * img.width + sx]) {
|
|
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderAnimation(el: MonoFormatAnimation, id: number): void {
|
|
if (el.frames.length === 0) return;
|
|
let state = this.animState.get(id);
|
|
if (!state) { state = { frame: 0, counter: 0 }; this.animState.set(id, state); }
|
|
|
|
this.#blitImage(el.frames[state.frame]!, el.xOffset, el.yOffset);
|
|
|
|
state.counter++;
|
|
if (state.counter > el.updateInterval) {
|
|
state.counter = 0;
|
|
state.frame = (state.frame + 1) % el.frames.length;
|
|
if (!this.opts.loop && state.frame === 0) state.frame = el.frames.length - 1;
|
|
}
|
|
}
|
|
|
|
#renderHScroll(el: MonoFormatHScroll, id: number): void {
|
|
let pos = this.hScrollPos.get(id) ?? 0;
|
|
// Draw visible slice
|
|
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, pos, 0);
|
|
// Advance scroll: (SS+1)/16 pixels per tick
|
|
const speed = (el.scrollSpeed + 1) / 16;
|
|
pos += el.flags.invertDirection ? -speed : speed;
|
|
if (el.flags.endless) {
|
|
if (pos >= el.contentWidth) pos -= el.contentWidth;
|
|
if (pos < 0) pos += el.contentWidth;
|
|
} else {
|
|
pos = Math.max(0, Math.min(pos, el.contentWidth - el.width));
|
|
}
|
|
this.hScrollPos.set(id, pos);
|
|
}
|
|
|
|
#renderVScroll(el: MonoFormatVScroll, id: number): void {
|
|
let pos = this.vScrollPos.get(id) ?? 0;
|
|
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, 0, pos);
|
|
const speed = (el.scrollSpeed + 1) / 16;
|
|
pos += el.flags.invertDirection ? -speed : speed;
|
|
if (el.flags.endless) {
|
|
if (pos >= el.contentHeight) pos -= el.contentHeight;
|
|
if (pos < 0) pos += el.contentHeight;
|
|
} else {
|
|
pos = Math.max(0, Math.min(pos, el.contentHeight - el.height));
|
|
}
|
|
this.vScrollPos.set(id, pos);
|
|
}
|
|
|
|
#renderLine(el: MonoFormatLine): void {
|
|
// Bresenham's line algorithm
|
|
const { ctx, opts: { onColor, offColor, scale: s } } = this;
|
|
ctx.fillStyle = el.invertPixels ? offColor : onColor;
|
|
let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget];
|
|
const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1;
|
|
const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1;
|
|
let err = dx + dy;
|
|
while (true) {
|
|
ctx.fillRect(x0*s, y0*s, s, s);
|
|
if (x0 === x1 && y0 === y1) break;
|
|
const e2 = 2 * err;
|
|
if (e2 >= dy) { err += dy; x0 += sx; }
|
|
if (e2 <= dx) { err += dx; y0 += sy; }
|
|
}
|
|
}
|
|
|
|
#renderClippedText(el: MonoFormatClippedText): void {
|
|
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
|
|
ctx.clip();
|
|
ctx.fillStyle = onColor;
|
|
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
|
|
ctx.font = `${fontSize}px monospace`;
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(el.text, el.xOffset * s, el.yOffset * s);
|
|
ctx.restore();
|
|
}
|
|
|
|
#renderHScrollText(el: MonoFormatHScrollText, id: number): void {
|
|
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
|
|
const { ctx, opts: { onColor, scale: s } } = this;
|
|
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
|
|
ctx.font = `${fontSize}px monospace`;
|
|
const textPx = ctx.measureText(el.text).width;
|
|
let pos = this.hScrollPos.get(id) ?? el.width * s;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
|
|
ctx.clip();
|
|
ctx.fillStyle = onColor;
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s);
|
|
ctx.restore();
|
|
|
|
const speed = (el.scrollSpeed + 1) / 16 * s;
|
|
pos += el.flags.invertDirection ? speed : -speed;
|
|
if (el.flags.endless) {
|
|
if (pos < -textPx) pos = el.width * s;
|
|
if (pos > el.width * s) pos = -textPx;
|
|
}
|
|
this.hScrollPos.set(id, pos);
|
|
}
|
|
}
|
|
|