fix: restructure the library

pull/1/head
flop 4 weeks ago
parent db833b48c8
commit 68ac114872
  1. 428
      ts/public/bundle.js
  2. 88
      ts/src/driver.ts
  3. 260
      ts/src/file.ts
  4. 102
      ts/src/helper.ts
  5. 989
      ts/src/library.ts
  6. 254
      ts/src/parser.ts
  7. 251
      ts/src/renderer.ts

@ -1,428 +0,0 @@
// src/library.ts
class BinaryReader {
view;
pos = 0;
constructor(buffer) {
this.view = new DataView(buffer);
}
get offset() {
return this.pos;
}
get byteLength() {
return this.view.byteLength;
}
get remaining() {
return this.view.byteLength - this.pos;
}
readByte() {
this.#assertRemaining(1);
return this.view.getUint8(this.pos++);
}
readUint16() {
this.#assertRemaining(2);
const v = this.view.getUint16(this.pos, true);
this.pos += 2;
return v;
}
readShort() {
return this.readUint16();
}
readUint32() {
this.#assertRemaining(4);
const v = this.view.getUint32(this.pos, true);
this.pos += 4;
return v;
}
readUint64() {
this.#assertRemaining(8);
const lo = BigInt(this.view.getUint32(this.pos, true));
const hi = BigInt(this.view.getUint32(this.pos + 4, true));
this.pos += 8;
return hi << 32n | lo;
}
readBytes(n) {
this.#assertRemaining(n);
const slice = new Uint8Array(this.view.buffer, this.pos, n);
this.pos += n;
return slice;
}
readUtf8(n) {
return new TextDecoder().decode(this.readBytes(n));
}
seek(pos) {
if (pos < 0 || pos > this.view.byteLength) {
throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`);
}
this.pos = pos;
}
#assertRemaining(n) {
if (this.remaining < n) {
throw new RangeError(`BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain`);
}
}
}
var MAGIC = 1330532173;
var ContentTypeByte = {
0: "static",
1: "animation",
2: "text",
3: "scrolltext"
};
class MonoDisplayParser {
parse(buffer) {
const r = new BinaryReader(buffer);
const magic = r.readUint32();
if (magic !== MAGIC) {
throw new Error(`Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")`);
}
const version = r.readByte();
if (version !== 1) {
throw new Error(`Unsupported format version ${version}`);
}
const contentTypeByte = r.readByte();
const contentType = ContentTypeByte[contentTypeByte];
if (!contentType) {
throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`);
}
const width = r.readUint16();
const height = r.readUint16();
switch (contentType) {
case "static":
return this.#parseStatic(r, width, height);
case "animation":
return this.#parseAnimation(r, width, height);
case "text":
return this.#parseText(r);
case "scrolltext":
return this.#parseScrollText(r);
}
}
#parseStatic(r, width, height) {
const raw = r.readBytes(width * height);
return { type: "static", width, height, pixels: new Uint8Array(raw) };
}
#parseAnimation(r, width, height) {
const frameCount = r.readUint16();
const frames = [];
for (let i = 0;i < frameCount; i++) {
const durationMs = r.readUint32();
const raw = r.readBytes(width * height);
frames.push({ durationMs, pixels: new Uint8Array(raw) });
}
return { type: "animation", width, height, frames };
}
#parseText(r) {
const textLen = r.readUint16();
const text = r.readUtf8(textLen);
const fontId = r.readByte();
const alignByte = r.readByte();
const align = alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0;
return { type: "text", text, fontId, align };
}
#parseScrollText(r) {
const textLen = r.readUint16();
const text = r.readUtf8(textLen);
const speedPps = r.readUint16();
const dirByte = r.readByte();
const direction = dirByte === 1 ? 1 : 0;
return { type: "scrolltext", text, speedPps, direction };
}
}
class MonoDisplayRenderer {
ctx;
opts;
animFrame = 0;
animTimer = null;
scrollX = 0;
scrollRaf = null;
scrollLastTs = null;
constructor(canvas, opts) {
const ctx = canvas.getContext("2d");
if (!ctx)
throw new Error("Cannot get 2D canvas context");
this.ctx = ctx;
this.opts = opts;
}
stop() {
if (this.animTimer !== null) {
clearTimeout(this.animTimer);
this.animTimer = null;
}
if (this.scrollRaf !== null) {
cancelAnimationFrame(this.scrollRaf);
this.scrollRaf = null;
}
}
render(content) {
this.stop();
switch (content.type) {
case "static":
this.#renderStatic(content);
break;
case "animation":
this.#startAnimation(content);
break;
case "text":
this.#renderText(content);
break;
case "scrolltext":
this.#startScrollText(content);
break;
}
}
#resizeCanvas(w, h) {
const s = this.opts.scale;
this.ctx.canvas.width = w * s;
this.ctx.canvas.height = h * s;
}
#renderPixels(pixels, width, height) {
const { onColor, offColor, scale: s } = this.opts;
const ctx = this.ctx;
ctx.fillStyle = offColor;
ctx.fillRect(0, 0, width * s, height * s);
ctx.fillStyle = onColor;
for (let y = 0;y < height; y++) {
for (let x = 0;x < width; x++) {
if (pixels[y * width + x]) {
ctx.fillRect(x * s, y * s, s, s);
}
}
}
}
#renderStatic(content) {
this.#resizeCanvas(content.width, content.height);
this.#renderPixels(content.pixels, content.width, content.height);
}
#startAnimation(content) {
this.#resizeCanvas(content.width, content.height);
this.animFrame = 0;
const tick = () => {
if (content.frames.length === 0)
return;
const frame = content.frames[this.animFrame];
this.#renderPixels(frame.pixels, content.width, content.height);
this.animFrame++;
if (this.animFrame >= content.frames.length) {
if (!this.opts.loop)
return;
this.animFrame = 0;
}
this.animTimer = setTimeout(tick, frame.durationMs);
};
tick();
}
#renderText(content) {
const ctx = this.ctx;
const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts;
ctx.canvas.width = dw * s;
ctx.canvas.height = dh * s;
ctx.fillStyle = offColor;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = onColor;
const fontSize = Math.max(8, Math.floor(dh * s * 0.6));
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = "middle";
const padding = 4 * s;
ctx.fillText(content.text, padding, ctx.canvas.height / 2);
}
#startScrollText(content) {
const ctx = this.ctx;
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts;
ctx.canvas.width = dw * s;
ctx.canvas.height = dh * s;
this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width;
const tick = (ts) => {
const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0;
this.scrollLastTs = ts;
const { displayWidth: dw2, displayHeight: dh2, scale: s2, offColor, onColor } = this.opts;
const delta = content.speedPps * s2 * dt * (content.direction === 1 ? 1 : -1);
this.scrollX += delta;
const cpCount = [...content.text].length;
const textWidth = cpCount * 8 * s2;
if (content.direction === 0 && this.scrollX < -textWidth) {
this.scrollX = ctx.canvas.width;
} else if (content.direction === 1 && this.scrollX > ctx.canvas.width) {
this.scrollX = -textWidth;
}
ctx.fillStyle = offColor;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = onColor;
const fontSize = Math.max(8, Math.floor(dh2 * s2 * 0.6));
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = "middle";
ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2);
this.scrollRaf = requestAnimationFrame(tick);
};
this.scrollRaf = requestAnimationFrame(tick);
}
}
class MonoDisplayDriver {
canvas;
opts;
parser;
renderer = null;
constructor(canvasId, options = {}) {
const el = document.getElementById(canvasId);
if (!el || el.tagName !== "CANVAS") {
throw new Error(`MonoDisplayDriver: element #${canvasId} is not a <canvas>`);
}
this.canvas = el;
this.opts = {
onColor: options.onColor ?? "#ffffff",
offColor: options.offColor ?? "#000000",
scale: options.scale ?? 1,
displayWidth: options.displayWidth ?? 160,
displayHeight: options.displayHeight ?? 80,
loop: options.loop ?? true,
onError: options.onError ?? ((e) => {
throw e;
})
};
this.parser = new MonoDisplayParser;
}
async load(loader) {
try {
const buffer = await loader();
const content = this.parser.parse(buffer);
if (!this.renderer) {
this.renderer = new MonoDisplayRenderer(this.canvas, this.opts);
}
this.renderer.render(content);
} catch (err) {
this.opts.onError(err instanceof Error ? err : new Error(String(err)));
}
}
stop() {
this.renderer?.stop();
}
}
function buildBinBuffer(content) {
if (content.type !== "static") {
throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`);
}
const headerSize = 4 + 1 + 1 + 2 + 2;
const pixelSize = content.width * content.height;
const buf = new ArrayBuffer(headerSize + pixelSize);
const view = new DataView(buf);
view.setUint32(0, MAGIC, true);
view.setUint8(4, 1);
view.setUint8(5, 0);
view.setUint16(6, content.width, true);
view.setUint16(8, content.height, true);
new Uint8Array(buf, headerSize).set(content.pixels);
return buf;
}
// demo.ts
function makeCheckerboard() {
const W = 160, H = 80;
const pixels = new Uint8Array(W * H);
for (let y = 0;y < H; y++)
for (let x = 0;x < W; x++)
pixels[y * W + x] = (x + y) % 2;
return buildBinBuffer({ type: "static", width: W, height: H, pixels });
}
function makeAnimation() {
const W = 160, H = 80;
const on = new Uint8Array(W * H).fill(1);
const off = new Uint8Array(W * H).fill(0);
const MAGIC2 = new Uint8Array([77, 79, 78, 79]);
const header = new Uint8Array(10);
header.set(MAGIC2, 0);
header[4] = 1;
header[5] = 1;
new DataView(header.buffer).setUint16(6, W, true);
new DataView(header.buffer).setUint16(8, H, true);
const frameCount = new Uint8Array(2);
new DataView(frameCount.buffer).setUint16(0, 2, true);
function frame(pixels, durationMs) {
const dur = new Uint8Array(4);
new DataView(dur.buffer).setUint32(0, durationMs, true);
const f = new Uint8Array(4 + pixels.byteLength);
f.set(dur, 0);
f.set(pixels, 4);
return f;
}
const parts = [header, frameCount, frame(on, 500), frame(off, 500)];
const total = parts.reduce((s, p) => s + p.byteLength, 0);
const buf = new Uint8Array(total);
let off2 = 0;
for (const p of parts) {
buf.set(p, off2);
off2 += p.byteLength;
}
return buf.buffer;
}
function makeTextBin(text, align = 1) {
const enc = new TextEncoder().encode(text);
const MAGIC2 = new Uint8Array([77, 79, 78, 79]);
const header = new Uint8Array(10);
header.set(MAGIC2, 0);
header[4] = 1;
header[5] = 2;
const textLen = new Uint8Array(2);
new DataView(textLen.buffer).setUint16(0, enc.byteLength, true);
const tail = new Uint8Array([0, align]);
const total = 10 + 2 + enc.byteLength + 2;
const out = new Uint8Array(total);
let o = 0;
out.set(header, o);
o += 10;
out.set(textLen, o);
o += 2;
out.set(enc, o);
o += enc.byteLength;
out.set(tail, o);
return out.buffer;
}
function makeScrollTextBin(text, speedPps = 60, direction = 0) {
const enc = new TextEncoder().encode(text);
const MAGIC2 = new Uint8Array([77, 79, 78, 79]);
const header = new Uint8Array(10);
header.set(MAGIC2, 0);
header[4] = 1;
header[5] = 3;
const textLen = new Uint8Array(2);
new DataView(textLen.buffer).setUint16(0, enc.byteLength, true);
const speed = new Uint8Array(2);
new DataView(speed.buffer).setUint16(0, speedPps, true);
const total = 10 + 2 + enc.byteLength + 2 + 1;
const out = new Uint8Array(total);
let o = 0;
out.set(header, o);
o += 10;
out.set(textLen, o);
o += 2;
out.set(enc, o);
o += enc.byteLength;
out.set(speed, o);
o += 2;
out[o] = direction;
return out.buffer;
}
var driver = new MonoDisplayDriver("canvas_root", {
onColor: "#33ff66",
offColor: "#0a0a0a"
});
var demos = {
"Checkerboard (static)": makeCheckerboard,
"Blink (animation)": makeAnimation,
"Text (centered)": () => makeTextBin("Hello, World! \uD83D\uDFE2", 1),
"Scrolltext (left)": () => makeScrollTextBin("MONO DISPLAY — scrolling ticker test — 日本語テスト \uD83D\uDE80 ", 80, 0)
};
var controls = document.getElementById("controls");
var active = null;
for (const [label, factory] of Object.entries(demos)) {
const btn = document.createElement("button");
btn.textContent = label;
btn.onclick = () => {
active?.classList.remove("active");
btn.classList.add("active");
active = btn;
driver.load(() => Promise.resolve(factory()));
};
controls.appendChild(btn);
}
controls.firstElementChild?.click();

@ -0,0 +1,88 @@
// ---------------------------------------------------------------------------
// MonoDisplayRenderer - tick-based canvas renderer
// ---------------------------------------------------------------------------
import { MonoDisplayParser } from "./parser";
import { MonoDisplayRenderer } from "./renderer";
/** Optional constructor arguments for MonoDisplayDriver */
export interface MonoDisplayDriverOptions {
/** CSS colour for on-pixels. Default "#ffffff" */
onColor?: string;
/** CSS colour for off-pixels / background. Default "#000000" */
offColor?: string;
/**
* Scale factor - each logical pixel NxN canvas pixels.
* Keep at 1 and use CSS to stretch for crisp upscaling.
*/
scale?: number;
/** Default display width for elements without inherent size. Default 160. */
displayWidth?: number;
/** Default display height. Default 80. */
displayHeight?: number;
/**
* Render rate in ticks per second. All animations and scrolls are driven by this.
* Animation updateInterval, scroll speed, etc. are relative to this tick rate.
* Default 25.
*/
fps?: number;
/** Loop animations. Default true. */
loop?: boolean;
/** Error handler. Default: throw. */
onError?: (err: Error) => void;
}
// ---------------------------------------------------------------------------
// MonoDisplayDriver - public API surface
// ---------------------------------------------------------------------------
/**
* Top-level driver. Attach to a canvas by ID, call load() with an async
* factory that returns a .bin ArrayBuffer.
*
* @example
* ```ts
* const driver = new MonoDisplayDriver("canvas_root", { fps: 25 });
* driver.load(() => loadBinFile("display.bin"));
* ```
*/
export class MonoDisplayDriver {
private canvas: HTMLCanvasElement;
private opts: Required<MonoDisplayDriverOptions>;
private parser: MonoDisplayParser;
private renderer: MonoDisplayRenderer | null = null;
constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) {
const el = document.getElementById(canvasId);
if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a <canvas>`);
this.canvas = el as HTMLCanvasElement;
this.opts = {
onColor: options.onColor ?? "#ffffff",
offColor: options.offColor ?? "#000000",
scale: options.scale ?? 1,
displayWidth: options.displayWidth ?? 160,
displayHeight: options.displayHeight ?? 80,
fps: options.fps ?? 25,
loop: options.loop ?? true,
onError: options.onError ?? ((e: Error) => { throw e; }),
};
this.parser = new MonoDisplayParser();
}
async load(loader: () => Promise<ArrayBuffer>): Promise<void> {
try {
const buffer = await loader();
const file = this.parser.parse(buffer);
if (!this.renderer) this.renderer = new MonoDisplayRenderer(this.canvas, this.opts);
this.renderer.render(file);
} catch (err) {
this.opts.onError(err instanceof Error ? err : new Error(String(err)));
}
}
stop(): void { this.renderer?.stop(); }
}

@ -0,0 +1,260 @@
// ---------------------------------------------------------------------------
// MonoDisplayFile - JSON descriptor → binary .bin builder
// ---------------------------------------------------------------------------
import { packedSize, packPixels, pad32 } from "./helper";
import { MONOFORMAT_MAGIC_HEADER } from "./parser";
import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types";
/**
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer.
*
* elements_always Section type 1 (ElementsAlways).
* Pass a DrawElement[] or { flags, elements }.
* Default flags: drawFront=true, drawBack=false, clearBuffer=false.
*
* PLAN: encode ElementsTimespan sections, CustomFont sections.
*/
export class MonoDisplayFile {
private desc: MonoDisplayFileDescriptor;
constructor(descriptor: MonoDisplayFileDescriptor) {
this.desc = descriptor;
}
toBuffer(): Uint8Array<ArrayBufferLike> {
const sections: Uint8Array[] = [];
// Section 1: elements always
const alwaysDesc = this.desc.elements_always;
if (alwaysDesc) {
const isArray = Array.isArray(alwaysDesc);
const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements;
const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags;
sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements));
}
// File header (12 bytes)
const hdrBuf = new ArrayBuffer(12);
const hdrView = new DataView(hdrBuf);
hdrView.setUint32(0, MONOFORMAT_MAGIC_HEADER, true);
hdrView.setUint32(4, 1, true); // version
hdrView.setUint16(8, sections.length, true);
hdrView.setUint16(10, 0, true); // reserved
return this.#concat(new Uint8Array(hdrBuf), ...sections);
}
// --- section encoders ---
#encodeElementsSection(
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
flags: SectionFlags | undefined,
elements: DrawElement[],
): Uint8Array {
const flagBits =
(flags?.drawFront ?? true ? 0x01 : 0) |
(flags?.drawBack ?? false ? 0x02 : 0) |
(flags?.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, type);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, elements.length, true); // numElements
return this.#concat(hdr, ...encodedEls);
}
// --- element encoders ---
#encodeElement(el: DrawElement): Uint8Array {
switch (el.type) {
case ElementType.Image2D: return this.#encodeImage2D(el);
case ElementType.Animation: return this.#encodeAnimation(el);
case ElementType.HorizontalScroll: return this.#encodeHScroll(el);
case ElementType.VerticalScroll: return this.#encodeVScroll(el);
case ElementType.Line: return this.#encodeLine(el);
case ElementType.ClippedText: return this.#encodeClippedText(el);
case ElementType.HScrollText: return this.#encodeHScrollText(el);
default: return new Uint8Array();
}
}
#encodeImage2D(el: Image2DElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.height);
const fixed = 12; // bytes before pixel data
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Image2D, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, 0, true); // reserved
out.set(packed, 12);
return out;
}
#encodeAnimation(el: AnimationElement): Uint8Array {
const frameBytes = packedSize(el.width, el.height);
const fixed = 16;
const rawSize = fixed + el.frames.length * frameBytes;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Animation, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.frames.length, true);
v.setUint16(12, el.updateInterval ?? 0, true);
v.setUint16(14, 0, true); // reserved
let off = 16;
for (const f of el.frames) {
out.set(packPixels(f.pixels, el.width, el.height), off);
off += frameBytes;
}
return out;
}
#encodeScrollFlags(f: ScrollElementFlags | undefined): number {
return (
(f?.endless ? 0x01 : 0) |
(f?.invertDirection ? 0x02 : 0) |
(f?.padStart ? 0x04 : 0) |
(f?.padEnd ? 0x08 : 0)
);
}
#encodeHScroll(el: HScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.contentWidth, el.height);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.HorizontalScroll, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.contentWidth, true);
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
v.setUint8( 13, el.scrollSpeed ?? 0);
v.setUint16(14, 0, true); // reserved
out.set(packed, 16);
return out;
}
#encodeVScroll(el: VScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.contentHeight);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.VerticalScroll, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.contentHeight, true);
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
v.setUint8( 13, el.scrollSpeed ?? 0);
v.setUint16(14, 0, true);
out.set(packed, 16);
return out;
}
#encodeLine(el: LineElement): Uint8Array {
// 12 bytes, already 32-bit aligned
const out = new Uint8Array(12);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Line, true);
v.setUint16(2, el.xOrigin, true);
v.setUint16(4, el.yOrigin, true);
v.setUint16(6, el.xTarget, true);
v.setUint16(8, el.yTarget, true);
v.setUint8(10, el.lineStyle ?? 0);
v.setUint8(11, el.invertPixels ? 1 : 0);
return out;
}
#encodeClippedText(el: ClippedTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 14; // 2+2+2+2+2+2+2
const rawSize = fixed + enc.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.ClippedText, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.fontIndex ?? 0, true);
v.setUint16(12, enc.byteLength, true);
out.set(enc, 14);
return out;
}
#encodeHScrollText(el: HScrollTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 16; // 2+2+2+2+2+1+1+2+2
const rawSize = fixed + enc.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.HScrollText, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint8( 10, this.#encodeScrollFlags(el.flags));
v.setUint8( 11, el.scrollSpeed ?? 0);
v.setUint16(12, el.fontIndex ?? 0, true);
v.setUint16(14, enc.byteLength, true); // PLAN: confirm with spec
out.set(enc, 16);
return out;
}
// --- utilities ---
#concat(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((s, p) => s + p.byteLength, 0);
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) { out.set(p, off); off += p.byteLength; }
return out;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export async function loadBinFile(url: string): Promise<ArrayBuffer> {
const res = await fetch(url);
if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`);
return res.arrayBuffer();
}
/**
* Build a single-element-always .bin buffer containing one Image2D element.
* Convenience for tests; for production use MonoDisplayFile directly.
*/
export function buildBinBuffer(el: Image2DElement): ArrayBuffer {
return new MonoDisplayFile({ elements_always: [el] }).toBuffer();
}

@ -0,0 +1,102 @@
// ---------------------------------------------------------------------------
// Pixel pack/unpack helpers
// ---------------------------------------------------------------------------
/**
* Pack 1-byte-per-pixel array into 1bpp.
* Excess bits in the last byte are set to 0.
* Size = ceil(width * height / 8).
*/
export function packPixels(pixels: Uint8Array, width: number, height: number): Uint8Array {
const total = width * height;
const packed = new Uint8Array((total + 7) >> 3);
for (let i = 0; i < total; i++) {
if (pixels[i]) packed[i >> 3] |= 1 << (i & 7);
}
return packed;
}
/**
* Unpack 1bpp data to 1-byte-per-pixel. Excess bits beyond width*height are ignored.
*/
export function unpackPixels(packed: Uint8Array, width: number, height: number): Uint8Array {
const total = width * height;
const pixels = new Uint8Array(total);
for (let i = 0; i < total; i++) {
pixels[i] = (packed[i >> 3] >> (i & 7)) & 1;
}
return pixels;
}
/** Byte count of packed 1bpp image */
export function packedSize(width: number, height: number): number {
return (width * height + 7) >> 3;
}
/** Padding needed to align `byteCount` to a 4-byte boundary */
export function pad32(byteCount: number): number {
return (4 - (byteCount & 3)) & 3;
}
// ---------------------------------------------------------------------------
// BinaryReader — little-endian cursor over an ArrayBuffer
// ---------------------------------------------------------------------------
export class BinaryReader {
private view: DataView;
private pos: number = 0;
constructor(buffer: ArrayBuffer | ArrayBufferView) {
this.view = buffer instanceof ArrayBuffer
? new DataView(buffer)
: new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
}
get offset(): number { return this.pos; }
get byteLength(): number { return this.view.byteLength; }
get remaining(): number { return this.view.byteLength - this.pos; }
readByte(): number { this.#need(1); return this.view.getUint8(this.pos++); }
readUint16(): number { this.#need(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; }
readShort(): number { return this.readUint16(); }
readInt16(): number { this.#need(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; }
readUint24(): number {
this.#need(3);
const v = this.view.getUint8(this.pos) | (this.view.getUint8(this.pos+1) << 8) | (this.view.getUint8(this.pos+2) << 16);
this.pos += 3;
return v;
}
readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; }
readUint64(): bigint {
this.#need(8);
const lo = BigInt(this.view.getUint32(this.pos, true));
const hi = BigInt(this.view.getUint32(this.pos+4, true));
this.pos += 8;
return (hi << 32n) | lo;
}
readBytes(n: number): Uint8Array {
this.#need(n);
const s = new Uint8Array(this.view.buffer, this.pos, n);
this.pos += n;
return s;
}
readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); }
seek(pos: number): void {
if (pos < 0 || pos > this.view.byteLength) {
throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`);
}
this.pos = pos;
}
#need(n: number): void {
if (this.remaining < n) throw new RangeError(
`BinaryReader: need ${n} byte(s) at offset ${this.pos}, ${this.remaining} remain`
);
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,254 @@
// ---------------------------------------------------------------------------
// File format constants
// ---------------------------------------------------------------------------
import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper";
import { type MonoFormatFile } from "./library";
import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types";
// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE
export const MONOFORMAT_MAGIC_HEADER = 0x632B7EAF;
// ---------------------------------------------------------------------------
// MonoDisplayParser
// ---------------------------------------------------------------------------
export class MonoDisplayParser {
parse(buffer: ArrayBuffer | ArrayBufferView): MonoFormatFile {
const r = new BinaryReader(buffer);
// File header (12 bytes)
const magic = r.readUint32();
if (magic !== MONOFORMAT_MAGIC_HEADER) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`);
const version = r.readUint32();
if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`);
const numSections = r.readUint16();
r.readUint16(); // reserved
const sections: MonoFormatSection[] = [];
for (let i = 0; i < numSections; i++) {
const sectionType = r.readByte();
const sectionSize = r.readUint24(); // INCLUDES 4-byte header
const sectionStart = r.offset; // start of section DATA
const dataSize = sectionSize - 4;
switch (sectionType) {
case SectionType.ElementsAlways:
sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false));
break;
case SectionType.ElementsTimespan:
sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true));
break;
case SectionType.CustomFont:
sections.push(this.#parseCustomFont(r, dataSize));
break;
default:
// Unknown — skip
break;
}
// Seek past full section data (handles padding, unknown fields, future extensions)
r.seek(sectionStart + dataSize);
}
return { sections };
}
#parseFlags(raw: number): MonoFormatSectionFlags {
return {
drawFront: !!(raw & 0x01),
drawBack: !!(raw & 0x02),
clearBuffer: !!(raw & 0x04),
};
}
#parseElementsSection(
r: BinaryReader,
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
hasTimestamp: boolean,
): MonoFormatElementsAlways | MonoFormatElementsTimespan {
const flags = this.#parseFlags(r.readUint16());
const numElements = r.readUint16();
let startTimestamp = 0n, endTimestamp = 0n;
if (hasTimestamp) {
startTimestamp = r.readUint64();
endTimestamp = r.readUint64();
}
const elements: MonoFormatElement[] = [];
for (let i = 0; i < numElements; i++) {
const el = this.#parseElement(r);
if (el) elements.push(el);
}
if (hasTimestamp) {
return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements };
}
return { sectionType: SectionType.ElementsAlways, flags, elements };
}
#parseCustomFont(r: BinaryReader, dataSize: number): MonoFormatCustomFont {
const actualSize = r.readUint24();
r.readByte(); // reserved
const fontData = new Uint8Array(r.readBytes(Math.min(actualSize, dataSize - 4)));
return { sectionType: SectionType.CustomFont, fontData };
}
#parseElement(r: BinaryReader): MonoFormatElement | null {
const elementStart = r.offset;
const type = r.readUint16();
switch (type) {
case ElementType.Image2D: return this.#parseImage2D(r, elementStart);
case ElementType.Animation: return this.#parseAnimation(r, elementStart);
case ElementType.HorizontalScroll: return this.#parseHScroll(r, elementStart);
case ElementType.VerticalScroll: return this.#parseVScroll(r, elementStart);
case ElementType.Line: return this.#parseLine(r);
case ElementType.ClippedText: return this.#parseClippedText(r, elementStart);
case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart);
default:
// Unknown element type — cannot safely skip without knowing size; stop parsing section elements
return null;
}
}
#alignTo4(start: number, current: number): void {
// elements are 32-bit aligned; seek past padding if any
const end = start + (((current - start) + 3) & ~3);
// no-op if already aligned (handled by caller via section seek)
}
#parseImage2D(r: BinaryReader, start: number): MonoFormatImage2D {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
r.readUint16(); // reserved
// Fixed header: 2(type)+2+2+2+2+2 = 12 bytes
const packed = r.readBytes(packedSize(width, height));
const pixels = unpackPixels(new Uint8Array(packed), width, height);
// Skip padding to 32-bit boundary for the element
const dataRead = 12 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.Image2D, xOffset, yOffset, image: { pixels, width, height } };
}
#parseAnimation(r: BinaryReader, start: number): MonoFormatAnimation {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const numFrames = r.readUint16();
const updateInterval = r.readUint16();
r.readUint16(); // reserved
// Fixed header: 2+2+2+2+2+2+2+2 = 16 bytes
const frameBytes = packedSize(width, height);
const frames: MonoFormatPixelImage[] = [];
for (let f = 0; f < numFrames; f++) {
const packed = r.readBytes(frameBytes);
frames.push({ pixels: unpackPixels(new Uint8Array(packed), width, height), width, height });
}
const dataRead = 16 + numFrames * frameBytes;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.Animation, xOffset, yOffset, width, height, updateInterval, frames };
}
#parseScrollFlags(raw: number) {
return {
endless: !!(raw & 0x01),
invertDirection: !!(raw & 0x02),
padStart: !!(raw & 0x04),
padEnd: !!(raw & 0x08),
};
}
#parseHScroll(r: BinaryReader, start: number): MonoFormatHScroll {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const contentWidth = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
r.readUint16(); // reserved
// Fixed header: 2+2+2+2+2+2+1+1+2 = 16 bytes
const packed = r.readBytes(packedSize(contentWidth, height));
const pixels = unpackPixels(new Uint8Array(packed), contentWidth, height);
const dataRead = 16 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.HorizontalScroll, xOffset, yOffset, width, height, contentWidth,
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
content: { pixels, width: contentWidth, height },
};
}
#parseVScroll(r: BinaryReader, start: number): MonoFormatVScroll {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const contentHeight = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
r.readUint16(); // reserved
const packed = r.readBytes(packedSize(width, contentHeight));
const pixels = unpackPixels(new Uint8Array(packed), width, contentHeight);
const dataRead = 16 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.VerticalScroll, xOffset, yOffset, width, height, contentHeight,
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
content: { pixels, width, height: contentHeight },
};
}
#parseLine(r: BinaryReader): MonoFormatLine {
const xOrigin = r.readUint16();
const yOrigin = r.readUint16();
const xTarget = r.readUint16();
const yTarget = r.readUint16();
const lineStyle = r.readByte();
const flagsByte = r.readByte();
// 2+2+2+2+2+1+1 = 12 bytes, already 32-bit aligned
return { type: ElementType.Line, xOrigin, yOrigin, xTarget, yTarget, lineStyle, invertPixels: !!(flagsByte & 1) };
}
#parseClippedText(r: BinaryReader, start: number): MonoFormatClippedText {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const fontIndex = r.readUint16();
const textLen = r.readUint16();
// Fixed header: 2+2+2+2+2+2+2 = 14 bytes
const text = r.readUtf8(textLen);
const dataRead = 14 + textLen;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text };
}
#parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
const fontIndex = r.readUint16();
const textLen = r.readUint16(); // PLAN: confirm with spec (assumed, not explicit in diagram)
// Fixed header: 2+2+2+2+2+1+1+2+2 = 16 bytes
const text = r.readUtf8(textLen);
const dataRead = 16 + textLen;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.HScrollText, xOffset, yOffset, width, height,
scrollSpeed, fontIndex, flags: this.#parseScrollFlags(flagsByte), text,
};
}
}

@ -0,0 +1,251 @@
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";
/**
* 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);
}
}
Loading…
Cancel
Save