rudus/pkg/ludus.js

501 lines
14 KiB
JavaScript
Raw Normal View History

2025-07-01 18:35:36 +00:00
if (window) window.ludus = {run, kill, flush_stdout, stdout, p5, svg, flush_commands, commands, result, input, is_running}
2025-06-26 02:56:39 +00:00
2025-07-01 20:30:17 +00:00
const worker_url = new URL("worker.js", import.meta.url)
const worker = new Worker(worker_url, {type: "module"})
2025-06-25 21:46:00 +00:00
2025-06-30 22:59:59 +00:00
let outbox = []
2025-07-01 18:35:36 +00:00
let ludus_console = ""
let ludus_commands = []
let ludus_result = null
let code = null
let running = false
let ready = false
2025-07-01 18:35:36 +00:00
let io_interval_id = null
worker.onmessage = handle_messages
2025-06-25 21:46:00 +00:00
2025-07-01 22:52:03 +00:00
async function handle_messages (e) {
2025-06-30 22:59:59 +00:00
let msgs
try {
msgs = JSON.parse(e.data)
} catch {
console.log(e.data)
2025-07-01 16:54:11 +00:00
throw Error("Main: bad json from Ludus")
2025-06-30 22:59:59 +00:00
}
for (const msg of msgs) {
switch (msg.verb) {
2025-07-01 20:59:42 +00:00
case "Complete": {
2025-07-01 16:54:11 +00:00
console.log("Main: ludus completed with => ", msg.data)
2025-07-01 18:35:36 +00:00
ludus_result = msg.data
2025-06-30 22:59:59 +00:00
running = false
2025-07-02 20:05:06 +00:00
ready = false
outbox = []
2025-06-30 22:59:59 +00:00
break
}
2025-07-01 18:35:36 +00:00
// TODO: do more than report these
2025-07-01 20:59:42 +00:00
case "Console": {
2025-07-01 16:54:11 +00:00
console.log("Main: ludus says => ", msg.data)
2025-07-01 18:35:36 +00:00
ludus_console = ludus_console + msg.data
break
}
2025-07-01 20:59:42 +00:00
case "Commands": {
2025-07-01 18:35:36 +00:00
console.log("Main: ludus commands => ", msg.data)
2025-07-02 00:07:02 +00:00
for (const command of msg.data) {
// attempt to solve out-of-order command bug
ludus_commands[command[1]] = command
2025-07-01 18:35:36 +00:00
}
2025-06-30 22:59:59 +00:00
break
}
2025-07-01 22:52:03 +00:00
case "Fetch": {
console.log("Main: ludus requests => ", msg.data)
const res = await fetch(msg.data, {mode: "cors"})
const text = await res.text()
console.log("Main: js responds => ", text)
outbox.push({verb: "Fetch", data: [msg.data, res.status, text]})
}
case "Ready": {
console.log("Main: ludus is ready")
ready = true
}
2025-06-30 22:59:59 +00:00
}
}
}
2025-07-01 18:35:36 +00:00
function io_poller () {
2025-06-30 22:59:59 +00:00
if (io_interval_id && !running) {
2025-07-01 16:54:11 +00:00
// flush the outbox one last time
// (presumably, with the kill message)
2025-07-01 16:54:11 +00:00
worker.postMessage(outbox)
// cancel the poller
2025-06-30 22:59:59 +00:00
clearInterval(io_interval_id)
outbox = []
}
if (ready && running) {
2025-07-01 16:54:11 +00:00
worker.postMessage(outbox)
outbox = []
2025-06-30 22:59:59 +00:00
}
}
2025-07-01 18:35:36 +00:00
function start_io_polling () {
2025-07-02 00:10:24 +00:00
io_interval_id = setInterval(io_poller, 10)
2025-06-30 22:59:59 +00:00
}
2025-06-25 21:46:00 +00:00
2025-07-01 18:35:36 +00:00
// runs a ludus script; does not return the result
// the result must be explicitly polled with `result`
2025-06-26 02:56:39 +00:00
export function run (source) {
2025-07-02 20:05:06 +00:00
if (running || ready) {
return "TODO: handle this? should not be running"
}
2025-06-30 22:59:59 +00:00
running = true
ready = false
2025-07-01 22:52:03 +00:00
result = null
2025-06-25 21:46:00 +00:00
code = source
worker.postMessage([{verb: "Run", data: source}])
outbox = []
2025-07-01 18:35:36 +00:00
start_io_polling()
}
// tells if the ludus script is still running
export function is_running() {
return running && ready
2025-06-30 22:59:59 +00:00
}
2025-07-01 18:35:36 +00:00
// kills a ludus script
2025-06-30 22:59:59 +00:00
export function kill () {
running = false
2025-07-01 20:59:42 +00:00
outbox.push({verb: "Kill"})
console.log("Main: Killed Ludus")
2025-06-30 22:59:59 +00:00
}
2025-07-01 18:35:36 +00:00
// sends text into ludus (status: not working)
2025-06-30 22:59:59 +00:00
export function input (text) {
2025-07-02 20:20:22 +00:00
console.log("Main: calling `input` with ", text)
2025-07-01 20:59:42 +00:00
outbox.push({verb: "Input", data: text})
2025-06-25 21:46:00 +00:00
}
2025-07-01 18:35:36 +00:00
// returns the contents of the ludus console and resets the console
export function flush_stdout () {
let out = ludus_console
ludus_console = ""
out
}
// returns the contents of the ludus console, retaining them
export function stdout () {
return ludus_console
}
// returns the array of turtle commands
export function commands () {
return ludus_commands
2025-06-25 21:46:00 +00:00
}
2025-07-01 18:35:36 +00:00
// returns the array of turtle commands and clears it
export function flush_commands () {
let out = ludus_commands
ludus_commands = []
out
2025-06-25 21:46:00 +00:00
}
2025-07-01 18:35:36 +00:00
// returns the ludus result
// this is effectively Option<String>:
// null if no result has been returned, or
// a string representation of the result
2025-06-26 02:56:39 +00:00
export function result () {
2025-07-01 18:35:36 +00:00
return ludus_result
2025-06-25 21:46:00 +00:00
}
2025-07-01 18:35:36 +00:00
//////////// turtle plumbing below
// TODO: refactor this out into modules
2025-06-25 21:46:00 +00:00
const turtle_init = {
position: [0, 0],
heading: 0,
pendown: true,
pencolor: "white",
penwidth: 1,
visible: true
}
const colors = {
black: [0, 0, 0, 255],
silver: [192, 192, 192, 255],
gray: [128, 128, 128, 255],
white: [255, 255, 255, 255],
maroon: [128, 0, 0, 255],
red: [255, 0, 0, 255],
purple: [128, 0, 128, 255],
fuchsia: [255, 0, 255, 255],
green: [0, 128, 0, 255],
lime: [0, 255, 0, 255],
olive: [128, 128, 0, 255],
yellow: [255, 255, 0, 255],
navy: [0, 0, 128, 255],
blue: [0, 0, 255, 255],
teal: [0, 128, 128, 255],
aqua: [0, 255, 255, 255],
}
function resolve_color (color) {
if (typeof color === 'string') return colors[color]
if (typeof color === 'number') return [color, color, color, 255]
if (Array.isArray(color)) return color
return [0, 0, 0, 255] // default to black?
}
let background_color = "black"
function add (v1, v2) {
const [x1, y1] = v1
const [x2, y2] = v2
return [x1 + x2, y1 + y2]
}
function mult (vector, scalar) {
const [x, y] = vector
return [x * scalar, y * scalar]
}
function unit_of (heading) {
const turns = -heading + 0.25
const radians = turn_to_rad(turns)
return [Math.cos(radians), Math.sin(radians)]
}
2025-07-02 00:07:02 +00:00
function command_to_state (prev_state, command) {
const [_target, _id, curr_command] = command
2025-07-01 18:35:36 +00:00
const [verb] = curr_command
2025-06-25 21:46:00 +00:00
switch (verb) {
case "goto": {
const [_, x, y] = curr_command
return {...prev_state, position: [x, y]}
}
case "home": {
return {...prev_state, position: [0, 0], heading: 0}
}
case "right": {
const [_, angle] = curr_command
const {heading} = prev_state
return {...prev_state, heading: heading + angle}
}
case "left": {
const [_, angle] = curr_command
const {heading} = prev_state
return {...prev_state, heading: heading - angle}
}
case "forward": {
const [_, steps] = curr_command
const {heading, position} = prev_state
const unit = unit_of(heading)
const move = mult(unit, steps)
return {...prev_state, position: add(position, move)}
}
case "back": {
const [_, steps] = curr_command
const {heading, position} = prev_state
const unit = unit_of(heading)
const move = mult(unit, -steps)
return {...prev_state, position: add(position, move)}
}
case "penup": {
return {...prev_state, pendown: false}
}
case "pendown": {
return {...prev_state, pendown: true}
}
case "penwidth": {
const [_, width] = curr_command
return {...prev_state, penwidth: width}
}
case "pencolor": {
const [_, color] = curr_command
return {...prev_state, pencolor: color}
}
case "setheading": {
const [_, heading] = curr_command
return {...prev_state, heading: heading}
}
case "loadstate": {
// console.log("LOADSTATE: ", curr_command)
const [_, [x, y], heading, visible, pendown, penwidth, pencolor] = curr_command
return {position: [x, y], heading, visible, pendown, penwidth, pencolor}
}
case "show": {
return {...prev_state, visible: true}
}
case "hide": {
return {...prev_state, visible: false}
}
case "background": {
background_color = curr_command[1]
return prev_state
}
}
}
function eq_vect (v1, v2) {
const [x1, y1] = v1
const [x2, y2] = v2
return (x1 === x2) && (y1 === y2)
}
function eq_color (c1, c2) {
if (c1 === c2) return true
const res1 = resolve_color(c1)
const res2 = resolve_color(c2)
for (let i = 0; i < res1.length; ++i) {
if (res1[i] !== res2[i]) return false
}
return true
}
function states_to_call (prev, curr) {
const calls = []
// whose state should we use?
// pen states will only differ on more than one property
// if we use `loadstate`
// my sense is `prev`, but that may change
if (prev.pendown && !eq_vect(prev.position, curr.position)) {
calls.push(["line", prev.position[0], prev.position[1], curr.position[0], curr.position[1]])
}
if (!eq_color(curr.pencolor, prev.pencolor)) {
calls.push(["stroke", ...resolve_color(curr.pencolor)])
}
if (curr.penwidth !== prev.penwidth) {
calls.push(["strokeWeight", curr.penwidth])
}
return calls
}
const turtle_radius = 20
const turtle_angle = 0.385
let turtle_color = [255, 255, 255, 150]
function p5_call_root () {
return [
["background", ...resolve_color(background_color)],
["push"],
["rotate", Math.PI],
["scale", -1, 1],
["stroke", ...resolve_color(turtle_init.pencolor)],
]
}
function rotate (vector, heading) {
const radians = turn_to_rad(heading)
const [x, y] = vector
return [
(x * Math.cos (radians)) - (y * Math.sin (radians)),
(x * Math.sin (radians)) + (y * Math.cos (radians))
]
}
function turn_to_rad (heading) {
return (heading % 1) * 2 * Math.PI
}
function turn_to_deg (heading) {
return (heading % 1) * 360
}
function hex (n) {
return n.toString(16).padStart(2, "0")
}
function svg_render_line (prev, curr) {
if (!prev.pendown) return ""
if (eq_vect(prev.position, curr.position)) return ""
const {position: [x1, y1], pencolor, penwidth} = prev
const {position: [x2, y2]} = curr
const [r, g, b, a] = resolve_color(pencolor)
return `
<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#${hex(r)}${hex(g)}${hex(b)}" stroke-linecap="square" stroke-opacity="${a/255}" stroke-width="${penwidth}"/>
`
}
function escape_svg (svg) {
return svg
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}
2025-06-26 02:56:39 +00:00
export function extract_ludus (svg) {
2025-06-25 21:46:00 +00:00
const code = svg.split("<ludus>")[1]?.split("</ludus>")[0] ?? ""
return code
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, `"`)
.replace(/&apos;/g, `'`)
}
function svg_render_path (states) {
const path = []
for (let i = 1; i < states.length; ++i) {
const prev = states[i - 1]
const curr = states[i]
path.push(svg_render_line(prev, curr))
}
return path.join("")
}
function svg_render_turtle (state) {
if (!state.visible) return ""
const [fr, fg, fb, fa] = turtle_color
const fill_alpha = fa/255
const {heading, pencolor, position: [x, y], pendown, penwidth} = state
const origin = [0, turtle_radius]
const [x1, y1] = origin
const [x2, y2] = rotate(origin, turtle_angle)
const [x3, y3] = rotate(origin, -turtle_angle)
const [pr, pg, pb, pa] = resolve_color(pencolor)
const pen_alpha = pa/255
const ink = pendown ? `<line x1="${x1}" y1="${y1}" x2="0" y2="0" stroke="#${hex(pr)}${hex(pg)}${hex(pb)}" stroke-linecap="round" stroke-opacity="${pen_alpha}" stroke-width="${penwidth}" />` : ""
return `
<g transform="translate(${x}, ${y})rotate(${-turn_to_deg(heading)})">
<polygon points="${x1} ${y1} ${x2} ${y2} ${x3} ${y3}" stroke="none" fill="#${hex(fr)}${hex(fg)}${hex(fb)})" fill-opacity="${fill_alpha}"/>
${ink}
</g>
`
}
2025-06-26 02:56:39 +00:00
export function svg (commands) {
2025-06-25 21:46:00 +00:00
// console.log(commands)
const states = [turtle_init]
commands.reduce((prev_state, command) => {
const new_state = command_to_state(prev_state, command)
states.push(new_state)
return new_state
}, turtle_init)
// console.log(states)
const {maxX, maxY, minX, minY} = states.reduce((accum, {position: [x, y]}) => {
accum.maxX = Math.max(accum.maxX, x)
accum.maxY = Math.max(accum.maxY, y)
accum.minX = Math.min(accum.minX, x)
accum.minY = Math.min(accum.minY, y)
return accum
}, {maxX: 0, maxY: 0, minX: 0, minY: 0})
2025-07-01 18:35:36 +00:00
const [r, g, b] = resolve_color(background_color)
2025-06-25 21:46:00 +00:00
if ((r+g+b)/3 > 128) turtle_color = [0, 0, 0, 150]
const view_width = (maxX - minX) * 1.2
const view_height = (maxY - minY) * 1.2
const margin = Math.max(view_width, view_height) * 0.1
const x_origin = minX - margin
const y_origin = -maxY - margin
const path = svg_render_path(states)
const turtle = svg_render_turtle(states[states.length - 1])
return `<?xml version="1.0" standalone="no"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="${x_origin} ${y_origin} ${view_width} ${view_height}" width="10in" height="8in">
<rect x="${x_origin - 5}" y="${y_origin - 5}" width="${view_width + 10}" height="${view_height + 10}" fill="#${hex(r)}${hex(g)}${hex(b)}" stroke-width="0" paint-order="fill" />
<g transform="scale(-1, 1) rotate(180)">
${path}
${turtle}
</g>
<ludus>
${escape_svg(code)}
</ludus>
</svg>
`
}
function p5_render_turtle (state, calls) {
if (!state.visible) return
calls.push(["push"])
const [r, g, b, a] = turtle_color
calls.push(["fill", r, g, b, a])
const {heading, pencolor, position: [x, y], pendown, penwidth} = state
const origin = [0, turtle_radius]
const [x1, y1] = origin
const [x2, y2] = rotate(origin, turtle_angle)
const [x3, y3] = rotate(origin, -turtle_angle)
calls.push(["translate", x, y])
// need negative turtle rotation with the other p5 translations
calls.push(["rotate", -turn_to_rad(heading)])
calls.push(["noStroke"])
calls.push(["beginShape"])
calls.push(["vertex", x1, y1])
calls.push(["vertex", x2, y2])
calls.push(["vertex", x3, y3])
calls.push(["endShape"])
calls.push(["strokeWeight", penwidth])
calls.push(["stroke", ...resolve_color(pencolor)])
if (pendown) calls.push(["line", 0, 0, x1, y1])
calls.push(["pop"])
return calls
}
2025-06-26 02:56:39 +00:00
export function p5 (commands) {
2025-06-25 21:46:00 +00:00
const states = [turtle_init]
commands.reduce((prev_state, command) => {
const new_state = command_to_state(prev_state, command)
states.push(new_state)
return new_state
}, turtle_init)
// console.log(states)
const [r, g, b, _] = resolve_color(background_color)
if ((r + g + b)/3 > 128) turtle_color = [0, 0, 0, 150]
const p5_calls = [...p5_call_root()]
for (let i = 1; i < states.length; ++i) {
const prev = states[i - 1]
const curr = states[i]
const calls = states_to_call(prev, curr)
for (const call of calls) {
p5_calls.push(call)
}
}
p5_calls[0] = ["background", ...resolve_color(background_color)]
p5_render_turtle(states[states.length - 1], p5_calls)
p5_calls.push(["pop"])
return p5_calls
}
2025-06-30 16:48:50 +00:00