This commit is contained in:
Chris Wanstrath 2025-09-27 19:03:01 -07:00
parent a8102a2730
commit 22cdd68184
7 changed files with 108 additions and 7 deletions

View File

@ -148,3 +148,15 @@
#scrollback .output { #scrollback .output {
white-space: pre-wrap; white-space: pre-wrap;
} }
/* games */
.game {
background-color: white;
}
.game.active {
position: absolute;
top: 0;
left: 0;
}

View File

@ -21,8 +21,13 @@ export async function dispatchMessage(ws: any, msg: Message) {
async function inputMessage(ws: any, msg: Message) { async function inputMessage(ws: any, msg: Message) {
const result = await runCommand(msg.session || "", msg.id || "", msg.data as string, ws) const result = await runCommand(msg.session || "", msg.id || "", msg.data as string, ws)
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 }) send(ws, { id: msg.id, type: "output", data: result })
} }
}
async function saveFileMessage(ws: any, msg: Message) { async function saveFileMessage(ws: any, msg: Message) {
if (msg.id && typeof msg.data === "string") { if (msg.id && typeof msg.data === "string") {

View File

@ -3,12 +3,18 @@ import type { FC } from "hono/jsx"
export const Layout: FC = async ({ children, title }) => ( export const Layout: FC = async ({ children, title }) => (
<html lang="en"> <html lang="en">
<head> <head>
<title>{title || "Nose"}</title> <title>{title || "NOSE (Pluto)"}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/css/reset.css" rel="stylesheet" /> <link href="/css/reset.css" rel="stylesheet" />
<link href="/css/main.css" rel="stylesheet" /> <link href="/css/main.css" rel="stylesheet" />
<script type="importmap" dangerouslySetInnerHTML={{
__html: `{ "imports": { "@/": "/" } }`
}} />
<script src="/js/main.js" type="module" async></script> <script src="/js/main.js" type="module" async></script>
</head> </head>
<body data-mode="tall"> <body data-mode="tall">
<main> <main>

View File

@ -2,11 +2,14 @@
// The shell runs on the server and processes input, returning output. // The shell runs on the server and processes input, returning output.
import type { Message, CommandResult, CommandOutput } from "../shared/types.js" 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 { addInput, setStatus, addOutput, appendOutput, replaceOutput } from "./scrollback.js"
import { send } from "./websocket.js" import { send } from "./websocket.js"
import { randomId } from "../shared/utils.js" import { randomId } from "../shared/utils.js"
import { addToHistory } from "./history.js" import { addToHistory } from "./history.js"
import { focusInput } from "./focus.js"
import { browserCommands, cacheCommands } from "./commands.js" import { browserCommands, cacheCommands } from "./commands.js"
import { $ } from "./dom.js"
export function runCommand(input: string) { export function runCommand(input: string) {
if (!input.trim()) return if (!input.trim()) return
@ -29,7 +32,7 @@ export function runCommand(input: string) {
} }
// message received from server // message received from server
export function handleMessage(msg: Message) { export async function handleMessage(msg: Message) {
switch (msg.type) { switch (msg.type) {
case "output": case "output":
handleOutput(msg); break handleOutput(msg); break
@ -45,6 +48,8 @@ export function handleMessage(msg: Message) {
handleStreamAppend(msg); break handleStreamAppend(msg); break
case "stream:replace": case "stream:replace":
handleStreamReplace(msg); break handleStreamReplace(msg); break
case "game:start":
await handleGameStart(msg); break
default: default:
console.error("unknown message type", msg) console.error("unknown message type", msg)
} }
@ -78,3 +83,53 @@ function handleStreamReplace(msg: Message) {
function handleStreamEnd(_msg: Message) { function handleStreamEnd(_msg: Message) {
} }
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: `<canvas id="${id}" class="game active" height="540" width="960" tabindex="0"></canvas>` })
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)
}

View File

@ -29,10 +29,10 @@ export function send(msg: Message) {
console.log("-> send", msg) console.log("-> send", msg)
} }
function receive(e: MessageEvent) { async function receive(e: MessageEvent) {
const data = JSON.parse(e.data) as Message const data = JSON.parse(e.data) as Message
console.log("<- receive", data) console.log("<- receive", data)
handleMessage(data) await handleMessage(data)
} }
// close it... plz don't do this, though // close it... plz don't do this, though

View File

@ -6,11 +6,31 @@ export type Message = {
} }
export type MessageType = "error" | "input" | "output" | "commands" | "save-file" export type MessageType = "error" | "input" | "output" | "commands" | "save-file"
| "game:start"
| "stream:start" | "stream:end" | "stream:append" | "stream:replace" | "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 = { export type CommandResult = {
status: "ok" | "error" status: "ok" | "error"
output: CommandOutput 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)
}
}

View File

@ -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]> { async function exec(cmd: string, args: string[]): Promise<["ok" | "error", CommandOutput]> {
const module = await import(commandPath(cmd) + "?t+" + Date.now()) const module = await import(commandPath(cmd) + "?t+" + Date.now())
if (module?.game)
return ["ok", { game: cmd }]
if (!module || !module.default) if (!module || !module.default)
return ["error", `${cmd} has no default export`] return ["error", `${cmd} has no default export`]