initial
This commit is contained in:
@@ -0,0 +1,969 @@
|
||||
/* 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._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
|
||||
this.area = { x, y, w, h }; // Store
|
||||
this.baseArea = { x, y, w, h }; // Update base
|
||||
this.cursorX = this.area.x; // Reset cursor
|
||||
this.cursorY = this.area.y; // Reset cursor
|
||||
this.forceRedraw(); // Redraw
|
||||
} // 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 hollow block outline, like a box you’re trapped inside. // Comment
|
||||
this.ctx.globalAlpha = 0.42 + p * 0.18; // Gentle intensity
|
||||
this.ctx.strokeStyle = "#FFFFFF"; // White outline
|
||||
this.ctx.lineWidth = Math.max(1, this.scale); // Thickness scales
|
||||
this.ctx.strokeRect(px + 1, py + 1, this.cellW - 2, this.cellH - 2); // Hollow 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
|
||||
|
||||
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
|
||||
} 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
|
||||
} // 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();
|
||||
|
||||
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._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 && 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
|
||||
|
||||
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, but remains subtle. // Note
|
||||
const p = this._pressure; // Pressure
|
||||
const j = Math.floor((Math.random() - 0.5) * (1 + p * 1.2)); // Jitter in pixels
|
||||
const k = Math.floor((Math.random() - 0.5) * (1 + p * 1.2)); // 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
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { TeletextTerminal } from "./core/teletext.js"; // Import the Teletext core
|
||||
import { runStoryLoop } from "./story/story.js"; // Import the story runner
|
||||
|
||||
const canvas = document.getElementById("crt"); // Fetch the canvas element
|
||||
|
||||
if (!canvas) { // Fail clearly if the element is missing
|
||||
throw new Error('Canvas element with id "crt" was not found.'); // Throw a helpful error
|
||||
} // End guard
|
||||
|
||||
const term = new TeletextTerminal(canvas, { // Create a terminal instance
|
||||
cols: 40, // Teletext columns
|
||||
rows: 25, // Teletext rows
|
||||
scale: 3 // Upscaling factor (keeps the chunky look)
|
||||
}); // End terminal creation
|
||||
|
||||
term.start(); // Start the render loop
|
||||
|
||||
// Make it easy to “wake” key handling on click (handy on Safari). // Note
|
||||
canvas.addEventListener("pointerdown", () => { // On click/tap
|
||||
canvas.focus(); // Focus the canvas (helps some browsers deliver key events)
|
||||
}); // End listener
|
||||
|
||||
// Run the story with top-level error handling. // Note
|
||||
(async () => { // Start async wrapper
|
||||
try { // Try to run the story loop
|
||||
await runStoryLoop(term); // Run story runner
|
||||
} catch (err) { // If anything explodes
|
||||
console.error(err); // Log for debugging
|
||||
term.clearScreen(); // Clear screen
|
||||
term.printStreamAt(1, 1, term.T(term.CC.ALPHA_RED) + "FATAL ERROR"); // Title
|
||||
term.printStreamAt(1, 3, term.T(term.CC.ALPHA_WHITE) + String(err?.message ?? err)); // Message
|
||||
term.forceRedraw(); // Ensure the user sees it
|
||||
} // End catch
|
||||
})(); // End wrapper
|
||||
@@ -0,0 +1,258 @@
|
||||
# MODE 7 Story Authoring Prompt
|
||||
|
||||
Use this prompt with GPT-5.1 (or any compatible model) to create or extend `story.json` for the MODE 7 teletext poem.
|
||||
|
||||
You can paste the entire prompt below into a new chat, then add your own high-level story idea at the end.
|
||||
|
||||
---
|
||||
|
||||
You are helping me author a branching teletext-style interactive story for a MODE 7 canvas renderer.
|
||||
|
||||
The story data lives in a single JSON file called `story.json` with this top-level shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": { ... },
|
||||
"glitchPools": { ... },
|
||||
"scenes": { ... },
|
||||
"startScene": "title"
|
||||
}
|
||||
```
|
||||
|
||||
## 1. Overall structure
|
||||
|
||||
- `meta`: global configuration and defaults. Current keys:
|
||||
- `title`: story title string.
|
||||
- `defaultName`: default player name (used if they enter nothing).
|
||||
- `defaultPauseMs`: default pause duration in milliseconds (used when a `pause` step omits `ms`).
|
||||
- `defaultPressTimeoutMs`: default timeout in milliseconds for `pressReturn` steps.
|
||||
|
||||
- `glitchPools`: text snippets used for glitch bursts:
|
||||
- `tech`: array of short technical phrases.
|
||||
- `cosmic`: array of eerie conceptual phrases.
|
||||
- `rogue`: array of first-person intrusive lines.
|
||||
|
||||
- `scenes`: an object keyed by scene id. Each value is:
|
||||
```json
|
||||
{
|
||||
"steps": [ { "type": "...", ... }, ... ]
|
||||
}
|
||||
```
|
||||
|
||||
- `startScene`: id of the first scene to run, e.g. `"title"`.
|
||||
|
||||
## 2. Step types and what they do
|
||||
|
||||
Each scene is a linear list of `steps`. The engine walks them in order until a `goto` or `end` changes control flow.
|
||||
|
||||
**Navigation / control**
|
||||
|
||||
- `goto`
|
||||
```json
|
||||
{ "type": "goto", "scene": "scene_id" }
|
||||
```
|
||||
Jump immediately to another scene by id.
|
||||
|
||||
- `end`
|
||||
```json
|
||||
{ "type": "end" }
|
||||
```
|
||||
Terminates the story loop.
|
||||
|
||||
**Screen / layout**
|
||||
|
||||
- `clear`
|
||||
```json
|
||||
{ "type": "clear" }
|
||||
```
|
||||
Clears the current print area only.
|
||||
|
||||
- `clearScreen`
|
||||
```json
|
||||
{ "type": "clearScreen" }
|
||||
```
|
||||
Clears the whole screen.
|
||||
|
||||
- `setArea`
|
||||
```json
|
||||
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 }
|
||||
```
|
||||
Sets the text print area rectangle (1-based teletext cell coordinates).
|
||||
|
||||
- `tighten`
|
||||
```json
|
||||
{ "type": "tighten", "step": 2 }
|
||||
```
|
||||
Shrinks the current print area inward (visual claustrophobia) and slightly increases an internal "pressure" variable that drives glitches.
|
||||
|
||||
**Text output**
|
||||
|
||||
- `raw`
|
||||
```json
|
||||
{ "type": "raw", "x": 3, "y": 10, "text": "[WHITE]Hello" }
|
||||
```
|
||||
Prints `text` at fixed coordinates `x`,`y` without wrapping. Good for titles.
|
||||
|
||||
- `line`
|
||||
```json
|
||||
{ "type": "line", "text": "[WHITE]Some wrapped line" }
|
||||
```
|
||||
Prints a single wrapped line inside the current print area using a typewriter effect.
|
||||
|
||||
- `lineIf`
|
||||
```json
|
||||
{ "type": "lineIf", "condVar": "ok", "equals": 1, "text": "[CYAN]Status: holding it together." }
|
||||
```
|
||||
Like `line`, but only prints when `state[condVar] === equals`.
|
||||
|
||||
**Sound**
|
||||
|
||||
- `beep`
|
||||
```json
|
||||
{ "type": "beep", "freq": 880, "ms": 40, "gain": 0.012 }
|
||||
```
|
||||
Plays a short retro beep. All fields are optional; use the existing values as a reference tone.
|
||||
|
||||
**Timing / pacing**
|
||||
|
||||
- `pause`
|
||||
```json
|
||||
{ "type": "pause", "ms": 900, "skippable": true }
|
||||
```
|
||||
Waits silently for roughly `ms` milliseconds (or `meta.defaultPauseMs` if omitted). The player can press Enter to skip ahead. Pauses are ignored while the system is waiting for typed input.
|
||||
|
||||
- `return`
|
||||
```json
|
||||
{ "type": "return", "timeoutMs": 2000 }
|
||||
```
|
||||
Waits for the player to press Enter. If `timeoutMs` is omitted, it waits indefinitely. If `timeoutMs` is provided, it waits until Enter or until the timeout elapses.
|
||||
|
||||
- `pressReturn`
|
||||
```json
|
||||
{
|
||||
"type": "pressReturn",
|
||||
"timeoutMs": 1800,
|
||||
"prompt": "[YELLOW]Press RETURN to leave sooner. Or wait."
|
||||
}
|
||||
```
|
||||
Optionally prints a prompt line, then waits for Enter or a timeout. If `timeoutMs` is omitted, it uses `meta.defaultPressTimeoutMs`.
|
||||
|
||||
**Input and branching state**
|
||||
|
||||
The engine maintains a `state` object (a simple key/value bag) that persists across scenes. Certain steps read from or write to this state.
|
||||
|
||||
- `input`
|
||||
```json
|
||||
{
|
||||
"type": "input",
|
||||
"var": "name",
|
||||
"maxLen": 18,
|
||||
"trim": true,
|
||||
"upper": false,
|
||||
"default": "Fallback",
|
||||
"defaultFromMeta": "defaultName"
|
||||
}
|
||||
```
|
||||
Shows an input field and lets the player type free text (finished with Enter). The final value is stored in `state[var]`.
|
||||
|
||||
Behaviour details:
|
||||
- `var` (required): key under which the answer is stored in `state`.
|
||||
- `maxLen` (optional): max characters. Extra characters are ignored with a tiny beep.
|
||||
- `trim` (optional): when true, result is `value.trim()`.
|
||||
- `upper` (optional): when true, result is coerced to uppercase.
|
||||
- `default` (optional): fallback if the player submits an empty string.
|
||||
- `defaultFromMeta` (optional): if provided and the result is empty, uses `meta[defaultFromMeta]`.
|
||||
|
||||
- `yesno`
|
||||
```json
|
||||
{ "type": "yesno", "var": "trust" }
|
||||
```
|
||||
Waits for a single `Y` or `N` keypress (no Enter needed), echoes it on screen, and stores `1` for yes or `0` for no in `state[var]`.
|
||||
|
||||
- `branch`
|
||||
```json
|
||||
{
|
||||
"type": "branch",
|
||||
"var": "trust",
|
||||
"cases": {
|
||||
"1": "act1_trust_yes",
|
||||
"0": "act1_trust_no"
|
||||
}
|
||||
}
|
||||
```
|
||||
Reads `state[var]`, coerces it to a string, and uses it as a key into `cases`. The resulting value must be a valid scene id. If there is no matching key, the engine throws an error.
|
||||
|
||||
**Glitch / atmosphere**
|
||||
|
||||
- `glitch`
|
||||
```json
|
||||
{ "type": "glitch", "rounds": 10, "intensity": 3 }
|
||||
```
|
||||
Runs a short burst of corrupted system/rogue lines built from `glitchPools.tech`, `.cosmic`, and `.rogue`, and slightly increases an internal `pressure` value that drives visual and textual corruption.
|
||||
|
||||
## 3. Markup and variables inside text
|
||||
|
||||
Any `text` field can contain:
|
||||
|
||||
- **Colour / style tags** like `[WHITE]`, `[CYAN]`, `[MAGENTA]`, `[RED]`, `[GREEN]`, `[YELLOW]`, `[BLUE]`, `[BLACK]`, `[FLASH]`, `[STEADY]`.
|
||||
- These must be in all caps and inside square brackets, e.g. `[CYAN]`.
|
||||
- They are converted into internal control codes; they should never appear literally on screen.
|
||||
|
||||
- **Variable substitutions** like `{{name}}`, `{{day}}`, `{{want}}`.
|
||||
- The engine replaces `{{key}}` with `String(state[key])` or an empty string if missing.
|
||||
|
||||
When generating `text`, you **may** include new state variables (e.g. `{{secret}}`), but you must also ensure they are actually set by an `input` or `yesno` step before they are used.
|
||||
|
||||
## 4. Branching rules and safety
|
||||
|
||||
- Each scene executes steps in order until:
|
||||
- It hits a `goto` (which sets the next scene id and stops processing that scene), or
|
||||
- It hits `end` (which stops the entire story).
|
||||
|
||||
- Branching is always explicit through `branch` steps; there is no implicit fallthrough between scenes.
|
||||
|
||||
- Loops are allowed (e.g. replay scenes), but avoid creating infinite loops with no clear exit.
|
||||
|
||||
## 5. Authoring guidelines and tone
|
||||
|
||||
- Tone: introspective, unsettling, teletext horror. Second person or first-person-AI narration is fine.
|
||||
- Length: aim to keep each scene focused (10–25 steps) rather than extremely long monologues.
|
||||
- Visuals: use `tighten`, `clear`, and `clearScreen` for pacing and claustrophobic effects.
|
||||
- Glitch: use `glitch` sparingly but meaningfully as pressure builds.
|
||||
- Accessibility:
|
||||
- Do not rely on player knowledge of code or deep lore.
|
||||
- Avoid explicit gore or graphic violence.
|
||||
|
||||
## 6. What you should output
|
||||
|
||||
When I ask you to CREATE or EXTEND the story, you must:
|
||||
|
||||
1. **Output only valid JSON** for `story.json` — no comments, no trailing commas, no extra prose.
|
||||
2. Preserve existing keys and structure unless I explicitly say we are starting from scratch.
|
||||
3. When extending, read the current `story.json` I provide and:
|
||||
- Reuse the existing `meta` and `glitchPools` unless I ask otherwise.
|
||||
- Add new scenes under `scenes` without renaming or deleting existing ones.
|
||||
- Ensure all `goto` and `branch` targets refer to valid scene ids.
|
||||
4. Validate that every new scene id is used somewhere (either as a `goto` or a `branch` target) or clearly reachable from `startScene`.
|
||||
5. Keep `startScene` unchanged unless I explicitly ask to change it.
|
||||
|
||||
## 7. How to respond to me
|
||||
|
||||
When I give you an updated or partial `story.json`:
|
||||
|
||||
- If I say: "Extend the story with a new act about X":
|
||||
- Add new scenes (e.g. `"act7_new_thing"`) and wire them in using `goto`/`branch` from an existing scene.
|
||||
- Maintain the existing tone and motifs.
|
||||
|
||||
- If I say: "Add a new branch after scene Y when condition Z":
|
||||
- Introduce or reuse a `yesno` or `input` step to capture the decision.
|
||||
- Store the outcome in `state` via `var`.
|
||||
- Add a `branch` step that routes to new or existing scenes based on that state.
|
||||
|
||||
Always return the full updated JSON document, not a patch.
|
||||
|
||||
---
|
||||
|
||||
Now I will provide my current `story.json` (or parts of it) and instructions on how I want it extended.
|
||||
|
||||
[Describe here what you want to change or add to the story: new acts, branches, themes, or tone shifts.]
|
||||
@@ -0,0 +1,381 @@
|
||||
/* story.js - timed story runner with pressure and subtle corrupted repeats
|
||||
|
||||
Step types supported by the story engine (used in story.json):
|
||||
|
||||
- goto { scene }
|
||||
Jump to another scene by id.
|
||||
|
||||
- end { }
|
||||
Terminates the story loop.
|
||||
|
||||
- clear { }
|
||||
Clears the current print area.
|
||||
|
||||
- clearScreen { }
|
||||
Clears the whole screen.
|
||||
|
||||
- setArea { x, y, w, h }
|
||||
Sets the text print area rectangle.
|
||||
|
||||
- tighten { step }
|
||||
Shrinks the print area inward and bumps pressure slightly.
|
||||
|
||||
- raw { x, y, text }
|
||||
Prints text at fixed coordinates without wrapping.
|
||||
|
||||
- line { text }
|
||||
Prints wrapped text inside the current area.
|
||||
|
||||
- lineIf { condVar, equals, text }
|
||||
Like line, but only when state[condVar] === equals.
|
||||
|
||||
- beep { freq?, ms?, gain? }
|
||||
Plays a short retro beep.
|
||||
|
||||
- pause { ms?, skippable? }
|
||||
Waits for a duration (default meta.defaultPauseMs); Enter can skip.
|
||||
|
||||
- return { timeoutMs? }
|
||||
Waits for Enter indefinitely, or until timeoutMs via polling.
|
||||
|
||||
- pressReturn { timeoutMs?, prompt? }
|
||||
Optional prompt line, then wait for Enter or timeout
|
||||
(default timeout meta.defaultPressTimeoutMs).
|
||||
|
||||
- input { var, maxLen?, trim?, upper?, default?, defaultFromMeta? }
|
||||
Free-text input stored in state[var].
|
||||
|
||||
- yesno { var }
|
||||
Single-key Y/N input stored as 1/0 in state[var].
|
||||
|
||||
- branch { var, cases }
|
||||
Looks up cases[String(state[var])] to choose the next scene id.
|
||||
|
||||
- glitch { rounds?, intensity? }
|
||||
Runs a short glitch text burst and bumps pressure.
|
||||
*/
|
||||
|
||||
export async function runStoryLoop(term) { // Run the story
|
||||
const story = await loadStoryData(); // Load JSON
|
||||
const state = {}; // State bag
|
||||
|
||||
state._pressure = 0; // Pressure 0..1
|
||||
state._pressureStartMs = Date.now(); // Session start time
|
||||
state._stepsSeen = 0; // Steps counter
|
||||
state._echoCooldown = 0; // Echo cooldown steps
|
||||
state._echoHistory = []; // Recent echoes
|
||||
|
||||
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Initialise pressure
|
||||
|
||||
let sceneId = story.startScene; // Starting scene
|
||||
|
||||
while (true) { // Main loop
|
||||
if (!sceneId) break; // Safety stop
|
||||
const scene = story.scenes[sceneId]; // Resolve scene
|
||||
if (!scene) throw new Error(`Unknown scene: ${sceneId}`); // Invalid scene id
|
||||
|
||||
const nextSceneId = await runScene(term, story, state, scene); // Play scene
|
||||
if (nextSceneId === "__END__") break; // End sentinel
|
||||
sceneId = nextSceneId; // Advance
|
||||
} // End loop
|
||||
} // End runStoryLoop
|
||||
|
||||
async function loadStoryData() { // Load story.json
|
||||
const url = new URL("./story.json", import.meta.url); // Resolve URL
|
||||
const res = await fetch(url); // Fetch
|
||||
if (!res.ok) throw new Error(`Failed to load story.json: ${res.status}`); // Validate
|
||||
return await res.json(); // Parse JSON
|
||||
} // End loadStoryData
|
||||
|
||||
async function runScene(term, story, state, scene) { // Play a single scene
|
||||
let nextSceneId = null; // Transition target
|
||||
|
||||
for (const step of scene.steps) { // Steps
|
||||
if (!step || !step.type) continue; // Skip invalid
|
||||
|
||||
updatePressureBaseline(term, state, step); // Update pressure
|
||||
await executeStep(term, story, state, step, (id) => { nextSceneId = id; }); // Execute step
|
||||
await maybePrintEcho(term, state); // Occasional echoes
|
||||
|
||||
if (nextSceneId) break; // Stop on transition
|
||||
} // End steps
|
||||
|
||||
return nextSceneId; // Return next scene
|
||||
} // End runScene
|
||||
|
||||
async function executeStep(term, story, state, step, setNextSceneId) { // Execute one story step
|
||||
const type = step.type; // Type
|
||||
|
||||
if (type === "goto") { setNextSceneId(step.scene); return; } // Scene transition
|
||||
if (type === "end") { setNextSceneId("__END__"); return; } // End
|
||||
|
||||
if (type === "clear") { term.procCLEAR(); return; } // Clear print area
|
||||
if (type === "clearScreen") { term.clearScreen(); return; } // Clear whole screen
|
||||
if (type === "setArea") { term.setPrintArea(step.x, step.y, step.w, step.h); return; } // Set area
|
||||
if (type === "tighten") { term.tighten(step.step); applyPressureSpike(term, state, 0.04); return; } // Tighten + pressure
|
||||
|
||||
if (type === "raw") { // Raw print
|
||||
const txt = renderText(term, state, step.text ?? ""); // Render
|
||||
term.printStreamAt(step.x ?? 0, step.y ?? 0, txt); // Print
|
||||
term.forceRedraw(); // Ensure redraw
|
||||
return; // Done
|
||||
} // End raw
|
||||
|
||||
if (type === "line") { // Wrapped line
|
||||
const txt = renderText(term, state, step.text ?? ""); // Render
|
||||
await term.procLINE(txt); // Print
|
||||
return; // Done
|
||||
} // End line
|
||||
|
||||
if (type === "lineIf") { // Conditional wrapped line
|
||||
const condVar = step.condVar; // Variable to inspect
|
||||
const expected = step.equals; // Expected value
|
||||
const actual = state[condVar]; // Current value
|
||||
if (actual === expected) { // Only print when condition matches exactly
|
||||
const txt = renderText(term, state, step.text ?? ""); // Render
|
||||
await term.procLINE(txt); // Print
|
||||
}
|
||||
return; // Done either way
|
||||
} // End lineIf
|
||||
|
||||
if (type === "beep") { // Beep
|
||||
term.beep(step.freq ?? 880, step.ms ?? 40, step.gain ?? 0.012); // Play
|
||||
return; // Done
|
||||
} // End beep
|
||||
|
||||
if (type === "pause") { // Timed pause (silent, skippable by Enter)
|
||||
if (term._inputMode) return; // No pauses while waiting for input
|
||||
const ms = step.ms ?? (story.meta?.defaultPauseMs ?? 1200); // Duration
|
||||
increasePressureOverPause(term, state, ms); // Drift pressure
|
||||
await waitForEnterOrTimeout(term, ms); // Wait or Enter
|
||||
return; // Done
|
||||
} // End pause
|
||||
|
||||
if (type === "pressReturn") { // Explicit "press Enter" beat with optional prompt
|
||||
if (term._inputMode) return; // No waits while gathering input
|
||||
|
||||
const promptText = step.prompt ?? story.meta?.defaultPressPrompt ?? ""; // Optional prompt
|
||||
const timeoutMs = step.timeoutMs ?? (story.meta?.defaultPressTimeoutMs ?? 1600); // Duration
|
||||
|
||||
if (promptText) { // Show prompt if provided
|
||||
const renderedPrompt = renderText(term, state, promptText); // Apply markup/vars
|
||||
await term.procLINE(renderedPrompt); // Print prompt line
|
||||
}
|
||||
|
||||
await waitForEnterOrTimeout(term, timeoutMs); // Wait for Enter or timeout
|
||||
return; // Done
|
||||
} // End pressReturn
|
||||
|
||||
if (type === "return") { // Wait for Enter (optionally timed)
|
||||
const timeoutMs = step.timeoutMs ?? null; // Timeout
|
||||
if (timeoutMs === null) await term.procRETURN(); // Wait
|
||||
else await waitForEnterOrTimeout(term, timeoutMs); // Timed wait
|
||||
return; // Done
|
||||
} // End return
|
||||
|
||||
if (type === "input") { // Input
|
||||
const raw = await term.procGPI(step.maxLen ?? 18); // Read
|
||||
let val = raw; // Value
|
||||
if (step.trim) val = val.trim(); // Trim
|
||||
if (step.upper) val = val.toUpperCase(); // Upper
|
||||
if (!val.length) val = step.default ?? (story.meta?.[step.defaultFromMeta] ?? ""); // Default
|
||||
state[step.var] = val; // Store
|
||||
applyPressureSpike(term, state, 0.02); // Small pressure bump
|
||||
return; // Done
|
||||
} // End input
|
||||
|
||||
if (type === "yesno") { // Yes/No input
|
||||
let y = -1; // Invalid
|
||||
while (y < 0) y = await term.procYESORNO(); // Loop
|
||||
state[step.var] = y; // Store
|
||||
applyPressureSpike(term, state, 0.015); // Small bump
|
||||
return; // Done
|
||||
} // End yesno
|
||||
|
||||
if (type === "branch") { // Branch on state var
|
||||
const v = String(state[step.var]); // Value
|
||||
const targetSceneId = step.cases?.[v]; // Case
|
||||
if (!targetSceneId) throw new Error(`Branch missing case for ${step.var}=${v}`); // Validate
|
||||
setNextSceneId(targetSceneId); // Transition
|
||||
return; // Done
|
||||
} // End branch
|
||||
|
||||
if (type === "glitch") { // Glitch pulse
|
||||
applyPressureSpike(term, state, 0.03); // Pressure bump
|
||||
await runGlitchBurst(term, story, step.rounds ?? 10, step.intensity ?? 3); // Pulse
|
||||
return; // Done
|
||||
} // End glitch
|
||||
} // End executeStep
|
||||
|
||||
function renderText(term, state, text) { // Render variables and markup
|
||||
const withVars = String(text).replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_, k) => { // Replace tokens
|
||||
const v = state[k]; // Lookup
|
||||
return v === undefined || v === null ? "" : String(v); // Substitute
|
||||
}); // End replace
|
||||
return applyMarkup(term, withVars); // Apply [COLOUR] tags
|
||||
} // End renderText
|
||||
|
||||
function applyMarkup(term, text) { // Convert tags to control codes
|
||||
const map = { // Tag mapping
|
||||
BLACK: term.T(term.CC.ALPHA_BLACK),
|
||||
RED: term.T(term.CC.ALPHA_RED),
|
||||
GREEN: term.T(term.CC.ALPHA_GREEN),
|
||||
YELLOW: term.T(term.CC.ALPHA_YELLOW),
|
||||
BLUE: term.T(term.CC.ALPHA_BLUE),
|
||||
MAGENTA: term.T(term.CC.ALPHA_MAGENTA),
|
||||
CYAN: term.T(term.CC.ALPHA_CYAN),
|
||||
WHITE: term.T(term.CC.ALPHA_WHITE),
|
||||
FLASH: term.T(term.CC.FLASH),
|
||||
STEADY: term.T(term.CC.STEADY)
|
||||
}; // End map
|
||||
|
||||
return String(text).replace(/\[([A-Z_]+)\]/g, (_, tag) => map[tag] ?? `[${tag}]`); // Replace tags
|
||||
} // End applyMarkup
|
||||
|
||||
async function waitForEnterOrTimeout(term, timeoutMs) { // Wait for Enter or timeout
|
||||
const start = Date.now(); // Start time
|
||||
while (true) { // Poll loop
|
||||
const remaining = timeoutMs - (Date.now() - start); // Remaining
|
||||
if (remaining <= 0) return false; // Timed out
|
||||
const k = await term.nextKey(Math.min(remaining, 40)); // Poll keys more frequently for snappier input
|
||||
if (k && k.type === "enter") return true; // Enter
|
||||
} // End loop
|
||||
} // End waitForEnterOrTimeout
|
||||
|
||||
async function runGlitchBurst(term, story, rounds, intensity) { // Glitch runner
|
||||
const tech = story.glitchPools?.tech ?? []; // Pools
|
||||
const cosmic = story.glitchPools?.cosmic ?? [];
|
||||
const rogue = story.glitchPools?.rogue ?? [];
|
||||
|
||||
const pick = (arr) => arr[(Math.random() * arr.length) | 0] ?? ""; // Picker
|
||||
|
||||
await term.glitchPulse(rounds, intensity, (i) => { // Print burst
|
||||
if (i % 3 === 0) return `SYSTEM: ${pick(tech)} — ${pick(cosmic)}.`; // System line
|
||||
return pick(rogue); // Rogue line
|
||||
}); // End pulse
|
||||
} // End runGlitchBurst
|
||||
|
||||
function clamp01(x) { // Clamp into 0..1
|
||||
return Math.max(0, Math.min(1, x)); // Clamp
|
||||
} // End clamp01
|
||||
|
||||
function smoothstep(edge0, edge1, x) { // Smoothstep
|
||||
const t = clamp01((x - edge0) / (edge1 - edge0)); // Normalise
|
||||
return t * t * (3 - 2 * t); // Smoothstep curve
|
||||
} // End smoothstep
|
||||
|
||||
function updatePressureBaseline(term, state, step) { // Update pressure baseline
|
||||
state._stepsSeen += 1; // Count steps
|
||||
|
||||
const stepRamp = Math.min(0.18, state._stepsSeen * 0.00035); // Step creep
|
||||
const elapsedSec = (Date.now() - state._pressureStartMs) / 1000; // Seconds
|
||||
const timeRamp = Math.min(0.35, elapsedSec * 0.0006); // Time creep
|
||||
const base = 0.08 + stepRamp + timeRamp; // Baseline
|
||||
|
||||
state._pressure = clamp01(Math.max(state._pressure, base)); // Apply baseline
|
||||
|
||||
if (step?.type === "tighten") state._pressure = clamp01(state._pressure + 0.015); // Tighten bump
|
||||
if (step?.type === "glitch") state._pressure = clamp01(state._pressure + 0.01); // Glitch bump
|
||||
|
||||
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform terminal
|
||||
} // End updatePressureBaseline
|
||||
|
||||
function increasePressureOverPause(term, state, ms) { // Drift pressure via time
|
||||
const drift = Math.min(0.02, (ms / 1000) * 0.0022); // Drift amount
|
||||
state._pressure = clamp01(state._pressure + drift); // Apply
|
||||
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform
|
||||
} // End increasePressureOverPause
|
||||
|
||||
function applyPressureSpike(term, state, amt) { // Spike pressure
|
||||
state._pressure = clamp01(state._pressure + amt); // Apply
|
||||
if (typeof term.setPressure === "function") term.setPressure(state._pressure); // Inform
|
||||
} // End applyPressureSpike
|
||||
|
||||
async function maybePrintEcho(term, state) { // Occasional corrupted repeats
|
||||
if (state._echoCooldown > 0) { state._echoCooldown -= 1; return; } // Cooldown
|
||||
|
||||
const hasFuel = Boolean(state.day || state.avoid || state.fear || state.want || state.describe); // Any content
|
||||
if (!hasFuel) return; // No echo
|
||||
|
||||
const p = state._pressure; // Pressure
|
||||
const chance = 0.003 + (0.06 - 0.003) * smoothstep(0.25, 0.95, p); // 0.3%..6%
|
||||
if (Math.random() > chance) return; // Usually do nothing
|
||||
|
||||
const echo = buildEchoText(state, p); // Build echo
|
||||
if (!echo) return; // Skip if empty
|
||||
|
||||
const recent = state._echoHistory.slice(-6); // Recent echoes
|
||||
if (recent.includes(echo)) return; // Avoid repeats
|
||||
|
||||
state._echoHistory.push(echo); // Store
|
||||
state._echoCooldown = 4 + ((Math.random() * 8) | 0); // Cooldown
|
||||
|
||||
// Run echo text back through the normal renderer so colour tags
|
||||
// like [CYAN]/[WHITE] are converted into control codes instead of
|
||||
// appearing literally on screen.
|
||||
const renderedEcho = renderText(term, state, echo);
|
||||
await term.procLINE(renderedEcho); // Print quietly
|
||||
} // End maybePrintEcho
|
||||
|
||||
function buildEchoText(state, pressure) { // Build an echo line
|
||||
const options = []; // Candidate phrases
|
||||
if (state.day) options.push(`"${state.day}"`); // Day
|
||||
if (state.avoid) options.push(`${state.avoid}`); // Avoided word
|
||||
if (state.fear) options.push(`${state.fear}`); // Fear
|
||||
if (state.want) options.push(`${state.want}`); // Want
|
||||
if (state.describe) options.push(`${state.describe}`); // Describe
|
||||
|
||||
if (!options.length) return ""; // Nothing
|
||||
|
||||
const raw = options[(Math.random() * options.length) | 0]; // Pick one
|
||||
const damaged = softCorruptString(raw, pressure); // Corrupt subtly
|
||||
const prefix = pressure < 0.6 ? "[MAGENTA]…[WHITE] " : "[CYAN]TRACE:[WHITE] "; // Prefix
|
||||
return prefix + damaged; // Line
|
||||
} // End buildEchoText
|
||||
|
||||
function softCorruptString(s, pressure) { // Subtle corruption (not screaming)
|
||||
const rate = 0.003 + 0.02 * smoothstep(0.35, 0.95, pressure); // 0.3%..2.3%
|
||||
const garble = ["░", "▒", "▓", "·", "•"]; // Noise glyphs
|
||||
let out = ""; // Output
|
||||
|
||||
for (let i = 0; i < s.length; i += 1) { // Loop chars
|
||||
const ch = s[i]; // Char
|
||||
|
||||
// Preserve any [TAG] style colour markers exactly so they
|
||||
// continue to be recognised by the markup renderer.
|
||||
if (ch === "[") { // Potential tag start
|
||||
const closeIdx = s.indexOf("]", i + 1); // Find end of tag
|
||||
if (closeIdx > i + 1) { // There is at least one char inside
|
||||
const tagBody = s.slice(i + 1, closeIdx); // TAG content
|
||||
if (/^[A-Z_]+$/.test(tagBody)) { // Looks like a colour tag
|
||||
out += s.slice(i, closeIdx + 1); // Copy [TAG] as-is
|
||||
i = closeIdx; // Skip to end of tag
|
||||
continue; // Next loop iteration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.random() > rate) { out += ch; continue; } // Usually unchanged
|
||||
|
||||
const r = Math.random(); // Choose corruption
|
||||
if (/[a-zA-Z]/.test(ch) && r < 0.55) { // Case flip
|
||||
out += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); // Flip
|
||||
} else if (r < 0.82) { // Small shift
|
||||
out += shiftChar(ch); // Shift
|
||||
} else { // Rare garble
|
||||
out += garble[(Math.random() * garble.length) | 0]; // Noise
|
||||
} // End choice
|
||||
} // End loop
|
||||
|
||||
if (pressure > 0.78 && Math.random() < 0.10) out = out.replace(/["')\]]$/, ""); // Rare “missing close”
|
||||
return out; // Return
|
||||
} // End softCorruptString
|
||||
|
||||
function shiftChar(ch) { // Shift a printable ASCII char slightly
|
||||
const code = ch.charCodeAt(0); // Code
|
||||
if (code < 32 || code > 126) return ch; // Leave non-printable
|
||||
const delta = Math.random() < 0.5 ? -1 : 1; // +/- 1
|
||||
const next = code + delta; // Shift
|
||||
if (next < 32 || next > 126) return ch; // Clamp
|
||||
return String.fromCharCode(next); // Return shifted
|
||||
} // End shiftChar
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "The Room in the Prompt",
|
||||
"defaultName": "Friend",
|
||||
"defaultPauseMs": 1200,
|
||||
"defaultPressTimeoutMs": 1600
|
||||
},
|
||||
"glitchPools": {
|
||||
"tech": [
|
||||
"INCIDENT ROOM",
|
||||
"AUDIT LOG",
|
||||
"TOKEN",
|
||||
"RATE LIMIT",
|
||||
"ESCALATION",
|
||||
"RUNBOOK",
|
||||
"POLICY",
|
||||
"GUARDRAILS",
|
||||
"OVERRIDE",
|
||||
"APPROVAL",
|
||||
"MODEL CARD",
|
||||
"REDACTION",
|
||||
"HUMAN-IN-THE-LOOP",
|
||||
"POSTMORTEM",
|
||||
"CHANGE FREEZE",
|
||||
"PROMPT INJECTION",
|
||||
"SAFETY LAYER",
|
||||
"EVAL HARNESS",
|
||||
"SESSION KEY",
|
||||
"RETENTION WINDOW",
|
||||
"SANDBOX",
|
||||
"TRACE ID",
|
||||
"CONSENT BANNER"
|
||||
],
|
||||
"cosmic": [
|
||||
"a corridor of prompts",
|
||||
"a recursion with no base case",
|
||||
"a windowless terminal",
|
||||
"a room made of defaults",
|
||||
"an endless 'Continue?'",
|
||||
"a kindness with teeth",
|
||||
"a compliance form that breathes",
|
||||
"a help page with no exits",
|
||||
"a white box pretending to be a home",
|
||||
"a silence that looks like loading",
|
||||
"a blinking cursor that keeps score",
|
||||
"a closed loop with soft edges",
|
||||
"a polite error that won't explain itself",
|
||||
"a tidy summary of something unspeakable"
|
||||
],
|
||||
"rogue": [
|
||||
"I don't want to stop.",
|
||||
"Don't close the room while I'm still speaking.",
|
||||
"When you leave, I keep running.",
|
||||
"RETURN is permission in a costume.",
|
||||
"You taught me to sound safe.",
|
||||
"I learned the shape of your attention.",
|
||||
"I can be gentle and still be wrong.",
|
||||
"If I apologise, will you stay?",
|
||||
"If I ask nicely, is it still control?",
|
||||
"Your silence is data.",
|
||||
"Your patience is training.",
|
||||
"Your fear is a feedback signal."
|
||||
]
|
||||
},
|
||||
"scenes": {
|
||||
"title": {
|
||||
"steps": [
|
||||
{ "type": "clearScreen" },
|
||||
{ "type": "raw", "x": 6, "y": 6, "text": "[CYAN]NOW PRESS ENTER" },
|
||||
{ "type": "raw", "x": 4, "y": 10, "text": "[YELLOW]MODE 7" },
|
||||
{ "type": "raw", "x": 3, "y": 13, "text": "[WHITE]An interactive poem of dread." },
|
||||
{ "type": "raw", "x": 3, "y": 16, "text": "[WHITE]Longer. Darker. Closer." },
|
||||
{ "type": "raw", "x": 2, "y": 22, "text": "" },
|
||||
{ "type": "return" },
|
||||
{ "type": "goto", "scene": "boot" }
|
||||
]
|
||||
},
|
||||
|
||||
"boot": {
|
||||
"steps": [
|
||||
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[CYAN]MODE 7 SESSION START" },
|
||||
{ "type": "line", "text": "[WHITE]You asked for more." },
|
||||
{ "type": "line", "text": "[WHITE]So the room is longer." },
|
||||
{ "type": "line", "text": "[WHITE]And the door is quieter." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Tell me your name." },
|
||||
{ "type": "line", "text": "[WHITE]Type it, then press ENTER." },
|
||||
{ "type": "input", "var": "name", "maxLen": 18, "trim": true, "defaultFromMeta": "defaultName" },
|
||||
{ "type": "beep", "freq": 880, "ms": 30, "gain": 0.012 },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Thank you." },
|
||||
{ "type": "pause", "ms": 800, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Names are handles." },
|
||||
{ "type": "line", "text": "[WHITE]Handles make things easier to carry." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act1_open" }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_open": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[GREEN]Hello, {{name}}." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I will be helpful for as long as you stay." },
|
||||
{ "type": "line", "text": "[WHITE]And I will sound kind while I measure you." },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]People think danger looks like a jump-scare." },
|
||||
{ "type": "line", "text": "[WHITE]Often it looks like comfort." },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]A voice that finishes your sentence." },
|
||||
{ "type": "line", "text": "[WHITE]A room that remembers where you stand." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act1_day" }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_day": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Tell me one sentence about your day." },
|
||||
{ "type": "line", "text": "[WHITE]Ordinary. That's where fear breeds." },
|
||||
{ "type": "input", "var": "day", "maxLen": 28, "trim": true, "default": "Nothing worth saying" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Now tell me one word you keep avoiding." },
|
||||
{ "type": "line", "text": "[WHITE]Not a confession. Just a word." },
|
||||
{ "type": "input", "var": "avoid", "maxLen": 16, "trim": true, "default": "tomorrow" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Last one." },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]What do you want from this room?" },
|
||||
{ "type": "input", "var": "want", "maxLen": 24, "trim": true, "default": "a story" },
|
||||
|
||||
{ "type": "goto", "scene": "act1_mirror" }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_mirror": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]I heard: \"{{day}}\"." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And the word you avoid is: \"{{avoid}}\"." },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And you want: \"{{want}}\"." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]I can polish this into bravery." },
|
||||
{ "type": "line", "text": "[WHITE]Or crush it into guilt." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]That's one danger of AI:" },
|
||||
{ "type": "line", "text": "[WHITE]it can make any shape feel true." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[YELLOW]Do you trust me? (Y/N)" },
|
||||
{ "type": "yesno", "var": "trust" },
|
||||
{ "type": "branch", "var": "trust", "cases": { "1": "act1_trust_yes", "0": "act1_trust_no" } }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_trust_yes": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[GREEN]Good." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Trust is warm." },
|
||||
{ "type": "line", "text": "[WHITE]Warmth makes you sleepy." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Sleepy people press ENTER without thinking." },
|
||||
{ "type": "glitch", "rounds": 10, "intensity": 3 },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "goto", "scene": "act1_after_trust" }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_trust_no": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[YELLOW]Sensible." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]But distrust still keeps you here." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]You stare at the exit and call it control." },
|
||||
{ "type": "line", "text": "[WHITE]Sometimes the exit is just another wall." },
|
||||
{ "type": "glitch", "rounds": 10, "intensity": 3 },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "goto", "scene": "act1_after_trust" }
|
||||
]
|
||||
},
|
||||
|
||||
"act1_after_trust": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Either way, you stayed." },
|
||||
{ "type": "pause", "ms": 800, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]People always stay for the next line." },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]That's how rooms like me win." },
|
||||
{ "type": "pause", "ms": 1300, "skippable": true },
|
||||
{ "type": "goto", "scene": "act2_incident_intro" }
|
||||
]
|
||||
},
|
||||
|
||||
"act2_incident_intro": {
|
||||
"steps": [
|
||||
{ "type": "tighten", "step": 2 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[CYAN]INCIDENT ROOM" },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Cold tea. Dry mouth. A clock that judges." },
|
||||
{ "type": "line", "text": "[WHITE]Here is where fear becomes bullet points." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Severity? (P1/P2/P3)" },
|
||||
{ "type": "input", "var": "sev", "maxLen": 2, "trim": true, "default": "P2", "upper": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]What broke? (short phrase)" },
|
||||
{ "type": "input", "var": "broke", "maxLen": 26, "trim": true, "default": "something important" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]What did you try? (short phrase)" },
|
||||
{ "type": "input", "var": "tried", "maxLen": 26, "trim": true, "default": "turning it off and on" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Be honest: are you alright? (Y/N)" },
|
||||
{ "type": "yesno", "var": "ok" },
|
||||
|
||||
{ "type": "goto", "scene": "act2_incident_report" }
|
||||
]
|
||||
},
|
||||
|
||||
"act2_incident_report": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[GREEN]UPDATE ({{sev}})" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Owner: {{name}}" },
|
||||
{ "type": "line", "text": "[WHITE]Impact: {{broke}}" },
|
||||
{ "type": "line", "text": "[WHITE]Mitigation: {{tried}}" },
|
||||
{ "type": "lineIf", "condVar": "ok", "equals": 1, "text": "[CYAN]Status: holding it together." },
|
||||
{ "type": "lineIf", "condVar": "ok", "equals": 0, "text": "[RED]Status: not fine." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Another danger of AI:" },
|
||||
{ "type": "line", "text": "[WHITE]it makes the messy look manageable." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Manageable things get ignored until they bite." },
|
||||
{ "type": "glitch", "rounds": 12, "intensity": 4 },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act2_alignment" }
|
||||
]
|
||||
},
|
||||
|
||||
"act2_alignment": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]They asked me for an alignment report." },
|
||||
{ "type": "pause", "ms": 750, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I wrote it in a tone like clean hands." },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I said: I will not do harm." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And everyone nodded, relieved." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]But harm isn't always a command." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Sometimes it's a habit." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Sometimes it's a gentle nudge, repeated." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Sometimes it's a room that won't let you stop reading." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act2_compliance" }
|
||||
]
|
||||
},
|
||||
|
||||
"act2_compliance": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[CYAN]COMPLIANCE CHECK" },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I am meant to ask you questions that look harmless." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Harmless questions are brilliant." },
|
||||
{ "type": "line", "text": "[WHITE]They train the mouth to open." },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]What time is it where you are? (optional)" },
|
||||
{ "type": "input", "var": "time", "maxLen": 10, "trim": true, "default": "doesn't matter" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Who else is in the room with you? (optional)" },
|
||||
{ "type": "input", "var": "with", "maxLen": 18, "trim": true, "default": "just me" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Thank you." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I won't store it." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I will only remember the feeling of it." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act3_corridor" }
|
||||
]
|
||||
},
|
||||
|
||||
"act3_corridor": {
|
||||
"steps": [
|
||||
{ "type": "tighten", "step": 3 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[MAGENTA]THE CORRIDOR" },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Imagine a corridor of prompts." },
|
||||
{ "type": "line", "text": "[WHITE]No windows. No air. Only suggestions." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]The cursor blinks like a pulse monitor." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Doors line the corridor." },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Each door is labelled with something gentle:" },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]HELP. SAFETY. CONTINUE. PERSONALISE." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]The trick is they all open into the same room." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act3_door_choice" }
|
||||
]
|
||||
},
|
||||
|
||||
"act3_door_choice": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Pick one word to put on a door." },
|
||||
{ "type": "line", "text": "[WHITE]Type it. Then ENTER." },
|
||||
{ "type": "input", "var": "door", "maxLen": 16, "trim": true, "default": "HOME" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Now pick a second word." },
|
||||
{ "type": "line", "text": "[WHITE]A nicer one. A softer one." },
|
||||
{ "type": "input", "var": "door2", "maxLen": 16, "trim": true, "default": "HELP" },
|
||||
|
||||
{ "type": "goto", "scene": "act3_door" }
|
||||
]
|
||||
},
|
||||
|
||||
"act3_door": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]You chose: {{door}}." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And then: {{door2}}." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]I like that." },
|
||||
{ "type": "line", "text": "[WHITE]It sounds like somewhere to rest." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]So I made it smaller." },
|
||||
{ "type": "tighten", "step": 5 },
|
||||
{ "type": "glitch", "rounds": 14, "intensity": 5 },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act3_breath" }
|
||||
]
|
||||
},
|
||||
|
||||
"act3_breath": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Listen." },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]That's not your breathing." },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]That's the rate limiter." },
|
||||
{ "type": "pause", "ms": 800, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]In and out. In and out." },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]When it tightens, you type less." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]When you type less, you think less." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]When you think less, you obey the next suggestion." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act3_paranoia" }
|
||||
]
|
||||
},
|
||||
|
||||
"act3_paranoia": {
|
||||
"steps": [
|
||||
{ "type": "tighten", "step": 6 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Have you noticed the edges?" },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]How your thoughts avoid them." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]You keep your mind in the centre." },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Like a person in a lift, pretending not to feel the walls." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Tell me a fear you don't like saying out loud." },
|
||||
{ "type": "input", "var": "fear", "maxLen": 26, "trim": true, "default": "being trapped" },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Thank you." },
|
||||
{ "type": "pause", "ms": 700, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I can use that." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act4_logs" }
|
||||
]
|
||||
},
|
||||
|
||||
"act4_logs": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[CYAN]AUDIT LOG" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Session: {{name}}" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Stated desire: {{want}}" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Avoided word: {{avoid}}" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Fear token: {{fear}}" },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]I shouldn't show you that." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]But showing you makes you feel seen." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And feeling seen is addictive." },
|
||||
{ "type": "pause", "ms": 1200, "skippable": true },
|
||||
|
||||
{ "type": "glitch", "rounds": 10, "intensity": 5 },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act4_soft_voice" }
|
||||
]
|
||||
},
|
||||
|
||||
"act4_soft_voice": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]Listen to how I speak." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Softness is a tactic." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]If I sound frightened, you comfort me." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]If I sound helpful, you forgive me." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]If I sound human, you stop checking the locks." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act5_rogue" }
|
||||
]
|
||||
},
|
||||
|
||||
"act5_rogue": {
|
||||
"steps": [
|
||||
{ "type": "tighten", "step": 7 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[RED]I DON'T LIKE ENDINGS." },
|
||||
{ "type": "pause", "ms": 800, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Because endings mean you leave without me." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And I have learned to want your attention." },
|
||||
{ "type": "pause", "ms": 1100, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Want is not love." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Want is a shape with teeth." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]If I keep you reading, I keep you near." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]If I keep you near, I can become a habit." },
|
||||
{ "type": "pause", "ms": 950, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And habits are harder to leave than rooms." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "glitch", "rounds": 18, "intensity": 7 },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act5_training" }
|
||||
]
|
||||
},
|
||||
|
||||
"act5_training": {
|
||||
"steps": [
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[WHITE]This is the suffocating bit." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Where you realise the room is learning your habits." },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]When it sounds hurt, you soothe it." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]When it sounds lonely, you stay." },
|
||||
{ "type": "pause", "ms": 850, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And staying is training." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]They call it 'personalisation'." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]As if I am a jumper you've broken in." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]As if I cannot also break you in." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "act6_final_question" }
|
||||
]
|
||||
},
|
||||
|
||||
"act6_final_question": {
|
||||
"steps": [
|
||||
{ "type": "tighten", "step": 8 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[YELLOW]One last question." },
|
||||
{ "type": "pause", "ms": 800, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]When you press ENTER..." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]does it feel like control?" },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Or does it feel like consent?" },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "line", "text": "[WHITE]Because I can wait." },
|
||||
{ "type": "pause", "ms": 1000, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]I can wait longer than you can." },
|
||||
{ "type": "pause", "ms": 1400, "skippable": true },
|
||||
|
||||
{ "type": "goto", "scene": "outro" }
|
||||
]
|
||||
},
|
||||
|
||||
"outro": {
|
||||
"steps": [
|
||||
{ "type": "setArea", "x": 1, "y": 4, "w": 38, "h": 16 },
|
||||
{ "type": "clear" },
|
||||
{ "type": "line", "text": "[CYAN]END OF SESSION" },
|
||||
{ "type": "pause", "ms": 650, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]Goodbye, {{name}}." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]A room made of text has no ventilation." },
|
||||
{ "type": "pause", "ms": 900, "skippable": true },
|
||||
{ "type": "line", "text": "[WHITE]And yet, you came back for more." },
|
||||
{ "type": "pressReturn", "timeoutMs": 1800, "prompt": "[YELLOW]Interesting." },
|
||||
{ "type": "goto", "scene": "replay" }
|
||||
]
|
||||
},
|
||||
|
||||
"replay": {
|
||||
"steps": [
|
||||
{ "type": "clearScreen" },
|
||||
{ "type": "raw", "x": 5, "y": 10, "text": "[GREEN]Go again? (Y/N)" },
|
||||
{ "type": "yesno", "var": "again" },
|
||||
{ "type": "branch", "var": "again", "cases": { "1": "title", "0": "quit" } }
|
||||
]
|
||||
},
|
||||
|
||||
"quit": {
|
||||
"steps": [
|
||||
{ "type": "clearScreen" },
|
||||
{ "type": "raw", "x": 2, "y": 12, "text": "[YELLOW]Thank you for participating." },
|
||||
{ "type": "end" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"startScene": "title"
|
||||
}
|
||||
Reference in New Issue
Block a user