luanette 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +19 -2
- package/api.json +1 -0
- package/engine/luanette-node.js +0 -0
- package/lib/gif.js +103 -0
- package/lib/tables.js +67 -0
- package/luanette.mjs +456 -0
- package/mcp.mjs +175 -0
- package/package.json +11 -4
- package/pyrandom.mjs +50 -0
- package/templates/blank.lua +45 -0
- package/templates/platformer.lua +95 -0
- package/templates/puzzle.lua +96 -0
- package/templates/shooter.lua +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geofresh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# luanette
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The command-line tool for [Luanette](https://github.com/Geofresh/luanette), a Lua game console for
|
|
4
|
+
ESP32-S3 boards. It runs the console's own engine (WebAssembly, the same as the
|
|
5
|
+
[playground](https://geofresh.github.io/luanette/)) in Node, so there's nothing to build:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx luanette check game.lua # does it run? the same checks as the console's check.py
|
|
9
|
+
npx luanette run game.lua --input play.json # play it with your own input; screenshots and a GIF
|
|
10
|
+
npx luanette sound "C4 pulse 5, E4 slide, G4 saw 7 fadeout"
|
|
11
|
+
npx luanette link game.lua # a link that opens it in the playground
|
|
12
|
+
npx luanette new game --template platformer
|
|
13
|
+
npx luanette mcp # a server for coding agents (MCP)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Every command and the input script are in
|
|
17
|
+
[docs/cli.md](https://github.com/Geofresh/luanette/blob/main/docs/cli.md). If you're an agent,
|
|
18
|
+
read [AGENTS.md](https://github.com/Geofresh/luanette/blob/main/AGENTS.md) first.
|
|
19
|
+
|
|
20
|
+
MIT licensed.
|
package/api.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"abs","section":"Time, numbers, saving","help":[{"sig":"abs(x)","does":"the usual"}]},{"name":"act","section":"The host","help":[{"sig":"act(name, [arg])","does":"a command to the console: \"open\" opens the cart named arg; \"volume\" and \"brightness\" set that setting to the step arg (\"0\" to \"3\"). The launcher uses all three"}]},{"name":"btn","section":"Input","help":[{"sig":"btn(b)","does":"true while button b is held"}]},{"name":"btnp","section":"Input","help":[{"sig":"btnp(b)","does":"true on the frame b went down"}]},{"name":"buzz","section":"Feedback","help":[{"sig":"buzz(ms)","does":"the vibration motor, 10-500 ms (this board has none: caps().buzz is false)"}]},{"name":"camera","section":"Drawing","help":[{"sig":"camera(x, y)","does":"offset everything drawn by (-x, -y) / reset"},{"sig":"camera()","does":"offset everything drawn by (-x, -y) / reset"}]},{"name":"caps","section":"The host","help":[{"sig":"caps()","does":"what the cart runs on, as a table: the canvas size, where the picture sits, the board's name, what it has (touch, tilt, sound...). The fields are under \"Versions and what a cart runs on\""}]},{"name":"ceil","section":"Time, numbers, saving","help":[{"sig":"ceil(x)","does":"the usual"}]},{"name":"circ","section":"Drawing","help":[{"sig":"circ(x, y, r, [c])","does":"a circle outline / filled circle"}]},{"name":"circfill","section":"Drawing","help":[{"sig":"circfill(x, y, r, [c])","does":"a circle outline / filled circle"}]},{"name":"clip","section":"Drawing","help":[{"sig":"clip(x, y, w, h)","does":"draw only inside a rectangle / reset"},{"sig":"clip()","does":"draw only inside a rectangle / reset"}]},{"name":"cls","section":"Drawing","help":[{"sig":"cls([c])","does":"clear the screen (default 0)"}]},{"name":"data","section":"The host","help":[{"sig":"data(name)","does":"the latest value of a channel as a table, or nil: \"battery\" (mv, percent, charging, usb), \"carts\" (the carts on the board: a list of name, title, about), \"settings\" (volume and brightness, each a step from 0 to 3)"}]},{"name":"date","section":"Time, numbers, saving","help":[{"sig":"date([t])","does":"local time (the host's timezone, kept for offline use) as a table: year, month, day, hour, min, sec, wday (1 = Sunday), yday; for now() when t is left out. nil while the date is unknown"}]},{"name":"flr","section":"Time, numbers, saving","help":[{"sig":"flr(x)","does":"the usual"}]},{"name":"fps","section":"Time, numbers, saving","help":[{"sig":"fps(rate)","does":"the frame rate from the next frame (any rate that divides 60: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60) / lazy: active while there's input (a touch on the cart, a button) and half a second after, idle otherwise / the rate now. An idle cart wakes at once when a touch comes down or BOOT is pressed, so a slow idle rate costs no response time: give idle the slowest that still shows what moves on its own (a blink twice a second needs 4; a seconds counter 1 or 2). A cart doing a few frames a second instead of 30 or 60 spends that much less of the board's battery on its frames. Move things by time(), not by frames, when the rate changes"},{"sig":"fps(active, idle)","does":"the frame rate from the next frame (any rate that divides 60: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60) / lazy: active while there's input (a touch on the cart, a button) and half a second after, idle otherwise / the rate now. An idle cart wakes at once when a touch comes down or BOOT is pressed, so a slow idle rate costs no response time: give idle the slowest that still shows what moves on its own (a blink twice a second needs 4; a seconds counter 1 or 2). A cart doing a few frames a second instead of 30 or 60 spends that much less of the board's battery on its frames. Move things by time(), not by frames, when the rate changes"},{"sig":"fps()","does":"the frame rate from the next frame (any rate that divides 60: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60) / lazy: active while there's input (a touch on the cart, a button) and half a second after, idle otherwise / the rate now. An idle cart wakes at once when a touch comes down or BOOT is pressed, so a slow idle rate costs no response time: give idle the slowest that still shows what moves on its own (a blink twice a second needs 4; a seconds counter 1 or 2). A cart doing a few frames a second instead of 30 or 60 spends that much less of the board's battery on its frames. Move things by time(), not by frames, when the rate changes"}]},{"name":"ground","section":"3D","help":[{"sig":"ground(y, [color])","does":"shadows fall on the plane at height y, in color (the ground's color in shadow, 0xRRGGBB; default black)"}]},{"name":"hd.circle","section":"The full-color layer (hd)","help":[{"sig":"hd.circle(x, y, r, rgb, [a])","does":"a filled circle / a ring width px wide inside radius r"}]},{"name":"hd.clear","section":"The full-color layer (hd)","help":[{"sig":"hd.clear([rgb, a])","does":"wipe the canvas (inside the clip) to clear, or to a color at alpha a (255)"}]},{"name":"hd.clip","section":"The full-color layer (hd)","help":[{"sig":"hd.clip(x, y, w, h)","does":"draw only inside a box / anywhere (inside an hd.repaint: inside the box cut to the repaint's / the repaint's box)"},{"sig":"hd.clip()","does":"draw only inside a box / anywhere (inside an hd.repaint: inside the box cut to the repaint's / the repaint's box)"}]},{"name":"hd.frame","section":"The full-color layer (hd)","help":[{"sig":"hd.frame(x, y, w, h, rgb, [width, radius, a])","does":"a box's outline, width px (1)"}]},{"name":"hd.gradient","section":"The full-color layer (hd)","help":[{"sig":"hd.gradient(x, y, w, h, top, bottom, [a])","does":"a box shading from top to bottom, ordered-dithered (RGB565 has 32 steps of red and blue: a slow dark gradient would band)"}]},{"name":"hd.image","section":"The full-color layer (hd)","help":[{"sig":"hd.image(name, x, y, [w, h, a])","does":"draws an image (below) with its top left at x, y (to the pixel), at its own size or scaled to w x h (nearest pixel), at alpha a over its own; returns true, or false when there's no such image (yet)"}]},{"name":"hd.images","section":"The full-color layer (hd)","help":[{"sig":"hd.images()","does":"the names of the cart's own images"}]},{"name":"hd.inset","section":"The full-color layer (hd)","help":[{"sig":"hd.inset(y, [h])","does":"how far in from each side rows y to y + h show on this glass (its corners are rounded): keep a bar or a line of text that far in. 0 for the window, and for rows clear of the corners"}]},{"name":"hd.line","section":"The full-color layer (hd)","help":[{"sig":"hd.line(x0, y0, x1, y1, rgb, [width, a])","does":"a line width px wide (1), round ends"}]},{"name":"hd.measure","section":"The full-color layer (hd)","help":[{"sig":"hd.measure(text, [size, font])","does":"its width and line height"}]},{"name":"hd.order","section":"The full-color layer (hd)","help":[{"sig":"hd.order(\"over\" | \"under\")","does":"the layer over the picture (default), or under it: then the picture's color 0 shows the layer through, as over a 3D scene. Under a 3D scene's picture too: scene, layer, picture, in that order when \"under\""}]},{"name":"hd.rect","section":"The full-color layer (hd)","help":[{"sig":"hd.rect(x, y, w, h, rgb, [a, radius])","does":"a filled box, its corners rounded by radius"}]},{"name":"hd.repaint","section":"The full-color layer (hd)","help":[{"sig":"hd.repaint(x, y, w, h, fn, ...)","does":"calls fn(...) with all drawing kept inside the box, then puts the clip back: part of the screen drawn again by the code that draws it. fn paints the box's background too (the old pixels are there). Repaints nest (the inner box is cut to the outer); fn isn't called for an empty box"}]},{"name":"hd.ring","section":"The full-color layer (hd)","help":[{"sig":"hd.ring(x, y, r, rgb, [width, a])","does":"a filled circle / a ring width px wide inside radius r"}]},{"name":"hd.scanlines","section":"The full-color layer (hd)","help":[{"sig":"hd.scanlines(on)","does":"the scanlines on the layer (default on) / whether they're on"},{"sig":"hd.scanlines()","does":"the scanlines on the layer (default on) / whether they're on"}]},{"name":"hd.scroll","section":"The full-color layer (hd)","help":[{"sig":"hd.scroll(x, y, w, h, dy)","does":"moves what's in the box by dy rows (down when positive); the strip it uncovers keeps stale pixels, for the cart to draw again. The whole width (x 0, w the canvas's) moves nothing (the rows are re-mapped): scrolling a list costs only its new strip. A narrower box copies its pixels. ui.scroller does all this"}]},{"name":"hd.text","section":"The full-color layer (hd)","help":[{"sig":"hd.text(text, x, y, rgb, [size, font, align, a])","does":"text from (x, y) = the top of its line; returns its width. font: \"mono\" (JetBrains Mono, 12/14/16/20/24 px), \"bold\" (16/18/24/32) or \"vt\" (VT323, big terminal digits: 30/46/72, ASCII only); size snaps to the nearest the font has (16). align: \"left\", \"center\" or \"right\" of x. Latin-1, punctuation, arrows, box drawing, ★ ♥ ✓ and friends; anything else shows as a small box"}]},{"name":"hd.wrap","section":"The full-color layer (hd)","help":[{"sig":"hd.wrap(text, width, [size, font])","does":"a table of lines that fit width (at spaces; \\n breaks; a word too long is broken)"}]},{"name":"hide","section":"3D","help":[{"sig":"hide(o, [hidden])","does":"hide it (hide(o, false) shows it again)"}]},{"name":"light","section":"3D","help":[{"sig":"light(azimuth, elevation, [intensity, ambient])","does":"the sun's direction in degrees, its strength 0-255 (255), and the shadows' color (0x22283E)"}]},{"name":"line","section":"Drawing","help":[{"sig":"line(x0, y0, x1, y1, [c])","does":"a line"}]},{"name":"load","section":"Time, numbers, saving","help":[{"sig":"load()","does":"keep a small table (numbers, strings, booleans, nested tables; at most 2 KB) across runs / get it back (nil the first time). Whole numbers come back exactly (timestamps included)"}]},{"name":"log","section":"Feedback","help":[{"sig":"log(...)","does":"a line in the log: each value as tostring gives it, spaces between. The playground shows it in its Console, the board in its serial log, check.py in its result's log. At most 20 lines a frame and 200 characters a line"}]},{"name":"max","section":"Time, numbers, saving","help":[{"sig":"max(a, b)","does":"the usual"}]},{"name":"mid","section":"Time, numbers, saving","help":[{"sig":"mid(a, b, c)","does":"the usual"}]},{"name":"min","section":"Time, numbers, saving","help":[{"sig":"min(a, b)","does":"the usual"}]},{"name":"music","section":"Feedback","help":[{"sig":"music(n)","does":"your song from pattern n on / stop it (below)"},{"sig":"music(-1)","does":"your song from pattern n on / stop it (below)"}]},{"name":"now","section":"Time, numbers, saving","help":[{"sig":"now()","does":"the wall clock: seconds since 1970 (UTC), a whole number, or nil while the board doesn't know the date. It knows it from its RTC (kept through reboots and power-off while the battery lasts), which push.py sets; use it for anything that lives while the cart is closed (save(now()), then now() - saved on the next run)"}]},{"name":"obj","section":"3D","help":[{"sig":"obj(shape, color, [shine, unlit])","does":"an object: the shape placed in the scene, in color (0xRRGGBB); shine 0-255 adds a highlight; unlit shows the color as it is. Returns its number"}]},{"name":"orient","section":"3D","help":[{"sig":"orient(o, m1, ..., m9)","does":"turn it by a rotation matrix (rows), for carts that keep their own (physics)"},{"sig":"orient(o, {m1, ..., m9})","does":"turn it by a rotation matrix (rows), for carts that keep their own (physics)"}]},{"name":"paint","section":"3D","help":[{"sig":"paint(o, color, [shine, unlit])","does":"recolor it"}]},{"name":"pal","section":"Drawing","help":[{"sig":"pal(c0, c1)","does":"draw color c0 as c1 from now on / reset"},{"sig":"pal()","does":"draw color c0 as c1 from now on / reset"}]},{"name":"palette","section":"Drawing","help":[{"sig":"palette(i, 0xRRGGBB)","does":"change what color index i (0-15) looks like, for the whole picture (this frame included) / its color now / the starting 16 again. A cart still has 16 colors at a time; this picks which 16 (an exact red, softer pastels). pal remaps indices, palette the colors behind them"},{"sig":"palette(i)","does":"change what color index i (0-15) looks like, for the whole picture (this frame included) / its color now / the starting 16 again. A cart still has 16 colors at a time; this picks which 16 (an exact red, softer pastels). pal remaps indices, palette the colors behind them"},{"sig":"palette()","does":"change what color index i (0-15) looks like, for the whole picture (this frame included) / its color now / the starting 16 again. A cart still has 16 colors at a time; this picks which 16 (an exact red, softer pastels). pal remaps indices, palette the colors behind them"}]},{"name":"pget","section":"Drawing","help":[{"sig":"pget(x, y)","does":"set / read one pixel (pget returns the color index)"}]},{"name":"place","section":"3D","help":[{"sig":"place(o, x, y, z, [rx, ry, rz])","does":"move object o there, turned by rx, ry, rz degrees (about x, then y, then z)"}]},{"name":"print","section":"Drawing","help":[{"sig":"print(text, x, y, [c])","does":"text in the 4x6 font (3x5 glyphs, 4 px a character, so 32 a line); returns the x after the text. Letters show as capitals; \\n starts a new line at x; whole numbers print without .0"}]},{"name":"project","section":"3D","help":[{"sig":"project(x, y, z)","does":"where a point in the scene lands on the picture: x, y, depth (picture pixels, for labels), or nil behind the camera"}]},{"name":"pset","section":"Drawing","help":[{"sig":"pset(x, y, [c])","does":"set / read one pixel (pget returns the color index)"}]},{"name":"rect","section":"Drawing","help":[{"sig":"rect(x0, y0, x1, y1, [c])","does":"a box outline / filled box (corners inclusive)"}]},{"name":"rectfill","section":"Drawing","help":[{"sig":"rectfill(x0, y0, x1, y1, [c])","does":"a box outline / filled box (corners inclusive)"}]},{"name":"res","section":"3D","help":[{"sig":"res(mode)","does":"the scene drawn at \"full\" size (384 x 384, the default), \"half\" (192 x 192, each pixel shown 2x2: a quarter of the drawing, chunkier), or \"auto\" (the board draws at half while frames run over and goes back to full when there's room) / the size it's drawn at now"},{"sig":"res()","does":"the scene drawn at \"full\" size (384 x 384, the default), \"half\" (192 x 192, each pixel shown 2x2: a quarter of the drawing, chunkier), or \"auto\" (the board draws at half while frames run over and goes back to full when there's room) / the size it's drawn at now"}]},{"name":"rnd","section":"Time, numbers, saving","help":[{"sig":"rnd([x])","does":"a random number in [0, x) (x defaults to 1); rnd(t) with a table picks an element"}]},{"name":"save","section":"Time, numbers, saving","help":[{"sig":"save(t)","does":"keep a small table (numbers, strings, booleans, nested tables; at most 2 KB) across runs / get it back (nil the first time). Whole numbers come back exactly (timestamps included)"}]},{"name":"sfx","section":"Feedback","help":[{"sig":"sfx(n)","does":"a built-in sound: 0 blip, 1 coin, 2 hit, 3 jump, 4 explosion, 5 select, 6 error, 7 success; 8-63 your own (below); sfx(-1) stops yours"}]},{"name":"sgn","section":"Time, numbers, saving","help":[{"sig":"sgn(x)","does":"the usual"}]},{"name":"shadow","section":"3D","help":[{"sig":"shadow(o, [on])","does":"object o casts a shadow along the sun onto the ground (shadow(o, false) stops it). Costs its faces again"}]},{"name":"shape","section":"3D","help":[{"sig":"shape(\"box\", w, [h, d])","does":"a box (h, d default to w)"},{"sig":"shape(\"sphere\", r, [segs])","does":"a sphere (segs around, default 16)"},{"sig":"shape(\"torus\", R, r, [segs])","does":"a ring around the z axis (facing the camera at the start)"},{"sig":"shape(\"cylinder\", r, h, [segs])","does":"along y (the cone's point up)"},{"sig":"shape(\"cone\", r, h, [segs])","does":"along y (the cone's point up)"},{"sig":"shape(verts, faces, [smooth, uvs])","does":"your own: verts a flat list {x, y, z, x, y, z, ...}, faces a flat list of vertex numbers (from 1) in threes, each a triangle. Faces are turned outward from the shape's center, so wind them any way (meant for convex shapes: dice, gems). Flat-shaded unless smooth. uvs: texture coordinates {u, v, u, v, ...}, a pair a vertex, 0 to 1 across a texture (it repeats past them)"}]},{"name":"skin","section":"3D","help":[{"sig":"skin(o, tex, [ru, rv])","does":"object o in texture tex, repeated ru x rv times across its shape (default 1; rv defaults to ru), lit as its shine and unlit say (its color is set aside; skin(o) brings it back). A box's faces each show the whole texture, a sphere's, cylinder's and cone's wrap around their side (caps across), a torus's around and through its tube"}]},{"name":"sky","section":"3D","help":[{"sig":"sky(top, [bottom])","does":"the background, a gradient from top to bottom (0xRRGGBB each)"}]},{"name":"spr","section":"Drawing","help":[{"sig":"spr(n, x, y, [flip_x], [flip_y])","does":"sprite n from SPRITES (below), color 0 transparent"}]},{"name":"srand","section":"Time, numbers, saving","help":[{"sig":"srand(seed)","does":"seed rnd"}]},{"name":"stat","section":"Time, numbers, saving","help":[{"sig":"stat(n)","does":"stat(0) memory in KB, stat(1) the last frame's Lua time as a fraction of a frame, stat(7) frames a second"}]},{"name":"texture","section":"3D","help":[{"sig":"texture(w, h, texels)","does":"a texture: w x h texels (4, 8, 16, 32 or 64 each way), texels a string of color numbers as SPRITES are (hex digits row by row; spaces and line breaks ignored), in the palette as it is when made (palette() first for other colors). Returns its number (up to 16)"}]},{"name":"tilt","section":"Input","help":[{"sig":"tilt()","does":"ax, ay, each about -1..1: which way things would roll. ax > 0 when the right edge is lower, ay > 0 when the bottom edge is lower (screen directions: roll a ball by ax, ay and it goes downhill). Measured from how the board was held when the cart started (lying on a desk at a lean counts as level); level() takes the pose it's in now as level"}]},{"name":"time","section":"Time, numbers, saving","help":[{"sig":"time()","does":"seconds since the cart started (it keeps counting while the screen is dark)"}]},{"name":"touch","section":"Input","help":[{"sig":"touch()","does":"x, y in screen pixels while the cart window is touched, else nil / the same in canvas units (3x finer: for the full-color layer)"},{"sig":"touch(true)","does":"x, y in screen pixels while the cart window is touched, else nil / the same in canvas units (3x finer: for the full-color layer)"}]},{"name":"trifill","section":"Drawing","help":[{"sig":"trifill(x0, y0, x1, y1, x2, y2, [c])","does":"a filled triangle. Fractions count (to 1/16 px): triangles sharing an edge meet with no gap and no overlap, so meshes tile cleanly. For solid 3D: drop faces turned away, draw far to near"}]},{"name":"ui.changed","section":"The full-color layer (hd)","help":[{"sig":"ui.changed(key, ...)","does":"true the first time, and whenever the values differ from those given for key last time (one by one, with ==): ask each frame what a part of the screen shows, and repaint it when the answer is true: if ui.changed(\"score\", score, lives) then hd.repaint(0, 40, W, 60, draw_score) end. Ask about a part every frame it's on screen, also on a frame the whole screen is drawn anyway, so what's remembered stays what's shown"}]},{"name":"ui.scroller","section":"The full-color layer (hd)","help":[{"sig":"ui.scroller(x, y, w, h, draw)","does":"a box whose content (a list, a page) scrolls: draw(top, y0, y1) draws the content with its first row at screen row top, and only screen rows y0 to y1 are being painted (skip what's outside them). Returns s:"}]},{"name":"view","section":"3D","help":[{"sig":"view(x, y, z, [yaw, pitch, fov])","does":"the camera: at x, y, z, looking along +z (yaw 90 looks along +x, pitch 90 up); fov in degrees (60). Default view(0, 0, -420)"}]},{"name":"wake","section":"Feedback","help":[{"sig":"wake(secs)","does":"light the screen in secs seconds even if it went dark (a timer that's up) / cancel. The cart's frames resume then"},{"sig":"wake()","does":"light the screen in secs seconds even if it went dark (a timer that's up) / cancel. The cart's frames resume then"}]}]
|
|
Binary file
|
package/lib/gif.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// A small GIF encoder, for the playground's Record GIF and the command-line tool's `run --gif`.
|
|
2
|
+
// Frames are RGBA; colors are matched to a palette of the cart's 16 colors and a 6x6x6 cube (so a
|
|
3
|
+
// cart that draws only its 16 comes out exact), then LZW-compressed. The GIF loops forever.
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const Gif = (() => {
|
|
7
|
+
// The palette: the cart's colors ("#rrggbb" each, up to 16), then a 6x6x6 cube; 256 entries in all.
|
|
8
|
+
function palette(colors) {
|
|
9
|
+
const pal = [];
|
|
10
|
+
for (const c of colors.slice(0, 16)) {
|
|
11
|
+
const n = parseInt(c.replace("#", ""), 16);
|
|
12
|
+
pal.push([n >> 16, (n >> 8) & 255, n & 255]);
|
|
13
|
+
}
|
|
14
|
+
for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) pal.push([r * 51, g * 51, b * 51]);
|
|
15
|
+
while (pal.length < 256) pal.push([0, 0, 0]);
|
|
16
|
+
return pal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Each pixel's palette index: an exact color first, else the nearest (remembered by color).
|
|
20
|
+
function indexer(pal) {
|
|
21
|
+
const memo = new Map();
|
|
22
|
+
pal.forEach(([r, g, b], i) => { const k = (r << 16) | (g << 8) | b; if (!memo.has(k)) memo.set(k, i); });
|
|
23
|
+
return (r, g, b) => {
|
|
24
|
+
const k = (r << 16) | (g << 8) | b;
|
|
25
|
+
let i = memo.get(k);
|
|
26
|
+
if (i !== undefined) return i;
|
|
27
|
+
let best = 1e9;
|
|
28
|
+
for (let j = 0; j < pal.length; j++) {
|
|
29
|
+
const dr = pal[j][0] - r, dg = pal[j][1] - g, db = pal[j][2] - b, d = 2 * dr * dr + 4 * dg * dg + 3 * db * db;
|
|
30
|
+
if (d < best) { best = d; i = j; }
|
|
31
|
+
}
|
|
32
|
+
memo.set(k, i);
|
|
33
|
+
return i;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// GIF's LZW: 8-bit pixels, codes from 9 to 12 bits, a clear code when the table fills.
|
|
38
|
+
function lzw(pixels) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let acc = 0, bits = 0;
|
|
41
|
+
const put = (code, size) => {
|
|
42
|
+
acc |= code << bits;
|
|
43
|
+
bits += size;
|
|
44
|
+
while (bits >= 8) { out.push(acc & 255); acc >>>= 8; bits -= 8; }
|
|
45
|
+
};
|
|
46
|
+
const CLEAR = 256, END = 257;
|
|
47
|
+
let dict = new Map(), next = 258, size = 9;
|
|
48
|
+
put(CLEAR, size);
|
|
49
|
+
let prefix = pixels[0];
|
|
50
|
+
for (let i = 1; i < pixels.length; i++) {
|
|
51
|
+
const p = pixels[i], key = prefix * 256 + p, hit = dict.get(key);
|
|
52
|
+
if (hit !== undefined) { prefix = hit; continue; }
|
|
53
|
+
put(prefix, size);
|
|
54
|
+
if (next < 4096) {
|
|
55
|
+
dict.set(key, next++);
|
|
56
|
+
if (next > (1 << size) && size < 12) size++;
|
|
57
|
+
} else { // full: start the table again
|
|
58
|
+
put(CLEAR, size);
|
|
59
|
+
dict = new Map();
|
|
60
|
+
next = 258;
|
|
61
|
+
size = 9;
|
|
62
|
+
}
|
|
63
|
+
prefix = p;
|
|
64
|
+
}
|
|
65
|
+
put(prefix, size);
|
|
66
|
+
put(END, size);
|
|
67
|
+
if (bits) out.push(acc & 255);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// frames: [{rgba (Uint8ClampedArray or Uint8Array, w*h*4)}]; delay in 1/100 s. A Uint8Array.
|
|
72
|
+
function encode(frames, w, h, delay, colors) {
|
|
73
|
+
const pal = palette(colors), index = indexer(pal);
|
|
74
|
+
const bytes = [];
|
|
75
|
+
const word = (n) => bytes.push(n & 255, (n >> 8) & 255);
|
|
76
|
+
const text = (s) => { for (const c of s) bytes.push(c.charCodeAt(0)); };
|
|
77
|
+
text("GIF89a");
|
|
78
|
+
word(w); word(h);
|
|
79
|
+
bytes.push(0xf7, 0, 0); // a global table of 256 colors
|
|
80
|
+
for (const [r, g, b] of pal) bytes.push(r, g, b);
|
|
81
|
+
bytes.push(0x21, 0xff, 11); text("NETSCAPE2.0"); bytes.push(3, 1, 0, 0, 0); // loop forever
|
|
82
|
+
const px = new Uint8Array(w * h);
|
|
83
|
+
for (const f of frames) {
|
|
84
|
+
for (let i = 0, j = 0; i < px.length; i++, j += 4) px[i] = index(f.rgba[j], f.rgba[j + 1], f.rgba[j + 2]);
|
|
85
|
+
bytes.push(0x21, 0xf9, 4, 0); word(delay); bytes.push(0, 0); // its delay
|
|
86
|
+
bytes.push(0x2c); word(0); word(0); word(w); word(h); bytes.push(0);
|
|
87
|
+
bytes.push(8); // LZW minimum code size
|
|
88
|
+
const data = lzw(px);
|
|
89
|
+
for (let i = 0; i < data.length; i += 255) {
|
|
90
|
+
const n = Math.min(255, data.length - i);
|
|
91
|
+
bytes.push(n);
|
|
92
|
+
for (let k = 0; k < n; k++) bytes.push(data[i + k]);
|
|
93
|
+
}
|
|
94
|
+
bytes.push(0);
|
|
95
|
+
}
|
|
96
|
+
bytes.push(0x3b);
|
|
97
|
+
return Uint8Array.from(bytes);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { encode, palette };
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
if (typeof module !== "undefined") module.exports = Gif;
|
package/lib/tables.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The cart's data tables as the playground's editors read and write them: SOUNDS, MUSIC, SPRITES,
|
|
2
|
+
// each `NAME = { [n] = "text", -- name ... }` in the cart's code. Shared by the sound and sprite
|
|
3
|
+
// editors (and the command-line tool).
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const Tables = (() => {
|
|
7
|
+
// NAME = { [n] = "text", -- comment ... } in the code: the entries, or why the editor can't read it.
|
|
8
|
+
function readTable(src, name) {
|
|
9
|
+
const m = new RegExp(`^${name}\\s*=\\s*\\{([^]*?)^\\}`, "m").exec(src);
|
|
10
|
+
if (!m) {
|
|
11
|
+
return new RegExp(`(^|[^\\w.])${name}\\s*(=|\\[)`, "m").test(src)
|
|
12
|
+
? { problem: `${name} is set up in the code in a way the editor can't read. Change it there, or delete it to start over here.` }
|
|
13
|
+
: { map: new Map() };
|
|
14
|
+
}
|
|
15
|
+
const map = new Map();
|
|
16
|
+
for (const raw of m[1].split("\n")) {
|
|
17
|
+
const line = raw.trim();
|
|
18
|
+
if (!line || line.startsWith("--")) continue;
|
|
19
|
+
const e = /^\[(\d+)\]\s*=\s*"([0-9a-fA-F \-]*)",?\s*(?:--\s*(.*))?$/.exec(line);
|
|
20
|
+
if (!e) return { problem: `The editor can't read this line of ${name}: ${line}` };
|
|
21
|
+
map.set(Number(e[1]), { text: e[2], name: (e[3] || "").trim() });
|
|
22
|
+
}
|
|
23
|
+
return { map };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeTable(src, name, map, intro) {
|
|
27
|
+
const keys = [...map.keys()].sort((a, b) => a - b);
|
|
28
|
+
const body = keys.map((k) => ` [${k}] = "${map.get(k).text}",${map.get(k).name ? " -- " + map.get(k).name : ""}`).join("\n");
|
|
29
|
+
const block = keys.length ? `${name} = {\n${body}\n}` : "";
|
|
30
|
+
const said = intro.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // the intro as a pattern
|
|
31
|
+
const re = new RegExp(`(^-- ${said}\\n)?^${name}\\s*=\\s*\\{[^]*?^\\}\\n?`, "m");
|
|
32
|
+
if (re.test(src)) return src.replace(re, block ? `-- ${intro}\n${block}\n` : "");
|
|
33
|
+
if (!block) return src;
|
|
34
|
+
// After the header (the comment lines at the top) and a SOUNDS table if there is one.
|
|
35
|
+
const lines = src.split("\n");
|
|
36
|
+
let at = 0;
|
|
37
|
+
while (at < lines.length && lines[at].startsWith("--")) at++;
|
|
38
|
+
const sounds = /^SOUNDS\s*=\s*\{[^]*?^\}\n?/m.exec(src);
|
|
39
|
+
if (name === "MUSIC" && sounds) {
|
|
40
|
+
const end = sounds.index + sounds[0].length;
|
|
41
|
+
return src.slice(0, end) + `-- ${intro}\n${block}\n` + src.slice(end);
|
|
42
|
+
}
|
|
43
|
+
lines.splice(at, 0, "", `-- ${intro}`, block);
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The header asks for API `min` at least (sounds and music need 3, spaced sprites 4), or a board
|
|
48
|
+
// with older firmware would run the cart as one that doesn't need it.
|
|
49
|
+
function withApi(src, min) {
|
|
50
|
+
const lines = src.split("\n");
|
|
51
|
+
for (let i = 0; i < Math.min(lines.length, 12); i++) {
|
|
52
|
+
const m = /^--\s*api\s*:\s*(\d+)\s*$/.exec(lines[i]);
|
|
53
|
+
if (m) {
|
|
54
|
+
if (Number(m[1]) < min) lines[i] = "-- api: " + min;
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let at = 0;
|
|
59
|
+
while (at < lines.length && lines[at].startsWith("--") && at < 12) at++;
|
|
60
|
+
lines.splice(at, 0, "-- api: " + min);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { readTable, writeTable, withApi };
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
if (typeof module !== "undefined") module.exports = Tables;
|