Compare commits

...

17 Commits

Author SHA1 Message Date
5afb7023ec change clouds 2025-09-27 20:27:04 -07:00
419a8328fc it's alive 2025-09-27 20:12:06 -07:00
68db4a923f moving clouds 2025-09-27 20:09:13 -07:00
6cfd69c9d3 more game stuff 2025-09-27 20:06:23 -07:00
7ebabb5cc3 need you 2025-09-27 19:55:33 -07:00
1f311679f7 test game 2025-09-27 19:55:27 -07:00
f792f76f6c game api 2025-09-27 19:55:18 -07:00
d0e8f2f261 games need input 2025-09-27 19:31:46 -07:00
ab8a6b02ce game tweaks 2025-09-27 19:18:40 -07:00
2c9f8a563e game browser module 2025-09-27 19:05:29 -07:00
22cdd68184 games 2025-09-27 19:03:01 -07:00
a8102a2730 serve .ts too 2025-09-27 19:01:30 -07:00
c2f0e0a990 focus canvas 2025-09-27 19:01:13 -07:00
557e2a8a8d shared rng function 2025-09-27 19:00:52 -07:00
5fb3b0333d Fragment too 2025-09-27 18:10:46 -07:00
6dde10c2ae fix transpiler caching, jsx runtime 2025-09-27 16:52:40 -07:00
d320b2d74e serve command source 2025-09-27 16:47:09 -07:00
16 changed files with 430 additions and 41 deletions

86
app/nose/bin/game.tsx Normal file
View File

@ -0,0 +1,86 @@
/// <reference lib="dom" />
export const game = true
import type { InputState } from "@/shared/types"
import type { GameContext } from "@/shared/game"
import { rng } from "@/shared/utils.ts"
const WIDTH = 960
const HEIGHT = 540
const SPEED = 10
let x = rng(0, WIDTH)
let y = rng(0, HEIGHT)
type Cloud = { x: number; y: number; speed: number }
const clouds: Cloud[] = [
{ x: 300, y: 100, speed: 0.7 },
{ x: 600, y: 150, speed: 0.5 },
{ x: 75, y: 220, speed: 0.5 },
]
export function init() { console.log("init") }
export function update(_delta: number, input: InputState) {
if (input.pressed.has("ArrowUp") || input.pressed.has("w")) y -= 1 * SPEED
if (input.pressed.has("ArrowDown") || input.pressed.has("s")) y += 1 * SPEED
if (input.pressed.has("ArrowLeft") || input.pressed.has("a")) x -= 1 * SPEED
if (input.pressed.has("ArrowRight") || input.pressed.has("d")) x += 1 * SPEED
for (const cloud of clouds) {
cloud.x += cloud.speed
if (cloud.x > WIDTH + 80) { // off screen
cloud.x = -80
}
}
}
export function draw(ctx: GameContext) {
ctx.clear()
// sky background
ctx.rectfill(0, 0, ctx.width, ctx.height, "skyblue")
// sun
ctx.circfill(800, 100, 50, "yellow")
// grass
ctx.rectfill(0, 400, ctx.width, ctx.height, "green")
// clouds
for (const cloud of clouds) {
drawCloud(ctx, cloud.x, cloud.y)
}
// house body
ctx.rectfill(200, 250, 400, 400, "sienna")
// roof (triangle-ish with lines)
ctx.trianglefill(200, 250, 400, 250, 300, 150, "brown")
// door
ctx.rectfill(280, 320, 320, 400, "darkred")
// windows
ctx.rectfill(220, 270, 260, 310, "lightblue")
ctx.rectfill(340, 270, 380, 310, "lightblue")
// player
ctx.circfill(x, y, 10, "magenta")
ctx.circ(x, y, 11, "white")
// tree trunk
ctx.rectfill(500, 300, 540, 400, "saddlebrown")
// tree top (a few circles for puffiness)
ctx.circfill(520, 280, 40, "darkgreen")
ctx.circfill(480, 300, 40, "darkgreen")
ctx.circfill(560, 300, 40, "darkgreen")
}
function drawCloud(ctx: GameContext, x: number, y: number) {
ctx.circfill(x, y, 30, "white")
ctx.circfill(x + 30, y - 10, 40, "white")
ctx.circfill(x + 60, y, 30, "white")
ctx.circfill(x + 30, y + 10, 35, "white")
}

