From 22cdd68184bc0d522b295c86b8b0485a46f46bb4 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 27 Sep 2025 19:03:01 -0700 Subject: [PATCH] games --- app/src/css/terminal.css | 12 ++++++++ app/src/dispatch.ts | 7 ++++- app/src/html/layout.tsx | 8 +++++- app/src/js/shell.ts | 59 ++++++++++++++++++++++++++++++++++++++-- app/src/js/websocket.ts | 4 +-- app/src/shared/types.ts | 22 ++++++++++++++- app/src/shell.ts | 3 ++ 7 files changed, 108 insertions(+), 7 deletions(-) diff --git a/app/src/css/terminal.css b/app/src/css/terminal.css index 699164d..c190682 100644 --- a/app/src/css/terminal.css +++ b/app/src/css/terminal.css @@ -147,4 +147,16 @@ #scrollback .output { white-space: pre-wrap; +} + +/* games */ + +.game { + background-color: white; +} + +.game.active { + position: absolute; + top: 0; + left: 0; } \ No newline at end of file diff --git a/app/src/dispatch.ts b/app/src/dispatch.ts index 8cfa8f8..8db3e64 100644 --- a/app/src/dispatch.ts +++ b/app/src/dispatch.ts @@ -21,7 +21,12 @@ export async function dispatchMessage(ws: any, msg: Message) { async function inputMessage(ws: any, msg: Message) { const result = await runCommand(msg.session || "", msg.id || "", msg.data as string, ws) - send(ws, { id: msg.id, type: "output", data: result }) + + if (typeof result.output === "object" && "game" in result.output) { + send(ws, { id: msg.id, type: "game:start", data: result.output.game }) + } else { + send(ws, { id: msg.id, type: "output", data: result }) + } } async function saveFileMessage(ws: any, msg: Message) { diff --git a/app/src/html/layout.tsx b/app/src/html/layout.tsx index d79d06f..328150e 100644 --- a/app/src/html/layout.tsx +++ b/app/src/html/layout.tsx @@ -3,12 +3,18 @@ import type { FC } from "hono/jsx" export const Layout: FC = async ({ children, title }) => ( - {title || "Nose"} + {title || "NOSE (Pluto)"} + + + +
diff --git a/app/src/js/shell.ts b/app/src/js/shell.ts index 8fea051..7bcd808 100644 --- a/app/src/js/shell.ts +++ b/app/src/js/shell.ts @@ -2,11 +2,14 @@ // The shell runs on the server and processes input, returning output. import type { Message, CommandResult, CommandOutput } from "../shared/types.js" +import { Context } from "../shared/types.js" import { addInput, setStatus, addOutput, appendOutput, replaceOutput } from "./scrollback.js" import { send } from "./websocket.js" import { randomId } from "../shared/utils.js" import { addToHistory } from "./history.js" +import { focusInput } from "./focus.js" import { browserCommands, cacheCommands } from "./commands.js" +import { $ } from "./dom.js" export function runCommand(input: string) { if (!input.trim()) return @@ -29,7 +32,7 @@ export function runCommand(input: string) { } // message received from server -export function handleMessage(msg: Message) { +export async function handleMessage(msg: Message) { switch (msg.type) { case "output": handleOutput(msg); break @@ -45,6 +48,8 @@ export function handleMessage(msg: Message) { handleStreamAppend(msg); break case "stream:replace": handleStreamReplace(msg); break + case "game:start": + await handleGameStart(msg); break default: console.error("unknown message type", msg) } @@ -77,4 +82,54 @@ function handleStreamReplace(msg: Message) { } function handleStreamEnd(_msg: Message) { -} \ No newline at end of file +} + +let oldMode = "cinema" + +async function handleGameStart(msg: Message) { + const msgId = msg.id as string + const name = msg.data as string + const game = await import(`/command/${name}`) + const id = randomId() + let stopGame = false + + addOutput(msgId, { html: `` }) + + if (document.body.dataset.mode === "tall") { + browserCommands.mode?.() + oldMode = "tall" + } + + setStatus(msgId, "ok") + + const canvas = $(id) as HTMLCanvasElement + const ctx = new Context(canvas.getContext("2d")!) + canvas.focus() + canvas.addEventListener("keydown", e => { + e.preventDefault() + + if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) { + stopGame = true + if (oldMode === "tall") browserCommands.mode?.() + canvas.classList.remove("active") + canvas.style.height = canvas.height / 2 + "px" + canvas.style.width = canvas.width / 2 + "px" + focusInput() + } + }) + + let last = 0 + function loop(ts: number) { + if (stopGame) return + + const delta = ts - last + if (delta >= 1000 / 30) { + if (game.update) game.update(delta) + if (game.draw) game.draw(ctx) + last = ts + } + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) +} + diff --git a/app/src/js/websocket.ts b/app/src/js/websocket.ts index 7d82304..9e84ae4 100644 --- a/app/src/js/websocket.ts +++ b/app/src/js/websocket.ts @@ -29,10 +29,10 @@ export function send(msg: Message) { console.log("-> send", msg) } -function receive(e: MessageEvent) { +async function receive(e: MessageEvent) { const data = JSON.parse(e.data) as Message console.log("<- receive", data) - handleMessage(data) + await handleMessage(data) } // close it... plz don't do this, though diff --git a/app/src/shared/types.ts b/app/src/shared/types.ts index 39443ce..a22c041 100644 --- a/app/src/shared/types.ts +++ b/app/src/shared/types.ts @@ -6,11 +6,31 @@ export type Message = { } export type MessageType = "error" | "input" | "output" | "commands" | "save-file" + | "game:start" | "stream:start" | "stream:end" | "stream:append" | "stream:replace" -export type CommandOutput = string | string[] | { html: string } +export type CommandOutput = string | string[] | { html: string } | { game: string } export type CommandResult = { status: "ok" | "error" output: CommandOutput +} + +export class Context { + height = 540 + width = 960 + + constructor(public ctx: CanvasRenderingContext2D) { } + + circ(x: number, y: number, r: number, color = "black") { + const ctx = this.ctx + ctx.beginPath() + ctx.strokeStyle = color + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.stroke() + } + + clear() { + this.ctx.clearRect(0, 0, this.width, this.height) + } } \ No newline at end of file diff --git a/app/src/shell.ts b/app/src/shell.ts index 5f08558..e1e5af5 100644 --- a/app/src/shell.ts +++ b/app/src/shell.ts @@ -32,6 +32,9 @@ export async function runCommand(sessionId: string, taskId: string, input: strin async function exec(cmd: string, args: string[]): Promise<["ok" | "error", CommandOutput]> { const module = await import(commandPath(cmd) + "?t+" + Date.now()) + if (module?.game) + return ["ok", { game: cmd }] + if (!module || !module.default) return ["error", `${cmd} has no default export`]