Files
mode7/js/core/teletext.js
T

992 lines
42 KiB
JavaScript

/* teletext.js - MODE 7-ish canvas terminal with CRT-style rendering and key handling */
export class TeletextTerminal { // Export the terminal class
constructor(canvas, opts = {}) { // Build a new terminal
this.canvas = canvas; // Store canvas ref
this.ctx = this.canvas.getContext("2d", { alpha: false }); // Get 2D context (opaque for speed)
this.cols = Number(opts.cols ?? 40); // Column count
this.rows = Number(opts.rows ?? 25); // Row count
this._fastForward = false; // Whether output is currently fast-forwarding
this._fastForwardUntil = 0; // Timestamp until fast-forward expires
this._fastForwardMs = 1200; // How long Enter keeps fast-forward active
this._fastForwardAnyKey = false; // If true, any key triggers fast-forward (Enter always does)
this._inputMode = false; // True when the system is waiting for user input (no delays)
this._skipDelays = false; // True when we want instant output temporarily
this.scale = Number(opts.scale ?? 2); // Integer-ish scaling for chunky pixels
this.cellW = Number(opts.cellW ?? 8) * this.scale; // Cell width in pixels
this.cellH = Number(opts.cellH ?? 12) * this.scale; // Cell height in pixels
this.padX = Number(opts.padX ?? 8) * this.scale; // Padding X
this.padY = Number(opts.padY ?? 8) * this.scale; // Padding Y
this.fontFamily = String(opts.fontFamily ?? '"TeletextOTF", "Courier New", ui-monospace, monospace'); // Prefer the custom OTF, then fall back
this._busy = false; // True while the terminal is actively “typing” output
this._cursorEnabled = true; // Whether to draw the cursor
this._cursorOn = true; // Current blink state
this._cursorBlinkMs = 420; // Cursor blink period
this._cursorLastToggle = performance.now(); // Cursor blink timer base
this.fontSize = Number(opts.fontSize ?? (12 * this.scale)); // Font size scaled to cells
this.fontWeight = String(opts.fontWeight ?? "700"); // Heavier to feel teletext-y
this.canvas.width = this.padX * 2 + this.cols * this.cellW; // Set internal pixel width
this.canvas.height = this.padY * 2 + this.rows * this.cellH; // Set internal pixel height
this.bg = String(opts.bg ?? "#000000"); // Background colour
this.defaultFg = String(opts.defaultFg ?? "#FFFFFF"); // Default foreground colour
this.palette = { // Teletext-like palette
BLACK: "#000000",
RED: "#FF3B30",
GREEN: "#34C759",
YELLOW: "#FFCC00",
BLUE: "#0A84FF",
MAGENTA: "#FF2D55",
CYAN: "#5AC8FA",
WHITE: "#FFFFFF"
}; // End palette
this.CC = { // Control code map (encoded as characters 1..10)
ALPHA_BLACK: 1,
ALPHA_RED: 2,
ALPHA_GREEN: 3,
ALPHA_YELLOW: 4,
ALPHA_BLUE: 5,
ALPHA_MAGENTA: 6,
ALPHA_CYAN: 7,
ALPHA_WHITE: 8,
FLASH: 9,
STEADY: 10
}; // End CC map
this.buffer = this._makeBuffer(); // Create screen buffer
this.cursorX = 0; // Cursor column
this.cursorY = 0; // Cursor row
this.area = { x: 1, y: 4, w: 38, h: 16 }; // Default print area
this.baseArea = { ...this.area }; // Base area for tighten()
this.curFg = this.defaultFg; // Current foreground colour
this.curFlash = false; // Flash attribute
this.flashPhase = true; // Whether flashing chars are visible
this.flashMs = 520; // Flash period in milliseconds
this._lastFlashToggle = performance.now(); // Flash timer base
this._pressure = 0; // Pressure (0..1) for micro-corruption
this._lastFlickerAt = 0; // Last flicker timestamp
this._keyQueue = []; // Key event queue
this._keyWaiters = []; // Promises waiting for a key
this._maxKeyQueue = 64; // Bound buffered input so key repeat cannot grow memory indefinitely
this._audioCtx = null; // AudioContext (lazy)
this._muted = false; // Mute flag
this._backdropOn = true; // Backdrop flag
this._running = false; // Render loop running flag
this._raf = 0; // requestAnimationFrame id
this._textLayer = document.createElement("canvas"); // Offscreen text canvas
this._textCtx = this._textLayer.getContext("2d", { alpha: true }); // Text layer context
this._textLayer.width = this.canvas.width; // Match dimensions
this._textLayer.height = this.canvas.height; // Match dimensions
this._dirtyText = true; // Text layer dirty flag
this._noiseSeed = 0; // Noise seed
this._applyFont(); // Apply font settings
this._bindKeyboard(); // Bind keyboard input
this.forceRedraw(); // Draw initial state
} // End constructor
setInputMode(on) { // Enable/disable input mode (no delays when on)
this._inputMode = Boolean(on); // Store
this._busy = !this._inputMode; // Cursor psychology: waiting when in input mode
} // End setInputMode
fastForwardNow(ms = 2000) { // Force fast output for a short window
this._fastForward = true; // Enable fast-forward
this._fastForwardUntil = Date.now() + Math.max(0, Number(ms) || 0); // Set expiry
} // End fastForwardNow
skipDelaysOnce() { // Skip delays for the next procLINE/pause chunk
this._skipDelays = true; // Arm one-shot
} // End skipDelaysOnce
flushKeys() { // Clear any queued key events (prevents “typing early” from spilling into input)
this._keyQueue.length = 0; // Empty the queue in-place
} // End flushKeys
_peekQueuedKey() { // Look at the next queued key without removing it
return this._keyQueue.length > 0 ? this._keyQueue[0] : null; // Return first key or null
} // End _peekQueuedKey
_consumeQueuedKey() { // Remove and return the next queued key
return this._keyQueue.length > 0 ? this._keyQueue.shift() : null; // Shift or null
} // End _consumeQueuedKey
_enableFastForward() { // Enable fast-forward mode for a short window
this._fastForward = true; // Turn fast-forward on
this._fastForwardUntil = Date.now() + this._fastForwardMs; // Set an expiry timestamp
} // End _enableFastForward
_clearOldAreaOutsideNew(oldArea, newArea) { // Clear cells that were inside oldArea but are outside newArea
const oldLeft = oldArea.x; // Old left bound
const oldTop = oldArea.y; // Old top bound
const oldRight = oldArea.x + oldArea.w - 1; // Old right bound (inclusive)
const oldBottom = oldArea.y + oldArea.h - 1; // Old bottom bound (inclusive)
const newLeft = newArea.x; // New left bound
const newTop = newArea.y; // New top bound
const newRight = newArea.x + newArea.w - 1; // New right bound (inclusive)
const newBottom = newArea.y + newArea.h - 1; // New bottom bound (inclusive)
for (let y = oldTop; y <= oldBottom; y += 1) { // Walk all rows of the old area
for (let x = oldLeft; x <= oldRight; x += 1) { // Walk all cols of the old area
const insideNew = (x >= newLeft && x <= newRight && y >= newTop && y <= newBottom); // Test if this cell is inside the new area
if (insideNew) continue; // Keep anything still inside the new area
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank the cell (removes stale text)
} // End col loop
} // End row loop
this.forceRedraw(); // Ensure the cleared ring is visible immediately
} // End _clearOldAreaOutsideNew
_applyFont() { // Apply current font settings
this.ctx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Main font
this.ctx.textBaseline = "top"; // Cell top alignment
this._textCtx.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Offscreen font
this._textCtx.textBaseline = "top"; // Offscreen baseline
} // End _applyFont
_makeBuffer() { // Create a 2D array of cells
const buf = []; // Buffer accumulator
for (let y = 0; y < this.rows; y += 1) { // For each row
const row = []; // Build a row
for (let x = 0; x < this.cols; x += 1) { // For each column
row.push({ ch: " ", fg: this.defaultFg, flash: false }); // Default cell
} // End col loop
buf.push(row); // Push row
} // End row loop
return buf; // Return buffer
} // End _makeBuffer
T(code) { // Convert a numeric code to a 1-char token
return String.fromCharCode(code); // Encode code as a character
} // End T
start() { // Start the render loop
if (this._running) return; // Avoid double-start
this._running = true; // Mark running
this._ensureFontLoaded().then(() => { // Load the font in the background
this._applyFont(); // Re-apply fonts to both contexts once loaded
this.forceRedraw(); // Force a re-render using the correct font
}); // End font load chain
this._tick(performance.now()); // Kick off
} // End start
stop() { // Stop the render loop
this._running = false; // Mark stopped
if (this._raf) cancelAnimationFrame(this._raf); // Cancel RAF if present
this._raf = 0; // Clear id
} // End stop
forceRedraw() { // Force a full redraw
this._dirtyText = true; // Mark text layer dirty
} // End forceRedraw
setPressure(p) { // Set pressure (0..1)
const n = Number(p) || 0; // Normalise number
this._pressure = Math.max(0, Math.min(1, n)); // Clamp into range
} // End setPressure
toggleMute() { // Toggle mute
this._muted = !this._muted; // Flip
} // End toggleMute
toggleBackdrop() { // Toggle backdrop
this._backdropOn = !this._backdropOn; // Flip
} // End toggleBackdrop
setPrintArea(x, y, w, h) { // Set the print area, clearing the previous screen fully
// Clear the entire screen so no old text survives when the window moves or shrinks.
this.clearScreen(); // Blank everything and reset attributes/cursor
this.area = { x, y, w, h }; // Store new area
this.baseArea = { x, y, w, h }; // Update base for tighten()
this.cursorX = this.area.x; // Reset cursor to new area origin
this.cursorY = this.area.y; // Reset cursor to new area origin
this.forceRedraw(); // Redraw with the fresh area
} // End setPrintArea
tighten(step) { // Tighten the print area inward while clearing old text outside the new area
const s = Math.max(0, Math.min(10, Number(step) || 0)); // Clamp tighten step to a safe range
const oldArea = { ...this.area }; // Snapshot the current (old) area before changing it
const bx = this.baseArea.x; // Base x (original area anchor)
const by = this.baseArea.y; // Base y (original area anchor)
const bw = this.baseArea.w; // Base width
const bh = this.baseArea.h; // Base height
const nx = bx + s; // New x after tightening
const ny = by + s; // New y after tightening
const nw = Math.max(10, bw - s * 2); // New width after tightening (minimum so it stays usable)
const nh = Math.max(6, bh - s * 2); // New height after tightening (minimum so it stays usable)
const newArea = { x: nx, y: ny, w: nw, h: nh }; // Build the new area object
this.area = newArea; // Apply the new print area
this._clearOldAreaOutsideNew(oldArea, newArea); // Clear anything that used to be visible but is now outside the new area
this.cursorX = this.area.x; // Reset cursor to the new left edge
this.cursorY = this.area.y; // Reset cursor to the new top edge
this.forceRedraw(); // Force redraw in case cursor/attributes need repainting
} // End tighten
procCLEAR() { // Clear only the print area
for (let y = this.area.y; y < this.area.y + this.area.h; y += 1) { // Rows
for (let x = this.area.x; x < this.area.x + this.area.w; x += 1) { // Cols
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank cell
} // End cols
} // End rows
this.cursorX = this.area.x; // Reset cursor
this.cursorY = this.area.y; // Reset cursor
this.curFg = this.defaultFg; // Reset fg
this.curFlash = false; // Reset flash
this.forceRedraw(); // Redraw
} // End procCLEAR
clearScreen() { // Clear entire screen
for (let y = 0; y < this.rows; y += 1) { // Rows
for (let x = 0; x < this.cols; x += 1) { // Cols
this.buffer[y][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank
} // End cols
} // End rows
this.cursorX = 0; // Reset cursor
this.cursorY = 0; // Reset cursor
this.curFg = this.defaultFg; // Reset fg
this.curFlash = false; // Reset flash
this.forceRedraw(); // Redraw
} // End clearScreen
printStreamAt(x, y, text) { // Print a stream at fixed coordinates
let cx = x; // Local x
let cy = y; // Local y
for (let i = 0; i < text.length; i += 1) { // Walk text
const ch = text[i]; // Current char
const code = ch.charCodeAt(0); // Char code
if (this._applyControlCode(code)) continue; // Apply control codes
if (cx >= 0 && cx < this.cols && cy >= 0 && cy < this.rows) { // Bounds check
this.buffer[cy][cx] = { ch, fg: this.curFg, flash: this.curFlash }; // Write
} // End bounds
cx += 1; // Advance column
if (cx >= this.cols) { // Wrap screen edge
cx = 0; // Reset x
cy += 1; // Next row
if (cy >= this.rows) break; // Stop if off screen
} // End wrap
} // End loop
this.forceRedraw(); // Redraw
} // End printStreamAt
_applyControlCode(code) { // Apply control codes 1..10
if (code < 1 || code > 10) return false; // Not a control code
if (code === this.CC.ALPHA_BLACK) this.curFg = this.palette.BLACK; // Set fg
if (code === this.CC.ALPHA_RED) this.curFg = this.palette.RED; // Set fg
if (code === this.CC.ALPHA_GREEN) this.curFg = this.palette.GREEN; // Set fg
if (code === this.CC.ALPHA_YELLOW) this.curFg = this.palette.YELLOW; // Set fg
if (code === this.CC.ALPHA_BLUE) this.curFg = this.palette.BLUE; // Set fg
if (code === this.CC.ALPHA_MAGENTA) this.curFg = this.palette.MAGENTA; // Set fg
if (code === this.CC.ALPHA_CYAN) this.curFg = this.palette.CYAN; // Set fg
if (code === this.CC.ALPHA_WHITE) this.curFg = this.palette.WHITE; // Set fg
if (code === this.CC.FLASH) this.curFlash = true; // Flash on
if (code === this.CC.STEADY) this.curFlash = false; // Flash off
return true; // Consumed
} // End _applyControlCode
_scrollArea() { // Scroll print area up by one row
const top = this.area.y; // Top row index
const bottom = this.area.y + this.area.h - 1; // Bottom row index
const left = this.area.x; // Left col index
const right = this.area.x + this.area.w - 1; // Right col index
for (let y = top; y < bottom; y += 1) { // Shift rows
for (let x = left; x <= right; x += 1) { // Shift columns
this.buffer[y][x] = this.buffer[y + 1][x]; // Copy from next row
} // End cols
} // End rows
for (let x = left; x <= right; x += 1) { // Clear last row
this.buffer[bottom][x] = { ch: " ", fg: this.defaultFg, flash: false }; // Blank
} // End clear row
} // End _scrollArea
_ensureCursorInArea() { // Ensure cursor stays within the area
const maxY = this.area.y + this.area.h - 1; // Maximum allowed row
if (this.cursorY > maxY) { // If cursor fell below
this._scrollArea(); // Scroll the area
this.cursorY = maxY; // Clamp cursor
} // End condition
} // End _ensureCursorInArea
_newlineInArea() { // Move cursor to next line within area
this.cursorX = this.area.x; // Reset x
this.cursorY += 1; // Move down
this._ensureCursorInArea(); // Scroll if needed
} // End _newlineInArea
_writeCharAt(x, y, ch, fg = this.curFg, flash = this.curFlash) { // Write a cell without moving cursor
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return; // Bounds guard
this.buffer[y][x] = { ch, fg, flash }; // Store cell
} // End _writeCharAt
_putCharInArea(ch) { // Put one character into print area
if (this.cursorX >= this.area.x + this.area.w) { // If beyond right edge
this._newlineInArea(); // Wrap
} // End wrap
this._ensureCursorInArea(); // Ensure y
this._writeCharAt(this.cursorX, this.cursorY, ch); // Write cell
this.cursorX += 1; // Advance cursor
} // End _putCharInArea
_tokeniseForWrap(text) { // Tokenise including control codes
const out = []; // Tokens
for (let i = 0; i < text.length; i += 1) { // Walk text
const ch = text[i]; // Char
const code = ch.charCodeAt(0); // Code
if (code >= 1 && code <= 10) out.push({ type: "cc", code }); // Control code token
else out.push({ type: "ch", ch }); // Printable token
} // End loop
return out; // Return
} // End _tokeniseForWrap
_visibleWidth(tokens) { // Count only printable tokens
let w = 0; // Width counter
for (let i = 0; i < tokens.length; i += 1) { // Loop tokens
if (tokens[i].type === "ch") w += 1; // Count printable
} // End loop
return w; // Return width
} // End _visibleWidth
_findLastSpace(tokens) { // Find last space position in tokens
let idx = -1; // Space index
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const t = tokens[i]; // Token
if (t.type === "ch" && t.ch === " ") idx = i; // Record last space
} // End loop
return idx; // Return index
} // End _findLastSpace
_hardSplitByVisible(tokens, maxWidth) { // Split by printable width
const head = []; // Head tokens
const tail = []; // Tail tokens
let w = 0; // Printable width
let inHead = true; // Whether we are in head
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const t = tokens[i]; // Token
if (inHead) { // In head
head.push(t); // Add
if (t.type === "ch") w += 1; // Count printable
if (w >= maxWidth) inHead = false; // Switch once full
} else { // In tail
tail.push(t); // Add
} // End branch
} // End loop
while (tail.length && tail[0].type === "ch" && tail[0].ch === " ") tail.shift(); // Trim leading spaces
return { head, tail }; // Return pieces
} // End _hardSplitByVisible
_wrapTokens(tokens, maxWidth) { // Wrap tokens to width
const lines = []; // Wrapped lines
let current = []; // Current line tokens
let width = 0; // Current printable width
let lastBreakIdx = -1; // Last space index in current
const flush = () => { // Push current line
lines.push(current); // Store current
current = []; // Reset
width = 0; // Reset
lastBreakIdx = -1; // Reset
}; // End flush
for (let i = 0; i < tokens.length; i += 1) { // Walk tokens
const tok = tokens[i]; // Token
current.push(tok); // Append token
if (tok.type === "ch") { // Only printable counts
width += 1; // Increment width
if (tok.ch === " ") lastBreakIdx = current.length - 1; // Record break point
} // End printable
if (width > maxWidth) { // Overflow
if (lastBreakIdx >= 0) { // If we can break on space
const head = current.slice(0, lastBreakIdx); // Before space
const tail = current.slice(lastBreakIdx + 1); // After space
lines.push(head); // Commit head
current = tail; // Continue with tail
width = this._visibleWidth(current); // Recompute width
lastBreakIdx = this._findLastSpace(current); // Recompute break
} else { // Hard wrap
const split = this._hardSplitByVisible(current, maxWidth); // Split
lines.push(split.head); // Commit head
current = split.tail; // Continue
width = this._visibleWidth(current); // Recompute
lastBreakIdx = this._findLastSpace(current); // Recompute break
} // End wrap method
} // End overflow
} // End loop
if (current.length) flush(); // Flush last line
return lines; // Return wrapped lines
} // End _wrapTokens
_ease(p) { // Smoothstep ease
const x = Math.max(0, Math.min(1, p)); // Clamp
return x * x * (3 - 2 * x); // Smoothstep
} // End _ease
_shouldFlickerChar() { // Decide whether to flicker
const base = 0.00055; // Very low base chance
const extra = 0.0060 * this._ease(this._pressure); // Pressure-driven chance
const rate = base + extra; // Final rate
const now = Date.now(); // Current time
const minGap = 140 - Math.floor(this._pressure * 70); // Gap shrinks with pressure
if (now - this._lastFlickerAt < minGap) return false; // Avoid clustering
return Math.random() < rate; // Roll
} // End _shouldFlickerChar
_corruptChar(ch) { // Create a subtle corruption
const code = ch.charCodeAt(0); // Code point
if (code < 32 || code > 126) return ch; // Leave non-printable
const garble = ["░", "▒", "▓", "·", "•"]; // Subtle noise glyphs
const r = Math.random(); // Selector
if (/[a-zA-Z]/.test(ch) && r < 0.55) { // Case flip
return ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
} // End case flip
if (r < 0.86) { // Small ASCII nudge
const delta = Math.random() < 0.5 ? -1 : 1; // +/-1
const next = code + delta; // Shift
return next >= 32 && next <= 126 ? String.fromCharCode(next) : ch; // Clamp
} // End nudge
return garble[Math.floor(Math.random() * garble.length)]; // Rare garble
} // End _corruptChar
_drawCursor() { // Draw a cursor that reflects “working” vs “waiting”
if (!this._cursorEnabled) return; // Respect cursor toggle
if (!this._cursorOn) return; // Blink off phase
const x = this.cursorX; // Cursor cell x
const y = this.cursorY; // Cursor cell y
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return; // Bounds guard
const px = this.padX + x * this.cellW; // Pixel x
const py = this.padY + y * this.cellH; // Pixel y
const p = this._pressure ?? 0; // Pressure (0..1)
const wobble = this._busy ? Math.floor((Math.random() - 0.5) * (1 + p * 1.2)) : 0; // Subtle jitter only when “busy”
this.ctx.save(); // Save state
// “Working” cursor: a claustrophobic thin bar that shivers slightly. // Comment
if (this._busy) { // Busy cursor style
this.ctx.globalAlpha = 0.55 + p * 0.20; // More intense with pressure
this.ctx.fillStyle = "#FFFFFF"; // White bar
this.ctx.fillRect(px + wobble + (this.cellW - Math.max(2, this.scale)) / 2, py + 2, Math.max(2, this.scale), this.cellH - 4); // Vertical bar
} else { // Waiting cursor style
// “Waiting” cursor: a solid block at the insertion point. // Comment
this.ctx.globalAlpha = 0.55 + p * 0.18; // Gentle but visible
this.ctx.fillStyle = "#FFFFFF"; // Solid white square
this.ctx.fillRect(px + 1, py + 1, this.cellW - 2, this.cellH - 2); // Filled block
} // End style
this.ctx.restore(); // Restore state
} // End _drawCursor
_updateFastForward() { // Disable fast-forward once expired
if (!this._fastForward) return; // If off, do nothing
if (Date.now() <= this._fastForwardUntil) return; // Still active
this._fastForward = false; // Expire it
} // End _updateFastForward
async _ensureFontLoaded() { // Ensure the custom font is loaded before first draw
if (!document.fonts || !document.fonts.load) return; // If Font Loading API not supported, skip
const spec = `${this.fontWeight} ${this.fontSize}px TeletextOTF`; // Build a CSS font spec for the loader
try { // Attempt a font load
await document.fonts.load(spec, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); // Load with a representative sample
await document.fonts.ready; // Wait until the browser reports fonts ready
} catch (_) { // If it fails
// We silently fall back to monospace; no crash. // Comment
} // End try/catch
} // End _ensureFontLoaded
async _flickerCell(x, y) { // Flicker a printed cell briefly
const cell = this.buffer[y][x]; // Read current cell
const orig = cell.ch; // Original char
const corrupted = this._corruptChar(orig); // Corrupted char
if (corrupted === orig) return; // No change, skip
this.buffer[y][x] = { ...cell, ch: corrupted }; // Overwrite briefly
this.forceRedraw(); // Redraw
await this._sleep(16 + Math.floor(Math.random() * 34)); // Hold 16..50ms
this.buffer[y][x] = { ...cell, ch: orig }; // Restore
this.forceRedraw(); // Redraw
this._lastFlickerAt = Date.now(); // Record
} // End _flickerCell
_sleep(ms) { // Sleep helper
return new Promise((r) => setTimeout(r, ms)); // Resolve later
} // End _sleep
async procLINE(text) { // Print a wrapped line with typewriter pacing
this._busy = true; // Mark the terminal as “working”
const tokens = this._tokeniseForWrap(text); // Tokenise input
const wrapped = this._wrapTokens(tokens, this.area.w); // Wrap to area width
this._busy = !this._inputMode; // Busy only when not in input mode
for (let li = 0; li < wrapped.length; li += 1) { // For each wrapped line
const lineTokens = wrapped[li]; // Tokens in this line
if (this.cursorX !== this.area.x) this._newlineInArea(); // Ensure we start at left edge
for (let ti = 0; ti < lineTokens.length; ti += 1) { // For each token
const tok = lineTokens[ti]; // Token
if (tok.type === "cc") { // Control code token
this._applyControlCode(tok.code); // Apply
continue; // Skip printing
} // End control code
const ch = tok.ch; // Printable char
this._putCharInArea(ch); // Put char
this.forceRedraw(); // Redraw
if (this._pressure > 0 && this._shouldFlickerChar()) { // Flicker occasionally
const fx = this.cursorX - 1; // Cell we just wrote
const fy = this.cursorY; // Current row
if (fx >= this.area.x && fx < this.area.x + this.area.w) { // Bounds within area
await this._flickerCell(fx, fy); // Flicker it
} // End bounds
} // End flicker
this._updateFastForward(); // Refresh fast-forward expiry
const noDelay = this._inputMode || this._fastForward || this._skipDelays; // Any reason to skip delays
if (this._skipDelays) this._skipDelays = false; // One-shot consumption
if (noDelay || this._fastForward) { // If fast-forward or input mode is active
await this._sleep(0); // Yield only; effectively instant output
} else if (ch === ".") { // Longer pause after full stops
await this._sleep(250 + Math.floor(Math.random() * 180)); // Sentence beat
this._putCharInArea(" "); // Insert a space for that old teletext rhythm
} else { // Normal pacing
await this._sleep(7 + Math.floor(Math.random() * 16)); // Character jitter delay
} // End pacing
} // End tokens loop
this._newlineInArea(); // Move to next line after wrapped line
} // End wrapped lines loop
this._busy = false; // Mark terminal idle again
} // End procLINE
async glitchPulse(rounds, intensity, lineBuilder) { // Print glitch lines quickly
const r = Math.max(1, Number(rounds) || 1); // Clamp rounds
const it = Math.max(1, Number(intensity) || 1); // Clamp intensity
for (let i = 0; i < r; i += 1) { // Loop rounds
let line = String(lineBuilder(i) ?? ""); // Build base line
line = this._glitchCorruptLine(line, it); // Corrupt the line
await this.procLINE(this.T(this.CC.ALPHA_MAGENTA) + line); // Print as magenta
await this._sleep(28 + Math.floor(Math.random() * 55)); // Rapid delay
} // End loop
} // End glitchPulse
_glitchCorruptLine(s, intensity) { // Heavier corruption for glitch bursts
const garble = ["░", "▒", "▓", "·", "•", "*", "#"]; // Noise pool
const rate = Math.min(0.22, 0.03 + intensity * 0.02); // Corruption rate
let out = ""; // Output
for (let i = 0; i < s.length; i += 1) { // Loop chars
const ch = s[i]; // Char
const code = ch.charCodeAt(0); // Code
if (code < 32) { out += ch; continue; } // Preserve control chars
if (Math.random() < rate) { // Corrupt sometimes
if (/[a-zA-Z]/.test(ch) && Math.random() < 0.55) { // Case flip
out += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
} else if (Math.random() < 0.75) { // ASCII nudge
const c = ch.charCodeAt(0); // Code
const d = Math.random() < 0.5 ? -1 : 1; // Delta
const n = c + d; // New code
out += n >= 32 && n <= 126 ? String.fromCharCode(n) : ch; // Append
} else { // Garble glyph
out += garble[Math.floor(Math.random() * garble.length)]; // Noise
} // End choice
} else { // No corruption
out += ch; // Keep
} // End branch
} // End loop
return out; // Return
} // End _glitchCorruptLine
async waitForEnterKey() { // Wait until the user presses Enter
while (true) { // Loop
const k = await this.nextKey(null); // Wait for key
if (k && k.type === "enter") return; // Return on enter
} // End loop
} // End waitForEnterKey
async readTextInput(maxLen = 18) { // General input routine (no hidden inputs)
const startX = this.cursorX; // Field start x
const startY = this.cursorY; // Field row
this.setInputMode(true); // Enter input mode (no delays)
this._busy = false; // Input is “waiting”, not “working”
let s = ""; // Buffer
this.flushKeys(); // Drop any buffered “early typing” before input begins
for (let i = 0; i < maxLen; i += 1) { // Clear field visually
this._writeCharAt(startX + i, startY, " ", this.curFg, false); // Clear each cell
} // End clear
this.forceRedraw(); // Redraw
// Ensure the cursor appears at the input field start. // Comment
this.cursorX = startX;
this.cursorY = startY;
while (true) { // Key loop
const k = await this.nextKey(null); // Wait for key
if (!k) continue; // Ignore null
if (k.type === "enter") break; // Done
if (k.type === "backspace") { // Backspace
if (s.length > 0) { // If there is something to delete
s = s.slice(0, -1); // Remove last char
this._writeCharAt(startX + s.length, startY, " ", this.curFg, false); // Clear cell
this.forceRedraw(); // Redraw
this.cursorX = startX + s.length; // Move cursor to new insertion point
this.cursorY = startY;
} else { // Nothing to delete
this.beep(220, 18, 0.01); // Tiny beep
} // End else
continue; // Continue loop
} // End backspace
if (k.type === "char") { // Character typed
const ch = k.ch; // Character
const code = ch.charCodeAt(0); // Code
if (code < 32 || code > 126) { this.beep(200, 18, 0.01); continue; } // Reject non-printable
if (s.length >= maxLen) { this.beep(200, 18, 0.01); continue; } // Reject overflow
s += ch; // Append
this._writeCharAt(startX + (s.length - 1), startY, ch, this.curFg, false); // Draw char
this.forceRedraw(); // Redraw
this.cursorX = startX + s.length; // Advance cursor with text
this.cursorY = startY;
} // End char
} // End loop
this._newlineInArea(); // Move to next line after input
this.setInputMode(false); // Exit input mode after input is captured
return s; // Return input string
} // End readTextInput
async askYesOrNo() { // Yes/No prompt helper
this.flushKeys(); // Drop any buffered “early typing” before input begins
this.setInputMode(true); // Enter input mode (no delays)
const fieldX = this.cursorX; // Where we'll echo the choice
const fieldY = this.cursorY; // Same row as the prompt
// Clear a tiny field so a single Y/N can appear cleanly. // Comment
for (let i = 0; i < 3; i += 1) {
this._writeCharAt(fieldX + i, fieldY, " ", this.curFg, false);
}
this.forceRedraw();
// Place the cursor at the Y/N field so it follows the choice. // Comment
this.cursorX = fieldX;
this.cursorY = fieldY;
while (true) { // Loop until valid
const k = await this.nextKey(null); // Wait for a key
if (!k) continue; // Ignore nulls
if (k.type === "char") { // Letter typed
const ch = (k.ch || "").toLowerCase(); // Normalise
if (ch === "y" || ch === "n") { // Valid answer
const shown = ch.toUpperCase(); // Echo as upper-case
this._writeCharAt(fieldX, fieldY, shown, this.curFg, false); // Draw choice
this.forceRedraw(); // Update display
this.cursorX = fieldX + 1; // Move cursor just after the answer
this.cursorY = fieldY;
this._newlineInArea(); // Move to next line after answer
this.setInputMode(false); // Exit input mode
return ch === "y" ? 1 : 0; // Map to 1/0
}
this.beep(220, 30, 0.012); // Invalid key
continue;
}
// Ignore Enter / Backspace during Y/N, just beep so it feels responsive. // Comment
if (k.type === "enter" || k.type === "backspace") {
this.beep(220, 30, 0.012);
}
} // End loop
} // End askYesOrNo
// Backwards-compatible wrappers for existing story engine calls. // Comment
async procRETURN() { // Legacy name: wait for Enter
return this.waitForEnterKey();
} // End procRETURN
async procGPI(maxLen = 18) { // Legacy name: general input
return this.readTextInput(maxLen);
} // End procGPI
async procYESORNO() { // Legacy name: yes/no input
return this.askYesOrNo();
} // End procYESORNO
async nextKey(timeoutMs) { // Return next queued key (optionally timed)
if (this._keyQueue.length > 0) return this._keyQueue.shift(); // Immediate
// If no timeout is requested, keep behaviour simple: store a waiter that will
// be resolved by the next key event pushed via _pushKey.
if (timeoutMs === null || timeoutMs === undefined) {
return new Promise((resolve) => {
this._keyWaiters.push(resolve); // Store resolver
}); // End promise
}
// With a timeout, ensure we do NOT leak stale waiters. We register a waiter
// that can be removed if the timeout fires first, so that future keypresses
// are delivered to the current caller instead of long-dead polls.
return new Promise((resolve) => {
let settled = false; // Guard so we only settle once
const waiter = (evt) => { // Waiter invoked by _pushKey
if (settled) return; // Ignore if already timed out
settled = true; // Mark settled
resolve(evt); // Deliver the event
};
this._keyWaiters.push(waiter); // Register timed waiter
setTimeout(() => { // Timeout branch
if (settled) return; // If already resolved by a key, skip
settled = true; // Mark settled
// Remove this waiter so it doesn't soak up future keypresses
const idx = this._keyWaiters.indexOf(waiter);
if (idx >= 0) this._keyWaiters.splice(idx, 1); // Drop stale waiter
resolve(null); // Report timeout
}, timeoutMs);
}); // End timed promise
} // End nextKey
_pushKey(evt) { // Push a key event into the queue or resolve a waiter
if (!evt || !["enter", "backspace", "char"].includes(evt.type)) return; // Reject malformed events
if (evt.type === "char" && (typeof evt.ch !== "string" || evt.ch.length !== 1)) return; // Require one character
if (evt && evt.type === "enter") { // Enter triggers fast-forward
this._fastForward = true; // Enable fast-forward
this._fastForwardUntil = Date.now() + this._fastForwardMs; // Set expiry
} // End fast-forward trigger
if (evt && (evt.type === "enter" || (this._fastForwardAnyKey && (evt.type === "char" || evt.type === "backspace")))) { // If it should fast-forward
this._enableFastForward(); // Turn on fast-forward silently
} // End fast-forward trigger
if (this._keyWaiters.length > 0) { // If someone is waiting for a key
const r = this._keyWaiters.shift(); // Take the earliest waiter
r(evt); // Resolve immediately
return; // Done
} // End waiter path
if (this._keyQueue.length >= this._maxKeyQueue) this._keyQueue.shift(); // Discard oldest buffered input
this._keyQueue.push(evt); // Otherwise queue it for later
} // End _pushKey
_bindKeyboard() { // Bind key events
window.addEventListener("keydown", (e) => { // Global listener so typing always works
if (e.key === "Enter") { this._pushKey({ type: "enter" }); e.preventDefault(); return; } // Enter
if (e.key === "Backspace") { this._pushKey({ type: "backspace" }); e.preventDefault(); return; } // Backspace
if (typeof e.key === "string" && e.key.length === 1) { // Printable char
this._pushKey({ type: "char", ch: e.key }); // Queue
e.preventDefault(); // Prevent browser default
} // End printable
}, { passive: false }); // We call preventDefault, so passive must be false
} // End _bindKeyboard
beep(freq = 880, ms = 40, gain = 0.012) { // Retro beep
if (this._muted) return; // Respect mute
if (!this._audioCtx) { // Lazy-create audio context
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // Create
} // End init
const ctx = this._audioCtx; // Alias
const osc = ctx.createOscillator(); // Oscillator
const g = ctx.createGain(); // Gain node
osc.type = "square"; // Square wave for retro feel
osc.frequency.value = freq; // Frequency
g.gain.value = gain; // Volume
osc.connect(g); // Connect nodes
g.connect(ctx.destination); // Output
const now = ctx.currentTime; // Time base
osc.start(now); // Start
osc.stop(now + ms / 1000); // Stop
} // End beep
_tick(t) { // Render tick
if (!this._running) return; // Stop if not running
if (t - this._cursorLastToggle >= this._cursorBlinkMs) { // If it is time to blink
this._cursorOn = !this._cursorOn; // Toggle cursor visibility
this._cursorLastToggle = t; // Reset timer
} // End cursor blink
if (t - this._lastFlashToggle >= this.flashMs) { // Flash toggle timer
this.flashPhase = !this.flashPhase; // Toggle
this._lastFlashToggle = t; // Reset timer
} // End flash handling
this._renderFrame(t); // Draw a frame (includes noise/fizz each frame)
this._raf = requestAnimationFrame((tt) => this._tick(tt)); // Schedule next
} // End _tick
_renderTextLayer() { // Render buffer into offscreen canvas
const c = this._textCtx; // Alias
c.clearRect(0, 0, this._textLayer.width, this._textLayer.height); // Clear
c.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; // Ensure font
c.textBaseline = "top"; // Baseline
c.imageSmoothingEnabled = false; // Crisp
for (let y = 0; y < this.rows; y += 1) { // Rows
for (let x = 0; x < this.cols; x += 1) { // Cols
const cell = this.buffer[y][x]; // Cell
if (cell.flash && !this.flashPhase) continue; // Skip hidden flash phase
const px = this.padX + x * this.cellW; // Pixel x
const py = this.padY + y * this.cellH; // Pixel y
c.fillStyle = cell.fg; // Set colour
// Tiny glow/doubling to get that teletext “fizz”. // Note
c.globalAlpha = 0.20; // Low alpha ghost
c.fillText(cell.ch, px + 1, py); // Ghost right
c.fillText(cell.ch, px, py + 1); // Ghost down
c.globalAlpha = 1.0; // Solid pass
c.fillText(cell.ch, px, py); // Main char
} // End cols
} // End rows
this._dirtyText = false; // Mark clean
} // End _renderTextLayer
_renderFrame(t) { // Render a full CRT frame
if (this._dirtyText) this._renderTextLayer(); // Refresh text layer when needed
const c = this.ctx; // Alias
c.imageSmoothingEnabled = false; // Keep crisp pixels
c.fillStyle = this._backdropOn ? this.bg : "#0b0d12"; // Backdrop colour
c.fillRect(0, 0, this.canvas.width, this.canvas.height); // Fill background
// Slight jitter increases with pressure; almost none at the start. // Note
const p = this._pressure ?? 0; // Pressure (0..1)
const jitterAmp = 0.7 + p * 2.0; // Jitter amplitude grows with pressure
const j = Math.round((Math.random() - 0.5) * jitterAmp); // Horizontal jitter in pixels
const k = Math.round((Math.random() - 0.5) * jitterAmp); // Vertical jitter in pixels
// Simulated RGB split (very light). // Note
const split = 1 + Math.floor(p * 1.0); // Split grows slightly with pressure
c.globalAlpha = 0.22; // Low alpha for colour ghosts
c.drawImage(this._textLayer, j - split, k, this.canvas.width, this.canvas.height); // Red-ish ghost (via blend)
c.drawImage(this._textLayer, j + split, k, this.canvas.width, this.canvas.height); // Blue-ish ghost
c.globalAlpha = 1.0; // Reset alpha
c.drawImage(this._textLayer, j, k, this.canvas.width, this.canvas.height); // Main layer
this._drawCursor(); // Draw the cursor on top of everything
// Add a bit of noise every frame for fizziness. // Note
this._drawNoise(t); // Noise overlay
// Light vignette inside the canvas, for extra CRT feel. // Note
this._drawVignette(); // Vignette overlay
} // End _renderFrame
_drawNoise(t) { // Draw animated noise
const c = this.ctx; // Alias
const w = this.canvas.width; // Width
const h = this.canvas.height; // Height
const p = this._pressure; // Pressure
const alpha = 0.035 + p * 0.025; // Noise alpha rises slightly with pressure
const count = 140 + Math.floor(p * 120); // Number of specks
c.globalAlpha = alpha; // Set noise alpha
c.fillStyle = "#ffffff"; // White specks
this._noiseSeed = (this._noiseSeed + 1) % 100000; // Advance seed
for (let i = 0; i < count; i += 1) { // Draw specks
const x = (Math.random() * w) | 0; // Random x
const y = (Math.random() * h) | 0; // Random y
const s = (Math.random() < 0.92) ? 1 : 2; // Mostly 1px, sometimes 2px
c.fillRect(x, y, s, s); // Speck
} // End specks
// Occasional faint horizontal “tear”. // Note
if (Math.random() < (0.010 + p * 0.020)) { // Rare at low pressure, more later
const y = (Math.random() * h) | 0; // Tear y
c.globalAlpha = 0.06 + p * 0.05; // Tear alpha
c.fillRect(0, y, w, 1 + ((Math.random() * 2) | 0)); // Tear line
} // End tear
c.globalAlpha = 1.0; // Restore alpha
} // End _drawNoise
_drawVignette() { // Draw a subtle vignette overlay
const c = this.ctx; // Alias
const w = this.canvas.width; // Width
const h = this.canvas.height; // Height
const g = c.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.20, w * 0.5, h * 0.5, Math.max(w, h) * 0.75); // Gradient
g.addColorStop(0, "rgba(0,0,0,0)"); // Centre clear
g.addColorStop(1, "rgba(0,0,0,0.38)"); // Edges dark
c.fillStyle = g; // Apply
c.fillRect(0, 0, w, h); // Draw
} // End _drawVignette
} // End class