View File

@ -3,6 +3,8 @@
import { Glob } from "bun"
import { watch } from "fs"
import { join } from "path"
import { isFile } from "./utils"
import { sendAll } from "./websocket"
import { expectDir } from "./utils"
import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
@ -30,3 +32,23 @@ export async function findCommands(path: string): Promise<string[]> {
return list
}
export function commandPath(cmd: string): string | undefined {
return [
join(NOSE_SYS_BIN, cmd + ".ts"),
join(NOSE_SYS_BIN, cmd + ".tsx"),
join(NOSE_BIN, cmd + ".ts"),
join(NOSE_BIN, cmd + ".tsx")
].find((path: string) => isFile(path))
}
export function commandExists(cmd: string): boolean {
return commandPath(cmd) !== undefined
}
export async function commandSource(name: string): Promise<string> {
const path = commandPath(name)
if (!path) return ""
return Bun.file(path).text()
}

View File

@ -71,6 +71,10 @@
color: var(--c64-dark-blue);
}
canvas:focus {
outline: none;
}
a {
color: var(--cyan);
}

View File

@ -148,3 +148,15 @@
#scrollback .output {
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) {
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 })
}
}
async function saveFileMessage(ws: any, msg: Message) {
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 }) => (
<html lang="en">
<head>
<title>{title || "Nose"}</title>
<title>{title || "NOSE (Pluto)"}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/css/reset.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>
</head>
<body data-mode="tall">
<main>

View File

@ -27,7 +27,7 @@ export function focusHandler(e: MouseEvent) {
const a = target.closest("a")
if (!a) {
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA")
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "CANVAS")
return false
const selection = window.getSelection() || ""

102
app/src/js/game.ts Normal file
View File

@ -0,0 +1,102 @@
import type { Message, InputState } from "../shared/types.js"
import { GameContext } from "../shared/game.js"
import { focusInput } from "./focus.js"
import { $ } from "./dom.js"
import { randomId } from "../shared/utils.js"
import { setStatus, addOutput } from "./scrollback.js"
import { browserCommands } from "./commands.js"
const FPS = 30
let oldMode = "cinema"
let running = false
let canvas: HTMLCanvasElement
type Game = { init?: () => void, update?: (delta: number, input: InputState) => void, draw?: (ctx: GameContext) => void }
const pressedStack = new Set<string>()
let pressed: InputState = {
key: "",
shift: false,
ctrl: false,
meta: false,
pressed: pressedStack
}
export async function handleGameStart(msg: Message) {
const msgId = msg.id as string
const name = msg.data as string
let game
try {
game = await import(`/command/${name}`)
} catch (err: any) {
setStatus(msgId, "error")
addOutput(msgId, `Error: ${err.message ? err.message : err}`)
return
}
const canvasId = randomId()
addOutput(msgId, { html: `<canvas id="${canvasId}" class="game active" height="540" width="960" tabindex="0"></canvas>` })
if (document.body.dataset.mode === "tall") {
browserCommands.mode?.()
oldMode = "tall"
}
canvas = $(canvasId) as HTMLCanvasElement
canvas.focus()
setStatus(msgId, "ok")
canvas.addEventListener("keydown", handleKeydown)
canvas.addEventListener("keyup", handleKeyup)
gameLoop(new GameContext(canvas.getContext("2d")!), game)
}
function handleKeydown(e: KeyboardEvent) {
pressedStack.add(e.key)
e.preventDefault()
if (e.key === "Escape" || (e.ctrlKey && e.key === "c")) {
running = false
if (oldMode === "tall") browserCommands.mode?.()
canvas.classList.remove("active")
canvas.style.height = canvas.height / 2 + "px"
canvas.style.width = canvas.width / 2 + "px"
focusInput()
} else {
pressed.key = e.key
pressed.ctrl = e.ctrlKey
pressed.shift = e.shiftKey
pressed.meta = e.metaKey
}
}
function handleKeyup(e: KeyboardEvent) {
pressedStack.delete(e.key)
if (pressedStack.size === 0) {
pressed.key = ""
pressed.ctrl = false
pressed.shift = false
pressed.meta = false
}
}
function gameLoop(ctx: GameContext, game: Game) {
running = true
let last = 0
if (game.init) game.init()
function loop(ts: number) {
if (!running) return
const delta = ts - last
if (delta >= 1000 / FPS) {
if (game.update) game.update(delta, pressed)
if (game.draw) game.draw(ctx)
last = ts
}
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}

View File

@ -7,6 +7,7 @@ import { send } from "./websocket.js"
import { randomId } from "../shared/utils.js"
import { addToHistory } from "./history.js"
import { browserCommands, cacheCommands } from "./commands.js"
import { handleGameStart } from "./game.js"
export function runCommand(input: string) {
if (!input.trim()) return
@ -29,7 +30,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 +46,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)
}
@ -78,3 +81,4 @@ function handleStreamReplace(msg: Message) {
function handleStreamEnd(_msg: Message) {
}

View File

@ -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

View File

@ -9,9 +9,9 @@ import color from "kleur"
import type { Message } from "./shared/types"
import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config"
import { transpile, isFile, tilde } from "./utils"
import { apps, serveApp } from "./webapp"
import { serveApp } from "./webapp"
import { initDNS } from "./dns"
import { commands } from "./commands"
import { commands, commandSource, commandPath } from "./commands"
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
import { Layout } from "./html/layout"
@ -42,8 +42,8 @@ app.use("*", async (c, next) => {
app.on("GET", ["/js/:path{.+}", "/shared/:path{.+}"], async c => {
const path = "./src/" + c.req.path.replace("..", ".")
// path must end in .js
if (!path.endsWith(".js")) return c.text("File not found", 404)
// path must end in .js or .ts
if (!path.endsWith(".js") && !path.endsWith(".ts")) return c.text("File not found", 404)
const ts = path.replace(".js", ".ts")
if (isFile(ts)) {
@ -76,16 +76,15 @@ app.use("*", async (c, next) => {
return next()
})
app.get("/apps", c => {
const url = new URL(c.req.url)
const domain = url.hostname
let port = url.port
port = port && port !== "80" ? `:${port}` : ""
return c.html(<>
<h1>apps</h1>
<ul>{apps().map(app => <li><a href={`http://${app}.${domain}${port}`}>{app}</a></li>)}</ul>
</>)
app.get("/command/:name", async c => {
const name = c.req.param("name")
const path = commandPath(name)
if (!path) return c.text("Command not found", 404)
return new Response(await transpile(path), {
headers: {
"Content-Type": "text/javascript"
}
})
})
app.get("/", c => c.html(<Layout><Terminal /></Layout>))

143
app/src/shared/game.ts Normal file
View File

@ -0,0 +1,143 @@
export class GameContext {
constructor(public ctx: CanvasRenderingContext2D) { }
get width() { return this.ctx.canvas.width }
get height() { return this.ctx.canvas.height }
clear() {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
}
circ(x: number, y: number, r: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.strokeStyle = color
c.arc(x, y, r, 0, Math.PI * 2)
c.stroke()
c.restore()
}
circfill(x: number, y: number, r: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.fillStyle = color
c.arc(x, y, r, 0, Math.PI * 2)
c.fill()
c.restore()
}
line(x0: number, y0: number, x1: number, y1: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.strokeStyle = color
c.moveTo(x0, y0)
c.lineTo(x1, y1)
c.stroke()
c.restore()
}
oval(x0: number, y0: number, x1: number, y1: number, color = "black") {
const c = this.ctx
const w = x1 - x0
const h = y1 - y0
c.save()
c.beginPath()
c.strokeStyle = color
c.ellipse(x0 + w / 2, y0 + h / 2, Math.abs(w) / 2, Math.abs(h) / 2, 0, 0, Math.PI * 2)
c.stroke()
c.restore()
}
ovalfill(x0: number, y0: number, x1: number, y1: number, color = "black") {
const c = this.ctx
const w = x1 - x0
const h = y1 - y0
c.save()
c.beginPath()
c.fillStyle = color
c.ellipse(x0 + w / 2, y0 + h / 2, Math.abs(w) / 2, Math.abs(h) / 2, 0, 0, Math.PI * 2)
c.fill()
c.restore()
}
rect(x0: number, y0: number, x1: number, y1: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.strokeStyle = color
c.rect(x0, y0, x1 - x0, y1 - y0)
c.stroke()
c.restore()
}
rectfill(x0: number, y0: number, x1: number, y1: number, color = "black") {
const c = this.ctx
c.save()
this.ctx.fillStyle = color
this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0)
c.restore()
}
rrect(x: number, y: number, w: number, h: number, r: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.strokeStyle = color
this.roundRectPath(x, y, w, h, r)
c.stroke()
c.restore()
}
rrectfill(x: number, y: number, w: number, h: number, r: number, color = "black") {
const c = this.ctx
c.save()
c.beginPath()
c.fillStyle = color
this.roundRectPath(x, y, w, h, r)
c.fill()
c.restore()
}
trianglefill(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, color = "black") {
return this.polygonfill(
[
[x0, y0],
[x1, y1],
[x2, y2]
],
color
)
}
polygonfill(points: [number, number][], color = "black") {
if (points.length < 3) return // need at least a triangle
const c = this.ctx
c.save()
c.beginPath()
c.fillStyle = color
c.moveTo(points[0]![0], points[0]![1])
for (let i = 1; i < points.length; i++) {
c.lineTo(points[i]![0], points[i]![1])
}
c.closePath()
c.fill()
c.restore()
}
private roundRectPath(x: number, y: number, w: number, h: number, r: number) {
const c = this.ctx
c.moveTo(x + r, y)
c.lineTo(x + w - r, y)
c.quadraticCurveTo(x + w, y, x + w, y + r)
c.lineTo(x + w, y + h - r)
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
c.lineTo(x + r, y + h)
c.quadraticCurveTo(x, y + h, x, y + h - r)
c.lineTo(x, y + r)
c.quadraticCurveTo(x, y, x + r, y)
}
}

View File

@ -6,11 +6,14 @@ 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 type InputState = { key: string, shift: boolean, ctrl: boolean, meta: boolean, pressed: Set<string> }

View File

@ -7,3 +7,16 @@ export function countChar(str: string, char: string): number {
export function randomId(): string {
return Math.random().toString(36).slice(7)
}
// rng(1, 5) #=> result can be 1 2 3 4 or 5
// rng(2) #=> result can be 1 or 2
export function rng(min: number, max = 0) {
if (max === 0) {
max = min
min = 1
}
min = Math.ceil(min)
max = Math.floor(max)
return Math.floor(Math.random() * (max - min + 1)) + min
}

View File

@ -2,11 +2,9 @@
// Runs commands and such on the server.
// This is the "shell" - the "terminal" is the browser UI.
import { join } from "path"
import type { CommandResult, CommandOutput } from "./shared/types"
import type { Session } from "./session"
import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
import { isFile } from "./utils"
import { commandExists, commandPath } from "./commands"
import { ALS } from "./session"
const sessions: Map<string, Session> = new Map()
@ -34,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`]
@ -69,19 +70,6 @@ function getState(sessionId: string, taskId: string, ws?: any): Session {
return state
}
function commandPath(cmd: string): string | undefined {
return [
join(NOSE_SYS_BIN, cmd + ".ts"),
join(NOSE_SYS_BIN, cmd + ".tsx"),
join(NOSE_BIN, cmd + ".ts"),
join(NOSE_BIN, cmd + ".tsx")
].find((path: string) => isFile(path))
}
function commandExists(cmd: string): boolean {
return commandPath(cmd) !== undefined
}
function errorMessage(error: Error | any): string {
if (!(error instanceof Error))
return String(error)

View File

@ -75,14 +75,16 @@ const transpileCache: Record<string, string> = {}
// Transpile the frontend *.ts file at `path` to JavaScript.
export async function transpile(path: string): Promise<string> {
const code = await Bun.file(path).text()
const { mtime } = await stat(path)
const key = `${path}?${mtime}`
let cached = transpileCache[key]
if (!cached) {
const code = await Bun.file(path).text()
cached = transpiler.transformSync(code)
cached = cached.replaceAll(/\bjsxDEV_?\w*\(/g, "jsx(")
cached = cached.replaceAll(/\bFragment_?\w*,/g, "Fragment,")
transpileCache[key] = cached
}