mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 15:04:52 +00:00
feat(tui): overlay positioning API with CSS-like values
Add OverlayOptions for configurable positioning (anchor, margins, offsets, percentages). Add OverlayHandle for programmatic visibility control with hide/setHidden/isHidden. Add visible callback for responsive overlays. Extension API: ctx.ui.custom() now accepts overlayOptions and onHandle callback. Examples: overlay-qa-tests.ts (10 test commands), doom-overlay (DOOM at 35 FPS).
This commit is contained in:
parent
d29f268f46
commit
a4ccff382c
22 changed files with 1344 additions and 103 deletions
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* DOOM Component for overlay mode
|
||||
*
|
||||
* Renders DOOM frames using half-block characters (▀) with 24-bit color.
|
||||
* Height is calculated from width to maintain DOOM's aspect ratio.
|
||||
*/
|
||||
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { isKeyRelease, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { DoomEngine } from "./doom-engine.js";
|
||||
import { DoomKeys, mapKeyToDoom } from "./doom-keys.js";
|
||||
|
||||
function renderHalfBlock(
|
||||
rgba: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
targetCols: number,
|
||||
targetRows: number,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const scaleX = width / targetCols;
|
||||
const scaleY = height / (targetRows * 2);
|
||||
|
||||
for (let row = 0; row < targetRows; row++) {
|
||||
let line = "";
|
||||
const srcY1 = Math.floor(row * 2 * scaleY);
|
||||
const srcY2 = Math.floor((row * 2 + 1) * scaleY);
|
||||
|
||||
for (let col = 0; col < targetCols; col++) {
|
||||
const srcX = Math.floor(col * scaleX);
|
||||
const idx1 = (srcY1 * width + srcX) * 4;
|
||||
const idx2 = (srcY2 * width + srcX) * 4;
|
||||
const r1 = rgba[idx1] ?? 0,
|
||||
g1 = rgba[idx1 + 1] ?? 0,
|
||||
b1 = rgba[idx1 + 2] ?? 0;
|
||||
const r2 = rgba[idx2] ?? 0,
|
||||
g2 = rgba[idx2 + 1] ?? 0,
|
||||
b2 = rgba[idx2 + 2] ?? 0;
|
||||
line += `\x1b[38;2;${r1};${g1};${b1}m\x1b[48;2;${r2};${g2};${b2}m▀`;
|
||||
}
|
||||
line += "\x1b[0m";
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class DoomOverlayComponent implements Component {
|
||||
private engine: DoomEngine;
|
||||
private tui: TUI;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onExit: () => void;
|
||||
|
||||
// Opt-in to key release events for smooth movement
|
||||
wantsKeyRelease = true;
|
||||
|
||||
constructor(tui: TUI, engine: DoomEngine, onExit: () => void, resume = false) {
|
||||
this.tui = tui;
|
||||
this.engine = engine;
|
||||
this.onExit = onExit;
|
||||
|
||||
// Unpause if resuming
|
||||
if (resume) {
|
||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
||||
}
|
||||
|
||||
this.startGameLoop();
|
||||
}
|
||||
|
||||
private startGameLoop(): void {
|
||||
this.interval = setInterval(() => {
|
||||
try {
|
||||
this.engine.tick();
|
||||
// Force full re-render to prevent bleed artifacts at high frame rates
|
||||
this.tui.requestRender(true);
|
||||
} catch {
|
||||
// WASM error (e.g., exit via DOOM menu) - treat as quit
|
||||
this.dispose();
|
||||
this.onExit();
|
||||
}
|
||||
}, 1000 / 35);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Q to pause and exit (but not on release)
|
||||
if (!isKeyRelease(data) && (data === "q" || data === "Q")) {
|
||||
// Send DOOM's pause key before exiting
|
||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
||||
this.dispose();
|
||||
this.onExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const doomKeys = mapKeyToDoom(data);
|
||||
if (doomKeys.length === 0) return;
|
||||
|
||||
const released = isKeyRelease(data);
|
||||
|
||||
for (const key of doomKeys) {
|
||||
this.engine.pushKey(!released, key);
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// DOOM renders at 640x400 (1.6:1 ratio)
|
||||
// With half-block characters, each terminal row = 2 pixels
|
||||
// So effective ratio is 640:200 = 3.2:1 (width:height in terminal cells)
|
||||
// Add 1 row for footer
|
||||
const ASPECT_RATIO = 3.2;
|
||||
const MIN_HEIGHT = 10;
|
||||
const height = Math.max(MIN_HEIGHT, Math.floor(width / ASPECT_RATIO));
|
||||
|
||||
const rgba = this.engine.getFrameRGBA();
|
||||
const lines = renderHalfBlock(rgba, this.engine.width, this.engine.height, width, height);
|
||||
|
||||
// Footer
|
||||
const footer = " DOOM | Q=Pause | WASD=Move | Shift+WASD=Run | Space=Use | F=Fire | 1-7=Weapons";
|
||||
const truncatedFooter = footer.length > width ? footer.slice(0, width) : footer;
|
||||
lines.push(`\x1b[2m${truncatedFooter}\x1b[0m`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue