EXPERIMENTAL / EVERYTHING BELOW IS A REAL RECORDING
Apps your AI can write, running live.
Osvauld is an experiment in a different kind of software platform: small Lua apps over synchronized documents, rendered natively, written and changed by people and agents while they run. This is what it can do so far.
01 / APPS
An app is a Lua file.
The runtime turns a description of the interface into layout and vector pixels, every frame. The board you see is a few hundred lines of Lua. The cards live in a shared document, so it survives restarts and syncs between people with no extra code.
-- KANBAN---- The entry point: it wires the parts together and owns the view. `require` resolves against-- this app's own source doc, so these three names are `theme.lua`, `model.lua` and-- `ui/widgets.lua` sitting next to this file in the uploaded folder.---- The split runs along one line: **anything that touches pointer state stays here.** `drag`,-- `placement` and `col_modal` are per-viewer UI state that handlers write and the view reads,-- so every piece that reads them (`drop_slot`, `card_of`, `column_of`) lives in this file where-- the read and the write are visible together. `ui/widgets.lua` holds the pieces that are pure-- functions of their arguments, and `model.lua` holds the doc.local C = require("theme")local W = require("ui/widgets")local model = require("model")local board, update = model.board, model.update-- One shared table, not a copy: `S.drag` is written by handlers inside model.lua and read here.-- A `local drag` on either side could not do this — a local is file-scoped, so the assignment-- would be invisible across the boundary.local S = model.state-- VIEW PIECES-- Which gap the indicator belongs in: 1 = above the first card, #list+1 = below the last.-- A column-background drop ("into") appends, which is the trailing gap — same answer, one path.local function drop_slot(c, list) if not (S.drag and S.placement) then return nil end if S.placement.kind == "into" then return S.placement.col == c.id and #list + 1 or nil end if S.placement.kind ~= "card" or S.drag.id == S.placement.id then return nil end for i = 1, #list do if list[i].id == S.placement.id then return S.placement.before and i or i + 1 end endend-- `ghost` builds the floating copy: same look, but no id and no handlers, because a duplicate-- id would register a second drag target and collide in the state store.local function card_of(c, ghost) local dragging = S.drag ~= nil and S.drag.kind == "card" and S.drag.id == c.id local t = ui.row({ w_full = true, gap = 8, px = 10, py = 9, radius = 8, fill = ghost and C.card_hi or C.card, stroke = { 1, ghost and C.accent or C.line_soft }, align_center = true, ui.col({ grow = true, ui.text({ c.text, color = C.text, font_size = 13 }) }), }) if ghost then return t end t.id = c.id t.hover_fill = C.card_hi t.hover_stroke = { 1, C.line } t.opacity = dragging and 0.3 or 1.0 t.on_drag = function(e) local phase, dx, dy, scale, ox, oy = e.phase, e.dx, e.dy, e.scale, e.origin_x, e.origin_y update({ kind = "drag", what = "card", id = c.id, phase = phase, x = ox, y = oy, scale = scale }) end t.on_drop = function(e) local phase, x, y = e.phase, e.x, e.y update({ kind = "drop", id = c.id, phase = phase, x = x, y = y }) end t[#t + 1] = W.icon_button("del-card:" .. c.id, "x", function() update({ kind = "delete", id = c.id }) end, C.danger) return tendlocal function composer(c) local s = ui.state("draft:" .. c.id, { text = "", open = false }) local close = function() s.open = false end local send = function() if s.text ~= "" then close() update({ kind = "add", col = c.id }) end end local anchor = ui.button({ id = "add-card:" .. c.id, w_full = true, h = 32, radius = 6, center = true, fill = C.accent, hover_fill = C.accent_hi, press_scale = 0.97, ui.text({ "Add card", no_wrap = true, color = "#ffffff", font_size = 13 }), on_click = function() s.open = true end, }) if not s.open then return ui.col({ px = 10, py = 10, anchor }) end local panel = ui.col({ w = 280, gap = 8, pad = 10, radius = 8, fill = C.panel, stroke = { 1, C.line }, ui.input({ value = s.text, id = "draft:" .. c.id, autofocus = true, w_full = true, h = 34, px = 10, radius = 6, fill = C.sunken, stroke = { 1, C.line }, color = C.text, font_size = 13, on_input = function(v) s.text = v end, on_enter = send, }), ui.button({ id = "submit-card:" .. c.id, h = 32, radius = 6, center = true, fill = C.accent, hover_fill = C.accent_hi, press_scale = 0.97, ui.text({ "Create card", no_wrap = true, color = "#ffffff", font_size = 13 }), on_click = send, }), }) return ui.col({ px = 10, py = 10, ui.overlay({ id = "composer:" .. c.id, side = "top", align = "start", on_dismiss = close, anchor, panel }), })end-- What this column is *this frame*. Mid-drag the width comes from pointer state and the doc is not-- touched; on release the model writes it once. Same shape as the card and column drags above —-- nothing goes to the CRDT until the gesture finishes, so a drag is one op and not sixty.local function width_of(c) if S.resize and S.resize.id == c.id then return S.resize.w end return c.w or C.col_wendlocal function column_of(c, list) local body = {} local slot = drop_slot(c, list) for i = 1, #list do body[#body + 1] = W.guide("g:" .. c.id .. ":" .. i, slot == i) body[#body + 1] = card_of(list[i]) end body[#body + 1] = W.guide("g:" .. c.id .. ":tail", slot == #list + 1) if #list == 0 then body[#body + 1] = W.empty_slot(slot ~= nil) end local lifting = S.drag ~= nil and S.drag.kind == "col" and S.drag.id == c.id return ui.col({ id = c.id, w = width_of(c), no_shrink = true, -- The clamp in `actions.resize` is what the drag obeys; these are what the *layout* obeys, -- and they are not the same guard. A width can arrive from a peer whose theme differs, or -- from a doc edited by hand, and neither went past the drag. min_w = C.col_w_min, max_w = C.col_w_max, radius = 12, fill = C.panel, stroke = { 1, C.line_soft }, opacity = lifting and 0.35 or 1.0, on_drop = function(e) local phase, x, y = e.phase, e.x, e.y update({ kind = "drop_col", col = c.id, phase = phase, x = x }) end, -- header doubles as the column's drag handle ui.row({ id = "colhead:" .. c.id, gap = 8, px = 12, py = 11, align_center = true, hover_fill = C.line_soft, on_drag = function(e) local phase, dx, dy, scale, ox, oy = e.phase, e.dx, e.dy, e.scale, e.origin_x, e.origin_y update({ kind = "drag", what = "col", id = c.id, phase = phase, x = ox, y = oy, scale = scale }) end, ui.text({ c.name, no_wrap = true, color = C.text, font_size = 14 }), W.badge(#list), ui.col({ grow = true }), W.icon_button("del-col:" .. c.id, "x", function() update({ kind = "delete_col", id = c.id }) end, C.danger), }), ui.col({ h = 1, w_full = true, fill = C.line_soft }), -- cards ui.col({ id = "cards:" .. c.id, scroll_y = true, grow = true, px = 10, py = 7, body, }), composer(c), })endlocal function modal() local m = ui.state("col_modal", { name = "" }) local create = function() update({ kind = "add_col" }) end local panel = ui.col({ id = "col-modal", w = 360, gap = 14, pad = 20, radius = 14, fill = C.panel, stroke = { 1, C.line }, fade_in = 120, on_click = function() end, ui.text({ "New column", no_wrap = true, color = C.text, font_size = 16 }), ui.input({ value = m.name, id = "col_name", autofocus = true, w_full = true, h = 36, px = 10, radius = 6, fill = C.sunken, stroke = { 1, C.line }, color = C.text, font_size = 13, on_input = function(v) m.name = v end, on_enter = create, on_esc = function() update({ kind = "close_col" }) end, }), ui.row({ gap = 8, ui.col({ grow = true }), ui.button({ id = "cancel-col", h = 34, px = 14, radius = 6, center = true, hover_fill = C.line_soft, press_scale = 0.97, ui.text({ "Cancel", no_wrap = true, color = C.muted, font_size = 13 }), on_click = function() update({ kind = "close_col" }) end, }), ui.button({ id = "create-col", h = 34, px = 16, radius = 6, center = true, fill = C.accent, hover_fill = C.accent_hi, press_scale = 0.97, ui.text({ "Create", no_wrap = true, color = "#ffffff", font_size = 13 }), on_click = create, }), }), }) return ui.col({ absolute = true, top = 0, left = 0, right = 0, bottom = 0, id = "col-modal-backdrop", center = true, fill = "rgba(1,4,9,0.72)", on_click = function() update({ kind = "close_col" }) end, panel, })end-- ROOTreturn function() -- Bind the mirror once per frame. `view()` repatches it before calling this, so these two -- are current for the whole frame — and reading them once keeps every count and index below -- consistent with each other. local columns, cards = board.columns, board.cards local grouped = model.by_column() local col_list = {} local flying = nil local col_slot = nil if S.drag and S.placement and S.placement.kind == "col" and S.drag.id ~= S.placement.id then for i = 1, #columns do if columns[i].id == S.placement.id then col_slot = S.placement.before and i or i + 1 break end end end for i = 1, #columns do local c = columns[i] col_list[#col_list + 1] = W.guide_v("gv:" .. i, col_slot == i) col_list[#col_list + 1] = column_of(c, grouped[c.id]) -- The grip is a sibling of the column, not a child of it: it has to sit in the gutter -- *between* two columns, and a child clipped to its parent's rounded panel would be a -- three-point target hanging off the edge of a corner. col_list[#col_list + 1] = W.grip("grip:" .. c.id, S.resize ~= nil and S.resize.id == c.id, function(phase, x, y, dx) update({ kind = "resize", id = c.id, phase = phase, x = x, dx = dx }) end) if S.drag and S.drag.kind == "col" and S.drag.id == c.id then flying = ui.col({ w = 300, radius = 12, pad = 12, fill = C.panel, stroke = { 1, C.accent }, ui.text({ c.name, no_wrap = true, color = C.text, font_size = 14 }), }) end end col_list[#col_list + 1] = W.guide_v("gv:tail", col_slot == #columns + 1) if S.drag and S.drag.kind == "card" then for i = 1, #cards do if cards[i].id == S.drag.id then flying = card_of(cards[i], true) break end end end -- The ghost sits at the root, outside every scroll clip, in viewport coords: `x`/`y` are -- `pos - grab`, i.e. where the dragged element's top-left belongs right now. local ghost = flying and ui.col({ absolute = true, left = S.drag.x, top = S.drag.y, w = 300, scale = S.drag.scale or 1, opacity = 0.9, flying, }) return ui.col({ full = true, fill = C.bg, -- top bar ui.row({ gap = 10, px = 24, py = 14, align_center = true, ui.text({ "Board", no_wrap = true, color = C.text, font_size = 18 }), W.badge(#cards), ui.col({ grow = true }), ui.button({ id = "open-col", h = 32, px = 14, radius = 8, center = true, fill = C.accent, hover_fill = C.accent_hi, press_scale = 0.97, ui.text({ "+ Column", no_wrap = true, color = "#ffffff", font_size = 13 }), on_click = function() update({ kind = "open_col" }) end, }), }), ui.col({ h = 1, w_full = true, fill = C.line_soft }), -- board ui.col({ id = "board_zoom", zoomable = true, grow = true, w_full = true, fill = C.bg, ui.row({ id = "board", h_full = true, stretch = true, px = 13, py = 20, col_list, }), }), ghost or false, S.col_modal and modal(), })end
-- MODEL---- Columns and cards live in the CRDT, not in Lua tables: `board.columns` and `board.cards` are-- the mirror, refreshed from Loro before every frame, and every mutation goes back through the-- doc. That is what makes the board survive a restart, and what will make a second peer's drag-- show up here.---- One rule to keep in mind while reading the actions below: **the mirror is a frame behind your-- own write.** `:set` and friends go straight to Loro, and `board.cards` is only repatched in-- view(). So read what you need first, then write; never read back what you just wrote.---- This module runs once per app instance -- `require` caches it -- so the seeding below happens-- once no matter how many files ask for the model.local C = require("theme")local board = doc:open("board")-- Module scope runs on every open, so seeding has to be guarded or reopening the app would-- wipe the board it just loaded.---- `w` is seeded so the field is there to read, but every reader still says `c.w or C.col_w`:-- boards created before columns had a width are the common case, not the exotic one.if not board.columns then board:set({ "columns" }, doc.list({ doc.map({ id = "c-todo", name = "Todo", w = C.col_w }), doc.map({ id = "c-doing", name = "In Progress", w = C.col_w }), doc.map({ id = "c-done", name = "Done", w = C.col_w }), }))endif not board.cards then board:set({ "cards" }, doc.list({ doc.map({ id = "k1", col = "c-todo", text = "Wire the MCP bridge" }), doc.map({ id = "k2", col = "c-todo", text = "Font weight in TextSpec" }), doc.map({ id = "k3", col = "c-doing", text = "Error boundaries in walk" }), doc.map({ id = "k4", col = "c-done", text = "ui.state + sweep" }), }))end-- Per-viewer pointer state -- what *this* pointer is doing right now. Deliberately not in the-- doc: broadcasting a half-finished drag would show everyone else your mouse.---- It has to be a table field rather than a local now that the view lives in another file. A-- `local drag` is file-scoped, and reassigning it here could never be seen from main.lua; a-- field on an exported table can. That is the whole cost of the split, and it is why there is-- exactly one of these tables rather than one per module.local S = { drag = nil, placement = nil, col_modal = false, resize = nil }local actions = {}local function index_of(list, id) for i = 1, #list do if list[i].id == id then return i end endendlocal function find(list, id) local i = index_of(list, id) return i and list[i]end-- Loro's move removes then reinserts, so `to` is the index the element ends up at *after* its-- own removal. Dragging downwards therefore lands one slot short unless the target is nudged.local function target_index(list, from, anchor_id, before) local at = index_of(list, anchor_id) if not at then return #list end local to = before and at or at + 1 if from < to then to = to - 1 end return toendlocal function commit_card() local cards = board.cards local from = index_of(cards, S.drag.id) if not from then return end -- Dropped on a column rather than on a card: leave the ordering alone and just restamp the -- column. The card list is flat, so `col` is the only thing that has to change. if S.placement.kind == "into" then board:set({ "cards", S.drag.id, "col" }, S.placement.col) return end if S.drag.id == S.placement.id then return end local at = index_of(cards, S.placement.id) local anchor = at and cards[at] -- Move first, then restamp: :move retargets the element's `pos` register and :set writes a -- field inside it, and those are independent registers. Deleting and reinserting would mint -- a new element, which is how a concurrent edit turns into a duplicated card. board:move({ "cards", S.drag.id }, target_index(cards, from, S.placement.id, S.placement.before)) if anchor and anchor.col ~= cards[from].col then board:set({ "cards", S.drag.id, "col" }, anchor.col) endendlocal function commit_col() if S.drag.id == S.placement.id then return end local columns = board.columns local from = index_of(columns, S.drag.id) if not from then return end board:move( { "columns", S.drag.id }, target_index(columns, from, S.placement.id, S.placement.before) )endlocal function commit() if not (S.drag and S.placement) then return end if S.drag.kind == "col" then commit_col() else commit_card() endendfunction actions.add(msg) local s = ui.state("draft:" .. msg.col) if s.text and s.text ~= "" then board:insert({ "cards" }, doc.map({ id = uuid(), col = msg.col, text = s.text })) s.text = "" endendfunction actions.delete(msg) board:delete({ "cards", msg.id })endfunction actions.drag(msg) if msg.phase == "start" then S.drag = { kind = msg.what, id = msg.id, x = msg.x, y = msg.y, scale = msg.scale } elseif msg.phase == "move" then if S.drag then S.drag.x, S.drag.y, S.drag.scale = msg.x, msg.y, msg.scale end S.placement = nil elseif msg.phase == "end" then S.drag, S.placement = nil, nil endendfunction actions.drop(msg) if not (S.drag and S.drag.kind == "card") then return end if msg.phase == "over" then S.placement = { kind = "card", id = msg.id, before = msg.y < 0.5 } else commit() endend-- One target, two meanings: a card lands *inside* the column, a column lands *beside* it.function actions.drop_col(msg) if not S.drag then return end if msg.phase == "over" then if S.drag.kind == "col" then S.placement = { kind = "col", id = msg.col, before = msg.x < 0.5 } else S.placement = { kind = "into", col = msg.col } end else commit() endend-- Column width goes in the doc, and the two things it is *not* are worth saying out loud.---- Not source. The obvious home for a size is the `ui.col` that draws the column — that is what-- docs/design/code-as-tree.md §11 is about — but there is no such node: `column_of` is one-- constructor drawing every column, so main.lua has nothing in it that means "the Todo column".-- That is nid-channel.md §5's instance-identity limit, met the first time it mattered.---- Not per-viewer state either, which is the other tempting answer. A wide column is a claim about-- the work in it, so it belongs to the board the way the column's name does — the next person to-- open this should see the board somebody arranged, not their own default.---- The clamp runs on every move rather than once at the end, or the pointer walks past the limit-- and the column sits still until it comes all the way back.function actions.resize(msg) if msg.phase == "start" then local c = find(board.columns, msg.id) local w = c and c.w or C.col_w S.resize = { id = msg.id, from = w, w = w } return end if not (S.resize and S.resize.id == msg.id) then return end if msg.phase == "move" then local w = S.resize.from + msg.dx S.resize.w = math.max(C.col_w_min, math.min(C.col_w_max, w)) else -- Written unconditionally, including when the press never moved. A guard here looked -- obviously right and turned out to be theatre: Loro's own `insert` skips an op whose -- value already matches, and `app_host`'s `:set` adds no check of its own, so an -- unchanged width never reaches the history either way. Removing the guard fails no test, -- which is how it was found. `a_resize_that_never_moved_writes_nothing` pins the behaviour -- we are leaning on, so this comment stops being true loudly rather than quietly. board:set({ "columns", msg.id, "w" }, S.resize.w) S.resize = nil endendfunction actions.open_col() S.col_modal = trueendfunction actions.close_col() S.col_modal = false -- Clear the draft too, or the next open shows the abandoned text. ui.state("col_modal", { name = "" }).name = ""endfunction actions.add_col() local m = ui.state("col_modal") if not m.name or m.name == "" then return end board:insert({ "columns" }, doc.map({ id = uuid(), name = m.name, w = C.col_w })) m.name = "" S.col_modal = falseendfunction actions.delete_col(msg) -- Collect first, delete second. Each :delete lands in the doc immediately but the mirror is -- only repatched in view(), so iterating `board.cards` while deleting from it would be -- walking a stale list. local doomed = {} for _, c in ipairs(board.cards) do if c.col == msg.id then doomed[#doomed + 1] = c.id end end for _, id in ipairs(doomed) do board:delete({ "cards", id }) end board:delete({ "columns", msg.id })endlocal function update(msg) local f = actions[msg.kind] if not f then print("unknown action:", msg.kind) return end f(msg)end-- Cards keyed by the column they sit in. A derivation over the mirror, so it is recomputed every-- frame rather than cached: the tables it reads are repatched under it.local function by_column() local out = {} for _, c in ipairs(board.columns) do out[c.id] = {} end for _, card in ipairs(board.cards) do local list = out[card.col] if list then list[#list + 1] = card end end return outendreturn { board = board, state = S, update = update, by_column = by_column,}
-- Small pieces that draw and nothing else.---- The boundary here is not "small" but *pure*: every function below is a function of its-- arguments and the palette. None of them touch the board, the pointer state or `update`, which-- is what lets them sit in another file without anything being passed down to reach them.local C = require("theme")local M = {}-- Guides are rendered on *every* gap, always, so the space is permanently reserved and nothing-- shifts when a drop target appears — only the line's alpha changes. `fade` is keyed by id:-- that's where the runtime parks the tween, so retargeting mid-flight reverses smoothly.function M.guide(id, on) return ui.col({ py = 3, w_full = true, ui.col({ id = id, h = 2, radius = 1, fill = C.accent, fade = { on and 1 or 0, 140 } }), })end-- Vertical twin, for column reordering. No height: the board row is `stretch`, so it fills.function M.guide_v(id, on) return ui.col({ px = 7, ui.col({ id = id, w = 2, grow = true, radius = 1, fill = C.accent, fade = { on and 1 or 0, 140 }, }), })end-- The resize grip: a hairline in the gutter to the right of a column. Same shape as `guide_v` on-- purpose — it stands in the same kind of gap and must not push the columns around by being there.---- Faint but never invisible. `guide_v` can fade to nothing because a drop target announces itself-- when it becomes relevant; a grip has to be found before it is useful, and a control you can only-- hit by guessing where it is is not a control. Hover brightens it, dragging holds it bright.---- Pure like everything else here: it is handed the handler rather than reaching for `update`.function M.grip(id, active, on_drag) return ui.col({ px = 4, ui.col({ id = id, w = 3, grow = true, radius = 2, fill = active and C.accent or C.line, hover_fill = C.accent, on_drag = on_drag, }), })end-- Both of these sit on the *main* axis of a row next to text that can be any length — the badge-- beside a column name, the button beside a card's body. A flex item shrinks to its min-content-- width before its neighbour gives way, so without `no_shrink` a long card title squashes the-- delete button until its glyph wraps, and the control the user is reaching for is the one that-- disappears. Grows nothing, costs nothing, and only matters when space runs out.function M.badge(n) return ui.row({ px = 7, py = 1, radius = 10, fill = C.line_soft, center = true, no_shrink = true, ui.text({ tostring(n), no_wrap = true, color = C.muted, font_size = 11 }), })endfunction M.icon_button(id, glyph, on_press, hover) return ui.button({ id = id, w = 22, h = 22, radius = 6, center = true, no_shrink = true, hover_fill = hover or C.line, press_scale = 0.92, ui.text({ glyph, no_wrap = true, color = C.muted, font_size = 13 }), on_click = on_press, })endfunction M.empty_slot(on) return ui.col({ h = 64, center = true, radius = 8, stroke = { 1, on and C.accent or C.line_soft }, ui.text({ "drop a card here", no_wrap = true, color = on and C.accent or C.muted, font_size = 12 }), })endreturn M
-- PALETTE---- Pure data, required by everything that draws. First file to split out precisely because it-- depends on nothing: if `require` were broken, this is the one that would still work.---- `model.lua` requires it too, which looks like a layering mistake and is not: a resize drag has-- to clamp *while it is happening*, or the pointer runs past the limit and the column does not-- start moving again until it comes back. The clamp is where the write is, and the numbers are-- here with the rest of the board's geometry.return { bg = "#0d1117", panel = "#161b22", card = "#1c2128", card_hi = "#22282f", sunken = "#0d1117", line = "#30363d", line_soft = "#21262d", text = "#e6edf3", muted = "#8b949e", accent = "#2f81f7", accent_hi = "#4493f8", danger = "#f85149", -- Column geometry. `col_w` is what a column starts at; the bounds are what a drag may write. -- The floor is the one that matters — a column dragged to nothing has no grip left to drag -- back by, and there is no undo for it. col_w = 300, col_w_min = 180, col_w_max = 620,}
02 / WORKSPACES
The workspace itself is configurable.
The home screen is a Lua file in your encrypted store, not fixed chrome. Change the theme in the built-in editor, or let an agent edit the layout over the bridge. A broken edit is refused and the workspace keeps running; Reset restores the original.
local C = require("theme")local cursor = require("cursor")local editor = require("editor")local function card(item) local accent = C.kinds[item.kind] or C.accent return ui.col({ id = "launcher:item:" .. item.id, w = 200, min_w = 180, h = 144, pad = 18, gap = 12, radius = 14, fill = C.panel, stroke = { 1, C.line }, hover_fill = C.hover, hover_stroke = { 1, accent }, tint = 140, press_scale = 0.97, cursor = "arrow", on_click = function() shell.open_item(item.id) end, ui.row({ gap = 8, align_center = true, ui.col({ w = 8, h = 8, radius = 4, fill = accent }), ui.text({ string.upper(item.kind), color = accent, font_size = 11, no_wrap = true }), }), ui.text({ item.name, color = C.text, font_size = 19 }), ui.col({ grow = true }), ui.text({ "Open app →", color = C.muted, font_size = 12, no_wrap = true }), })endreturn function() local snapshot = shell.snapshot() local cards = {} for _, item in ipairs(snapshot.items) do cards[#cards + 1] = card(item) end return ui.col({ id = "launcher:root", grow = true, stretch = true, fill = C.bg, radius = 18, pad = 20, gap = 16, on_hover = cursor.track, system_cursor = false, ui.row({ id = "launcher:workspace-header", wrap = true, gap = 12, align_center = true, ui.button({ id = "launcher:back", h = 36, px = 10, center = true, no_shrink = true, radius = 6, hover_fill = C.hover, tint = 120, on_click = function() shell.back() end, ui.text({ "← Back", color = C.muted, font_size = 13, no_wrap = true }), }), ui.text({ snapshot.workspace.name, grow = true, min_w = 60, color = C.text, font_size = 15 }), ui.button({ id = "launcher:add-item", h = 36, px = 14, center = true, no_shrink = true, radius = 6, fill = C.accent, press_scale = 0.97, on_click = function() shell.import_item() end, ui.text({ "+ Add item", color = C.bg, font_size = 13, no_wrap = true }), }), }), ui.row({ wrap = true, gap = 12, align_center = true, ui.text({ "Your apps", color = C.text, font_size = 24, no_wrap = true }), ui.col({ grow = true }), editor.button(), }), ui.text({ "A workspace made yours", color = C.muted, font_size = 13 }), #cards == 0 and ui.col({ grow = true, center = true, ui.text({ "no apps in this workspace yet", color = C.muted, font_size = 13 }), }) or ui.col({ id = "launcher:items", grow = true, scroll_y = true, ui.row({ wrap = true, gap = 16, cards }), }), editor.view(), cursor.view(), })end
# theme.lua, typed in the in-shell Customize editor, then Save & apply- bg = "#10141d",+ bg = "#1a1410",- panel = "#1a2130",+ panel = "#2a1f18",- accent = "#83adff",+ accent = "#ff9f6b",# main.lua, sent by an agent over the bridge (edit_launcher_file)- "Your apps"+ #cards .. " things in " .. snapshot.workspace.name- w = 200, min_w = 180, h = 144, pad = 18, gap = 12,+ w = 430, min_w = 180, h = 110, pad = 18, gap = 6,# a broken edit is refused and the workspace keeps running;# Reset launcher restores the original.
03 / SYNC
Two people, one document.
Persistence and collaboration come with the document, not with the app. This chat is a model file that opens a document and calls insert. Each person has their own identity and node, and the edits merge on reconnect.
-- CHAT---- Channels on the left, messages or search results on the right. The search box is the point-- of this app: it is the reference for `index.lua` + `search.query` (docs/design/search.md).local C = require("theme")local M = require("model")local S = M.Slocal function channel_button(ch) local on = ch.id == S.channel return ui.button({ id = "ch:" .. ch.id, px = 10, py = 6, radius = 6, fill = on and C.card_hi or C.panel, hover_fill = C.card, ui.text({ "# " .. ch.name, color = on and C.text or C.muted, font_size = 13, no_wrap = true }), on_click = function() S.channel = ch.id S.editing = nil end, })endlocal function small_button(id, label, color, on_click) return ui.button({ id = id, px = 6, py = 2, radius = 4, hover_fill = C.card_hi, no_shrink = true, ui.text({ label, color = color, font_size = 11, no_wrap = true }), on_click = on_click, })endlocal function message_row(m) return ui.row({ id = "msg:" .. m.id, gap = 8, px = 10, py = 6, radius = 6, fill = S.editing == m.id and C.card_hi or C.card, align_center = true, ui.text({ m.author, color = C.accent_hi, font_size = 12, no_wrap = true, no_shrink = true }), ui.col({ grow = true, ui.text({ m.text, id = "text:" .. m.id, color = C.text, font_size = 13 }) }), small_button("edit:" .. m.id, "edit", C.muted, function() S.editing = m.id end), small_button("del:" .. m.id, "delete", C.danger, function() M.delete(m.id) end), })endlocal function hit_row(h) local cid = M.channel_of(h.doc) return ui.button({ id = "hit:" .. h.id, gap = 8, px = 10, py = 6, radius = 6, fill = C.card, hover_fill = C.card_hi, ui.text({ "#" .. (cid or h.doc), color = C.hit, font_size = 12, no_wrap = true, no_shrink = true }), ui.text({ h.snippet or "", id = "snippet:" .. h.id, color = C.text, font_size = 13 }), on_click = function() if cid then S.channel = cid end end, })endlocal function body(query) local rows = {} if query ~= "" then local hits = search.query(query, { limit = 50 }) for _, h in ipairs(hits) do rows[#rows + 1] = hit_row(h) end if #hits == 0 then rows[1] = ui.text({ "no results", id = "no-results", color = C.muted, font_size = 13 }) end return ui.col({ id = "results", grow = true, scroll_y = true, gap = 6, pad = 12, rows }) end local msgs = M.channel(S.channel).messages or {} for _, m in ipairs(msgs) do rows[#rows + 1] = message_row(m) end return ui.col({ id = "messages", grow = true, scroll_y = true, gap = 6, pad = 12, rows })endreturn function() local find = ui.state("search", { q = "" }) local draft = ui.state("composer", { text = "" }) local send = function() M.send(draft.text) draft.text = "" end local sidebar = {} for _, ch in ipairs(M.chat.channels or {}) do sidebar[#sidebar + 1] = channel_button(ch) end return ui.row({ id = "chat", grow = true, stretch = true, fill = C.bg, ui.col({ w = 160, no_shrink = true, gap = 4, pad = 10, fill = C.panel, sidebar }), ui.col({ grow = true, stretch = true, ui.row({ pad = 10, gap = 8, ui.input({ id = "search", value = find.q, grow = true, on_input = function(v) find.q = v end, on_esc = function() find.q = "" end, }), }), body(find.q), ui.row({ pad = 10, gap = 8, ui.input({ id = "composer", value = draft.text, grow = true, on_input = function(v) draft.text = v end, on_enter = send, }), ui.button({ id = "send", px = 14, radius = 6, center = true, fill = C.accent, hover_fill = C.accent_hi, ui.text({ S.editing and "save" or "send", color = "#ffffff", font_size = 13, no_wrap = true }), on_click = send, }), }), }), })end
-- MODEL---- `chat` holds the channel list; each channel's messages live in their own doc,-- `channel/<id>`, so one busy channel never makes another's doc large. Search spans them all-- because `index.lua` matches `channel/*`.local M = { S = { channel = "general", editing = nil } }local chat = doc:open("chat")if not chat.channels then chat:set({ "channels" }, doc.list({ doc.map({ id = "general", name = "general" }), doc.map({ id = "design", name = "design" }), }))endM.chat = chatlocal docs = {}function M.channel(cid) local d = docs[cid] if not d then d = doc:open("channel/" .. cid) docs[cid] = d end return dend-- Seeded so a fresh app (and the app-shipped tests' empty tab) has something to find.local general = M.channel("general")if not general.messages then general:set({ "messages" }, doc.list({ doc.map({ id = "seed-1", author = "anu", text = "the deploy is green", sent_at = 1 }), doc.map({ id = "seed-2", author = "abe", text = "lunch at noon?", sent_at = 2 }), }))endlocal function message(text) return doc.map({ id = uuid(), author = "me", text = text, sent_at = now() })end-- Read the mirror before writing: `d.messages` is a frame behind, and only decides whether the-- list exists yet.function M.send(text) if text == "" then return end local S, d = M.S, M.channel(M.S.channel) if S.editing then d:set({ "messages", S.editing, "text" }, text) S.editing = nil elseif d.messages then d:insert({ "messages" }, message(text)) else d:set({ "messages" }, doc.list({ message(text) })) endendfunction M.delete(id) M.channel(M.S.channel):delete({ "messages", id }) if M.S.editing == id then M.S.editing = nil endend-- "channel/design" → "design"function M.channel_of(doc_name) return doc_name:match("^channel/(.+)$")endreturn M
-- What search sees: one record per message, across every channel doc. The host runs this in-- its own read-only VM (docs/design/search.md §4) — nothing here can write or draw.---- The author is indexed as the id it is, not a display name: a rename then re-indexes nothing.return { doc = "channel/*", each = { "messages" }, key = function(m) return m.id end, fields = function(_id, m, _channel) return { body = m.text, facet = { author = m.author }, time = m.sent_at, } end, rank = "recent",}
Agents drive the shell over a local bridge: read a source file, send exact edits, and the running app reloads. The agent changes code, not pixels.
-- Launch checklist. Tasks live in the "todo" document, so every reload keeps them.local t = doc:open("todo")if not t.tasks then t:set({ "tasks" }, doc.list({ doc.map({ id = "t1", text = "Write the launch post", done = false, prio = "high" }), doc.map({ id = "t2", text = "Record the demo reel", done = true, prio = "high" }), doc.map({ id = "t3", text = "Fix the pricing page copy", done = false, prio = "med" }), doc.map({ id = "t4", text = "Reply to early testers", done = false, prio = "med" }), doc.map({ id = "t5", text = "Rotate the node keys", done = true, prio = "high" }), doc.map({ id = "t6", text = "Tidy the changelog", done = false, prio = "low" }), }))endlocal C = { bg = "#0d1117", card = "#1c2128", card_hi = "#22282f", sunken = "#0d1117", line = "#30363d", text = "#e6edf3", muted = "#8b949e", accent = "#2f81f7", accent_hi = "#4493f8",}local function row(task) return ui.button({ id = "task:" .. task.id, w_full = true, h = 38, px = 14, gap = 12, radius = 10, align_center = true, fill = C.card, hover_fill = C.card_hi, press_scale = 0.99, ui.col({ w = 18, h = 18, radius = 6, fill = task.done and C.accent or C.sunken, stroke = { 1, task.done and C.accent or C.line } }), ui.text({ task.text, color = task.done and C.muted or C.text, font_size = 15, no_wrap = true }), on_click = function() t:set({ "tasks", task.id, "done" }, not task.done) end, })endreturn function() local rows = {} local done = 0 for _, task in ipairs(t.tasks) do if task.done then done = done + 1 end table.insert(rows, row(task)) end return ui.col({ id = "todo", full = true, align_center = true, fill = C.bg, py = 22, ui.col({ w = 640, gap = 14, ui.row({ align_center = true, ui.col({ gap = 4, ui.text({ "Launch checklist", color = C.text, font_size = 26, no_wrap = true }), ui.text({ done .. " of " .. #t.tasks .. " done", id = "summary", color = C.muted, font_size = 14, no_wrap = true }), }), }), ui.col({ gap = 6, rows }), }), })end
# priority chips@@-local function row(task)+local PRIO = {+ high = { "#f85149", "#3d1d20" },+ med = { "#d29922", "#3a2f14" },+ low = { "#3fb950", "#16301d" },+}++local function chip(p)+ local c = PRIO[p] or PRIO.low+ return ui.row({+ px = 10, h = 22, radius = 11, fill = c[2], center = true,+ ui.text({ p, color = c[1], font_size = 11, no_wrap = true }),+ })+end++local function row(task)@@-font_size = 15, no_wrap = true }),- on_click = function()+font_size = 15, no_wrap = true }),+ ui.col({ grow = true }),+ chip(task.prio),+ on_click = function()# filter chips with live counts@@-return function()- local rows = {}- local done = 0- for _, task in ipairs(t.tasks) do- if task.done then done = done + 1 end- table.insert(rows, row(task))- end+local function filters(ux, total, done)+ local chips = {}+ for _, f in ipairs({ { "all", total }, { "open", total - done }, { "done", done } }) do+ local on = ux.filter == f[1]+ table.insert(chips, ui.button({+ id = "filter:" .. f[1],+ h = 30, px = 14, gap = 6, radius = 15, align_center = true,+ fill = on and C.accent or C.card, hover_fill = on and C.accent_hi or C.card_hi,+ ui.text({ f[1], color = on and "#ffffff" or C.muted, font_size = 13, no_wrap = true }),+ ui.text({ tostring(f[2]), color = on and "#cfe3ff" or C.muted, font_size = 13, no_wrap = true }),+ on_click = function() ux.filter = f[1] end,+ }))+ end+ return ui.row({ gap = 8, chips })+end++return function()+ local ux = ui.state("todo-ui", { filter = "all" })+ local rows = {}+ local done = 0+ for _, task in ipairs(t.tasks) do+ if task.done then done = done + 1 end+ if ux.filter == "all" or (ux.filter == "done") == task.done then+ table.insert(rows, row(task))+ end+ end@@- ui.col({ gap = 6, rows }),+ filters(ux, #t.tasks, done),+ ui.col({ gap = 6, rows }),# progress ring drawn with gfx@@-return function()- local ux =+local rings = {}+local function ring(steps) -- 0..48 segments of a 48-segment circle+ if not rings[steps] then+ local R, cx = 24, 28+ local function at(i)+ local a = -math.pi / 2 + i / 48 * 2 * math.pi+ return cx + R * math.cos(a), cx + R * math.sin(a)+ end+ local track, arc = {}, {}+ for i = 0, 48 do+ local x, y = at(i)+ table.insert(track, { i == 0 and "move" or "line", x, y })+ end+ for i = 0, math.max(1, steps) do+ local x, y = at(i)+ table.insert(arc, { i == 0 and "move" or "line", x, y })+ end+ rings[steps] = gfx.frame({+ width = 56, height = 56,+ gfx.stroke({ path = gfx.path(track), brush = gfx.solid(C.line), width = 7 }),+ gfx.stroke({ path = gfx.path(arc), brush = gfx.solid(C.accent), width = 7, cap = "round" }),+ })+ end+ return rings[steps]+end++return function()+ local ux =@@- return ui.col({- id = "todo",+ local pct = #t.tasks > 0 and math.floor(done * 100 / #t.tasks + 0.5) or 0+ return ui.col({+ id = "todo",@@-no_wrap = true }),- }),- }),+no_wrap = true }),+ }),+ ui.col({ grow = true }),+ ui.text({ pct .. "%", id = "pct", color = C.text, font_size = 20, no_wrap = true }),+ ui.frame({ visual = ring(math.floor(pct * 48 / 100 + 0.5)) }),+ }),# stats.lua: bars by priority, required by main.lua@@-local t = doc:open("todo")+local stats = require("stats")+local t = doc:open("todo")@@- ui.col({ gap = 6, rows }),+ ui.col({ gap = 6, rows }),+ stats.view(t.tasks),# add-task composer (Enter submits)@@-return function()- local ux =+local function composer()+ local d = ui.state("todo-draft", { text = "" })+ local function add()+ local text = d.text:match("^%s*(.-)%s*$")+ if text == "" then return end+ t:insert({ "tasks" }, doc.map({ id = uuid(), text = text, done = false, prio = "med" }))+ d.text = ""+ end+ return ui.row({+ gap = 8,+ ui.input({+ id = "draft", value = d.text, grow = true, h = 38, px = 12, radius = 8,+ fill = C.card, stroke = { 1, C.line }, color = C.text, font_size = 15,+ on_input = function(v) d.text = v end, on_enter = add,+ }),+ ui.button({+ id = "add", h = 38, px = 18, radius = 8, center = true,+ fill = C.accent, hover_fill = C.accent_hi, press_scale = 0.97,+ ui.text({ "Add", color = "#ffffff", font_size = 14, no_wrap = true }),+ on_click = add,+ }),+ })+end++return function()+ local ux =@@- filters(ux, #t.tasks, done),+ filters(ux, #t.tasks, done),+ composer(),# restyle the rows (contains a typo)@@-w_full = true, h = 38, px = 14, gap = 12, radius = 10,+w_full = true, h = 38, px = 14, gap = 12, raduis = 10,# fix the typo the host reported@@-gap = 12, raduis = 10,+gap = 12, radius = 10,
-- stats.lua: tasks by priority, drawn straight from the document.local C = { card = "#161b22", line = "#30363d", text = "#e6edf3", muted = "#8b949e" }local COLOR = { high = "#f85149", med = "#d29922", low = "#3fb950" }local W, H = 150, 12local M = {}local bars = {}local function bar(done, total, peak, color) local key = done .. ":" .. total .. ":" .. peak .. color if not bars[key] then local function rect(w) return gfx.path({ { "move", 0, 0 }, { "line", w, 0 }, { "line", w, H }, { "line", 0, H }, { "close" } }) end local items = { gfx.fill({ path = rect(W), brush = gfx.solid(C.line) }) } if total > 0 then table.insert(items, gfx.fill({ path = rect(W * total / peak), brush = gfx.solid("#30363d") })) table.insert(items, gfx.fill({ path = rect(math.max(1, W * done / peak)), brush = gfx.solid(color) })) end items.width, items.height = W, H bars[key] = gfx.frame(items) end return bars[key]endfunction M.view(tasks) local count = { high = { 0, 0 }, med = { 0, 0 }, low = { 0, 0 } } -- { done, total } for _, task in ipairs(tasks) do local c = count[task.prio] if c then c[2] = c[2] + 1 if task.done then c[1] = c[1] + 1 end end end local peak = 1 for _, c in pairs(count) do peak = math.max(peak, c[2]) end local cells = {} for _, p in ipairs({ "high", "med", "low" }) do local done, total = count[p][1], count[p][2] table.insert(cells, ui.col({ gap = 6, ui.row({ gap = 6, align_center = true, ui.text({ p, color = COLOR[p], font_size = 12, no_wrap = true }), ui.col({ grow = true }), ui.text({ done .. "/" .. total, color = C.muted, font_size = 12, no_wrap = true }), }), ui.frame({ visual = bar(done, total, peak, COLOR[p]) }), })) end return ui.col({ gap = 10, pad = 14, radius = 10, fill = C.card, stroke = { 1, C.line }, ui.text({ "By priority", color = C.text, font_size = 13, no_wrap = true }), ui.row({ gap = 28, cells }), })endreturn M
05 / FILES
Assets are edited in place.
Workspace files open in apps that were themselves written in Lua, inside the same shell.
local C = require("theme")local M = require("model")local Stage = require("stage")local Inspector = require("inspector")local Handles = require("handles")local selected = nil -- the selected part's idlocal built = { key = nil, frame = nil } -- the canvas visual; rebuilt on a new selection or editlocal play = nil -- { name, drawing, clip } while a clip plays on the canvas; nil is edit modelocal seen_at = 1 -- the viewport's zoom, so selection marks keep their screen size-- While a point dot is held: its shape `i`, the points moving (`{ j, k, from }`, the held one-- first), and `d`, how far they have moved, in drawing units. While a part is held: `ids`, the-- set of parts moving with it, and `d`.local drag = nil-- The canvas draws the asset scaled up by building a scaled copy, rather than scaling the-- element: hits keep reporting part ids, and canvas units are drawing units times the zoom.local function scaled_path(path, z) local out = {} for i, cmd in ipairs(path) do local c = { cmd[1] } for j = 2, #cmd do c[j] = cmd[j] * z end out[i] = c end return outend-- A colour stays as it is; a gradient's points and radii are in drawing space, so they scale-- too. Angles and stops don't.local function scaled_fill(fill, z) if type(fill) ~= "table" then return fill end local function pt(p) return p and { p[1] * z, p[2] * z } end local kind, g = next(fill) local out = { stops = g.stops, extend = g.extend } if kind == "linear" then out.from, out.to = pt(g.from), pt(g.to) elseif kind == "radial" then out.center, out.focus = pt(g.center), pt(g.focus) out.radius, out.focus_radius = g.radius * z, g.focus_radius and g.focus_radius * z else -- sweep out.center, out.from_angle, out.to_angle = pt(g.center), g.from_angle, g.to_angle end return { [kind] = out }end-- A lone length may be a var, `"@line"`: it stays a name here, and the vars scale instead.local function len(v, z) return type(v) == "number" and v * z or vendlocal function scaled_shape(s, z) if s.shadow then local sh, r = s.shadow, s.shadow.rect local rect = { r[1] * z, r[2] * z, r[3] * z, r[4] * z } local radius = len(sh.radius or 0, z) return { shadow = { rect = rect, radius = radius, blur = len(sh.blur, z), color = sh.color } } end local stroke = s.stroke and { len(s.stroke[1], z), s.stroke[2] } return { path = scaled_path(s.path, z), fill = scaled_fill(s.fill, z), stroke = stroke }end-- The asset at canvas scale, with the `outline` part's outlines drawn again in the accent colour-- and its path points on top as dots.local function canvas_drawing(parts, z, outline) local out, held = {}, nil for i, p in ipairs(parts) do local shapes = {} for _, s in ipairs(p.shapes or {}) do table.insert(shapes, scaled_shape(s, z)) end if p.id == outline then for _, s in ipairs(p.shapes or {}) do if s.path then -- a shadow has no outline to trace table.insert(shapes, { path = scaled_path(s.path, z), stroke = { 3 / seen_at, C.accent } }) end end end out[i] = { id = p.id, parent = p.parent, pivot = { p.pivot[1] * z, p.pivot[2] * z }, shapes = #shapes > 0 and shapes or nil, } if p.id == outline then held = p end end for _, h in ipairs(held and Handles.parts(held, z, seen_at) or {}) do table.insert(out, h) end local size = M.asset.size local vars = {} -- a number var is always a length for name, v in pairs(M.asset.vars or {}) do vars[name] = len(v, z) end return gfx.drawing({ size = { size[1] * z, size[2] * z }, vars = vars, parts = out })endlocal DIVS = { 1, 2, 4, 10, 20 } -- snap steps as divisions of a unit: 1/20 stays exact, 0.05 not-- `v` on the finest step still a few pixels apart on screen: whole units at a glance, twentieths-- up close.local function snap(v) local div = 1 for _, d in ipairs(DIVS) do if C.zoom * seen_at / d >= 4 then div = d end end return math.floor(v * div + 0.5) / divend-- `parts` with the held points where the drag has them, copying only what that changes.local function dragged(parts) local out = table.clone(parts) for n, p in ipairs(parts) do if drag.ids then out[n] = drag.ids[p.id] and M.moved(p, drag.d) or p elseif p.id == selected then local part, shape = table.clone(p), table.clone(p.shapes[drag.i]) shape.path = table.clone(shape.path) for _, m in ipairs(drag.moves) do -- an end and its control can share a command shape.path[m.j] = table.clone(shape.path[m.j]) end for _, m in ipairs(drag.moves) do shape.path[m.j][2 * m.k] = m.from[1] + drag.d[1] shape.path[m.j][2 * m.k + 1] = m.from[2] + drag.d[2] end part.shapes = table.clone(p.shapes) part.shapes[drag.i] = shape out[n] = part end end return outendlocal function key(...) return { "views", M.view, "parts", ... }end-- Writes the held parts where the drag left them, and the sockets riding them. Paths go whole.local function write_moved(parts) local d = drag.d for _, p in ipairs(parts) do if drag.ids[p.id] then local m = M.moved(p, d) M.asset:set(key(p.id, "pivot"), M.tag(m.pivot)) for i, s in ipairs(m.shapes) do if s.path then M.asset:set(key(p.id, "shapes", i, "path"), M.tag(s.path)) end if type(s.fill) == "table" then M.asset:set(key(p.id, "shapes", i, "fill"), M.tag(s.fill)) end if s.shadow then M.asset:set(key(p.id, "shapes", i, "shadow", "rect"), M.tag(s.shadow.rect)) end end end end for name, s in pairs(M.asset.sockets or {}) do if drag.ids[s.part] then M.asset:set({ "sockets", name, "at" }, M.tag({ s.at[1] + d[1], s.at[2] + d[2] })) end endendlocal function visual(parts) local live = drag and drag.d[1] .. "," .. drag.d[2] or "" local key = table.concat({ M.name, selected or "", Inspector.rev, seen_at, live }, ":") if built.key ~= key then built.key = key built.frame = canvas_drawing(drag and dragged(parts) or parts, C.zoom, selected):pose({}) end return built.frameend-- A clip at canvas scale: moves are in drawing units, so x and y scale; turns and scales don't.local function scaled_clip(clip, z) local tracks = {} for part, props in pairs(clip.tracks) do tracks[part] = {} for prop, keys in pairs(props) do local k = (prop == "x" or prop == "y") and z or 1 local out = {} for i, key in ipairs(keys) do out[i] = { key[1], key[2] * k, key[3] } end tracks[part][prop] = out end end return { length = clip.length, loop = clip.loop, tracks = tracks }end-- Every press makes a new clip handle, which is what restarts a clip that is already playing.local function start(name) play = { name = name, drawing = canvas_drawing(M.parts(), C.zoom, nil), clip = gfx.clip(scaled_clip(M.asset.clips[name], C.zoom)), }endlocal function chip(id, label, on, on_click) return ui.button({ id = id, px = 10, py = 6, radius = 6, fill = on and C.row_selected or C.panel, hover_fill = on and C.row_selected or C.row_hover, on_click = on_click, ui.text({ label, color = on and C.accent or C.text, font_size = 13, no_wrap = true }), })endlocal function asset_tab(name) return chip("asset:" .. name, name, name == M.name, function() selected, play = nil, nil M.open(name) end)end-- "edit" shows the rest pose and selects parts; a clip name plays that clip in a world.local function clip_bar() local names = {} for name in pairs(M.asset.clips or {}) do table.insert(names, name) end table.sort(names) local chips = { chip("mode:edit", "edit", play == nil, function() play = nil end) } for _, name in ipairs(names) do local on = play ~= nil and play.name == name table.insert(chips, chip("clip:" .. name, name, on, function() start(name) end)) end return ui.row({ gap = 6, wrap = true, chips })end-- Edit mode is a frame, so a click names the part; play mode is a world, whose clicks would-- only name the whole asset — hence two modes rather than one.local function canvas(parts) if play then local size = M.asset.size return ui.world({ id = "stage", width = size[1] * C.zoom, height = size[2] * C.zoom, { id = M.name, pos = { 0, 0 }, drawing = play.drawing, clip = play.clip }, }) end return ui.frame({ id = "canvas", visual = visual(parts), -- A click on no part clears the selection; one on a point dot keeps it. on_click = function(e) if not (e.shape and e.shape:sub(1, 3) == "pt:") then selected = e.shape end end, -- A held dot or part follows the pointer on the canvas alone; letting go writes it. A -- part takes the parts under it along. A drag from empty canvas pans. drag_on_shapes = true, on_drag = function(e) if e.phase == "start" then if e.shape and e.shape:sub(1, 3) ~= "pt:" then selected = e.shape drag = { ids = M.family(parts, e.shape), d = { 0, 0 }, x0 = e.x, y0 = e.y } end local i, j, k = (e.shape or ""):match("^pt:(%d+):(%d+):(%d+)$") for _, p in ipairs(i and parts or {}) do if p.id == selected then i, j, k = tonumber(i), tonumber(j), tonumber(k) local path = p.shapes[i].path local moves = { { j = j, k = k } } for _, r in ipairs(Handles.riders(path, j, k)) do table.insert(moves, r) end for _, m in ipairs(moves) do m.from = { path[m.j][2 * m.k], path[m.j][2 * m.k + 1] } end drag = { i = i, moves = moves, d = { 0, 0 }, x0 = e.x, y0 = e.y } end end elseif drag then -- A held point snaps, and the rest move by what it moved; a part moves by snapped steps. local dx, dy = (e.x - drag.x0) / C.zoom, (e.y - drag.y0) / C.zoom if drag.ids then drag.d = { snap(dx), snap(dy) } else local from = drag.moves[1].from drag.d = { snap(from[1] + dx) - from[1], snap(from[2] + dy) - from[2] } end if e.phase == "end" then if drag.ids then write_moved(parts) end for _, m in ipairs(drag.moves or {}) do local cmd = { "views", M.view, "parts", selected, "shapes", drag.i, "path", m.j } local px, py = table.clone(cmd), table.clone(cmd) table.insert(px, 2 * m.k) table.insert(py, 2 * m.k + 1) M.asset:set(px, m.from[1] + drag.d[1]) M.asset:set(py, m.from[2] + drag.d[2]) end drag = nil Inspector.rev += 1 -- the canvas rebuilds from the mirror, which has it by then end end end, })endlocal function part_row(p) local on = p.id == selected return ui.button({ id = "part:" .. p.id, px = 12, py = 8, gap = 8, align_center = true, radius = 6, fill = on and C.row_selected or C.panel, hover_fill = on and C.row_selected or C.row_hover, on_click = function() selected = p.id end, ui.text({ p.id, color = on and C.accent or C.text, font_size = 13, no_wrap = true }), p.parent and ui.text({ "in " .. p.parent, color = C.muted, font_size = 11, no_wrap = true }) or false, })endlocal function entity_row(r) return ui.row({ px = 12, py = 8, gap = 8, align_center = true, ui.text({ r.id, color = C.text, font_size = 13, no_wrap = true }), ui.text({ r.note, color = C.muted, font_size = 11, no_wrap = true }), })endreturn function() local stage = M.scenes[M.name] local parts = not stage and M.parts() or nil local tabs, rows = {}, {} for _, name in ipairs(M.names) do table.insert(tabs, asset_tab(name)) end for name in pairs(M.scenes) do table.insert(tabs, asset_tab(name)) end local picked = nil for _, p in ipairs(parts or {}) do table.insert(rows, part_row(p)) picked = p.id == selected and p or picked end for _, r in ipairs(stage and Stage.rows() or {}) do table.insert(rows, entity_row(r)) end local title = M.name .. " · " .. (stage and "stage" or M.view) local listing = stage and "entities, back to front" or "parts, back to front" local body = stage and Stage.view() or parts and canvas(parts) or ui.text({ "opening the asset…", color = C.muted, no_wrap = true }) return ui.row({ full = true, fill = C.bg, ui.col({ grow = true, fill = C.canvas, pad = 12, gap = 12, parts and clip_bar() or stage and Stage.bar(chip) or false, -- Ctrl+wheel zooms around the pointer and a drag on empty space pans; clicks still -- report canvas units, zoom undone. ui.col({ id = "viewport", grow = true, w_full = true, center = true, zoomable = true, zoom_max = 20, -- on top of the canvas's own 2.5×: 50× the asset, for fingers and eyelashes on_zoom = function(e) seen_at = e.scale end, body, }), }), ui.col({ w = C.panel_w, no_shrink = true, fill = C.panel, pad = 12, gap = 4, ui.row({ gap = 6, mb = 8, wrap = true, tabs }), ui.text({ title, color = C.text, font_size = 15, no_wrap = true }), ui.text({ listing, color = C.muted, font_size = 11, no_wrap = true }), rows, parts and Inspector.vars() or false, Inspector.view(picked), }), })end
-- The open asset: a workspace doc holding a drawing (its views and clips) or a stage (a scene of-- placed assets). An asset is seeded from its bundled seed; after that the asset is the truth.local M = {}M.view = "front"-- Each seed gives the asset's roots; a drawing's `parts` go under `views`.M.seeds = { hero = function() local hero = require("seed/hero") return { size = hero.size, vars = hero.vars, parts = hero.parts, clips = require("seed/clips"), sockets = { head_top = { kind = "hat", part = "head", at = { 80, 34 }, width = 60 } }, -- Top-down, only the feet meet a wall: a small circle where they stand. world = { collider = { circle = 16, at = { 80, 222 } } }, } end, toggle = function() return require("seed/toggle") end, chest = function() return require("seed/chest") end, hat = function() return require("seed/hat") end, crate = function() return require("seed/crate") end, room = function() return require("seed/room") end,}M.names = { "hero", "toggle", "chest", "hat", "crate" }M.scenes = { room = true }-- Doc writes are tagged at every depth: a table with only positional entries is a list,-- anything else a map.local function tag(v) if type(v) ~= "table" then return v end local out, n = {}, 0 for k, x in pairs(v) do out[k] = tag(x) n += 1 end return (n > 0 and n == #v) and doc.list(out) or doc.map(out)endM.tag = tag-- Part `id` and every part under it, as a set: a moved arm takes its hand along.function M.family(parts, id) local ids, grew = { [id] = true }, true while grew do -- a child may be listed before its parent grew = false for _, p in ipairs(parts) do if p.parent and ids[p.parent] and not ids[p.id] then ids[p.id], grew = true, true end end end return idsendlocal function shift(p, d) return p and { p[1] + d[1], p[2] + d[2] }end-- A copy of `part` moved by `d`: its pivot and every point it draws in drawing space — path-- points, gradient points, a shadow's corner. Lengths, angles and stops stay.function M.moved(part, d) local out = table.clone(part) out.pivot = shift(part.pivot, d) out.shapes = {} for i, s in ipairs(part.shapes or {}) do local c = table.clone(s) if s.shadow then local r = s.shadow.rect c.shadow = table.clone(s.shadow) c.shadow.rect = { r[1] + d[1], r[2] + d[2], r[3], r[4] } end if s.path then c.path = {} for j, cmd in ipairs(s.path) do local m = { cmd[1] } for k = 2, #cmd do -- x, y, x, y… m[k] = cmd[k] + d[1 + k % 2] end c.path[j] = m end end if type(s.fill) == "table" then local kind, g = next(s.fill) local f = table.clone(g) f.from, f.to = shift(g.from, d), shift(g.to, d) f.center, f.focus = shift(g.center, d), shift(g.focus, d) c.fill = { [kind] = f } end out.shapes[i] = c end return outendlocal loaded = {}-- An asset by name, writing any root its seed has and it lacks — so a seed that grows (the-- hero's socket) reaches an asset made before it. What it writes shows from the next frame.function M.load(name) if loaded[name] then return loaded[name] end local a = asset:open(name) for k, v in pairs(M.seeds[name] and M.seeds[name]() or {}) do local root, value = k, v if k == "parts" then root, value = "views", { [M.view] = { parts = v } } end if a[root] == nil then a:set({ root }, tag(value)) end end loaded[name] = a return aendfunction M.open(name) M.name = name M.asset = M.load(name)end-- The current view's parts of a drawing asset, or nil before its seed is visible.function M.parts(a) local views = (a or M.asset).views return views and views[M.view] and views[M.view].partsendM.open("hero")return M
-- Stage edit: every entity of a scene drawn as a named instance in one frame, so a drag reports-- the entity it grabbed. A drag moves it live; one write lands on release.local C = require("theme")local M = require("model")local S = {}local rests = {} -- asset name → { frame = its rest pose, asset = its mirror }, built oncelocal drag = nil -- { id, x0, y0, from, at, released } while an entity is movedlocal floor = nil -- the stage's backdrop, built on first uselocal play = nil -- { name, clips = { entity id → clip handle } } while a stage plays; nil is editlocal function rest(name) if not rests[name] then local a = M.load(name) local parts = M.parts(a) if not parts then return nil -- seeded this frame; it shows from the next end local drawing = gfx.drawing({ size = a.size, vars = a.vars, parts = parts }) rests[name] = { frame = drawing:pose({}), drawing = drawing, asset = a } end return rests[name]endlocal variants = {} -- entity id → its look when it sets `vars`, rebuilt when they change-- An entity's look: its asset's rest, or — when the entity sets `vars` — a variant of it.local function look(e) local r = rest(e.asset) if not r or not e.vars then return r end local said = {} for name, v in pairs(e.vars) do table.insert(said, name .. "=" .. tostring(v)) end table.sort(said) local key = e.asset .. ":" .. table.concat(said, ",") if not variants[e.id] or variants[e.id].key ~= key then local a = r.asset local module = { size = a.size, vars = a.vars, parts = M.parts(a) } local drawing = gfx.drawing(module, { vars = e.vars }) variants[e.id] = { key = key, drawing = drawing, frame = drawing:pose({}), asset = a } end return variants[e.id]endlocal SNAP = 24 -- how near a plug must come to a matching socket to equip therelocal last = {} -- last view's placements: where a grab starts, where sockets are while dragging-- Where each entity's box goes: a drag in flight, its own position, or — mounted — its host's-- socket less its own plug. A released drag is dropped here: this view's mirror already has it.local function placements(entities) if drag and drag.released then drag = nil end local at, by_id = {}, {} for _, e in ipairs(entities) do by_id[e.id] = e end local function place(e) if at[e.id] ~= nil then return at[e.id] or nil end at[e.id] = false -- a mount cycle ends here instead of recursing forever if drag and drag.id == e.id then at[e.id] = drag.snap and drag.snap.at or drag.at elseif e.pos then at[e.id] = { e.pos[1], e.pos[2] } elseif e.mount and by_id[e.mount.to] then local host, mine = rest(by_id[e.mount.to].asset), rest(e.asset) local socket = host and host.asset.sockets and host.asset.sockets[e.mount.socket] local plug = mine and mine.asset.plug local h = place(by_id[e.mount.to]) if socket and plug and h then at[e.id] = { h[1] + socket.at[1] - plug.at[1], h[2] + socket.at[2] - plug.at[2] } end end return at[e.id] or nil end for _, e in ipairs(entities) do place(e) end return atendlocal function backdrop(w, h) if not floor then local rect = gfx.path({ { "move", 0, 0 }, { "line", w, 0 }, { "line", w, h }, { "line", 0, h }, { "close" }, }) floor = gfx.fill({ path = rect, brush = gfx.solid(C.floor) }) end return floorend-- Every socket the dragged entity's plug fits, as stage points — the interface is the kind.-- An entity mounted on the dragged one can't be its host: that would be a cycle.local function sockets_for(ent, entities) local plug = rest(ent.asset) and rest(ent.asset).asset.plug local found = {} for _, host in ipairs(entities) do local r, h = rest(host.asset), last[host.id] local riding = host.mount and host.mount.to == ent.id if plug and r and h and host.id ~= ent.id and not riding then for name, socket in pairs(r.asset.sockets or {}) do if socket.kind == plug.kind then local x, y = h[1] + socket.at[1], h[2] + socket.at[2] table.insert(found, { to = host.id, socket = name, x = x, y = y }) end end end end return found, plugendlocal function on_drag(e, entities) if e.phase == "start" then for _, ent in ipairs(entities) do if ent.id == e.shape and last[ent.id] then local from = { last[ent.id][1], last[ent.id][2] } local sockets, plug = sockets_for(ent, entities) drag = { id = ent.id, ent = ent, x0 = e.x, y0 = e.y, from = from, at = from } drag.sockets, drag.plug = sockets, plug end end elseif drag and not drag.released then drag.at = { drag.from[1] + e.x - drag.x0, drag.from[2] + e.y - drag.y0 } drag.snap = nil for _, s in ipairs(drag.sockets) do local px, py = drag.at[1] + drag.plug.at[1], drag.at[2] + drag.plug.at[2] if (px - s.x) ^ 2 + (py - s.y) ^ 2 <= SNAP * SNAP then local at = { s.x - drag.plug.at[1], s.y - drag.plug.at[2] } drag.snap = { to = s.to, socket = s.socket, at = at } end end if e.phase == "end" then local function field(name) return { "scene", "entities", drag.id, name } end local ent, snap = drag.ent, drag.snap if snap then -- equip: it rides the socket and has no place of its own M.asset:set(field("mount"), doc.map({ to = snap.to, socket = snap.socket })) if ent.pos then M.asset:delete(field("pos")) end else M.asset:set(field("pos"), doc.list({ drag.at[1], drag.at[2] })) if ent.mount then -- pulled off its socket M.asset:delete(field("mount")) end end drag.released = true end endendlocal function ring(x, y, r) local k = 0.5523 * r return gfx.path({ { "move", x + r, y }, { "cubic", x + r, y + k, x + k, y + r, x, y + r }, { "cubic", x - k, y + r, x - r, y + k, x - r, y }, { "cubic", x - r, y - k, x - k, y - r, x, y - r }, { "cubic", x + k, y - r, x + r, y - k, x + r, y }, { "close" }, })end-- The world fields an entity plays with: its asset's `world` defaults, each replaced whole by-- the entity's own, where `false` takes one away. Keys are the room's: only an entity has them.local FIELDS = { "collider", "sensor", "loose", "group", "blocks" }function S.fields(e, a) local out = { controller = e.controller or nil } for _, k in ipairs(FIELDS) do local v = e[k] if v == nil then v = a.world and a.world[k] end out[k] = v or nil end return outend-- Each collider as a line and each sensor dashed, where the entity stands. A mounted entity-- has neither while it rides.local function body_marks(entities) local marks = {} for _, e in ipairs(entities) do local r, p = rest(e.asset), last[e.id] local f = r and p and not e.mount and S.fields(e, r.asset) or {} for _, k in ipairs({ "collider", "sensor" }) do local s = f[k] if s then local at = s.at or { 0, 0 } local x, y = p[1] + at[1], p[2] + at[2] local path = s.rect and gfx.path({ { "move", x, y }, { "line", x + s.rect[1], y }, { "line", x + s.rect[1], y + s.rect[2] }, { "line", x, y + s.rect[2] }, { "close" }, }) or ring(x, y, s.circle) local brush = gfx.solid(k == "sensor" and C.muted or C.accent) table.insert(marks, gfx.stroke({ path = path, brush = brush, width = 1.5, dashes = k == "sensor" and { 6, 4 } or nil, })) end end end return marksend-- A ring at every socket the dragged plug fits, filled where it would snap.local function socket_marks() local marks = {} for _, s in ipairs(drag and drag.sockets or {}) do local circle = ring(s.x, s.y, 9) local on = drag.snap and drag.snap.to == s.to and drag.snap.socket == s.socket local brush = gfx.solid(on and C.accent or C.muted) table.insert(marks, on and gfx.fill({ path = circle, brush = brush }) or gfx.stroke({ path = circle, brush = brush, width = 2 })) end return marksend-- Clip handles are made once per press of play, so each press starts every clip from its top.local function start(entities) play = { name = M.name, clips = {} } for _, e in ipairs(entities) do local r = rest(e.asset) local clip = e.clip and r and r.asset.clips and r.asset.clips[e.clip] if clip then play.clips[e.id] = gfx.clip(clip) end endend-- One world entity. A mounted one rides its socket's part with `turn`, pivoting on its own plug,-- so a hat nods with the head; the world carries only by an entity that is not itself carried.local function world_entity(e, by_id, at) local r = look(e) if not r or not at[e.id] then return false end local out = { id = e.id, pos = at[e.id], drawing = r.drawing, clip = play.clips[e.id] } local host = e.mount and by_id[e.mount.to] local h = host and not host.mount and rest(host.asset) local socket = h and h.asset.sockets and h.asset.sockets[e.mount.socket] local plug = r.asset.plug if socket and plug then out.attach = { to = host.id, part = socket.part, at = socket.at, pivot = plug.at, turn = true } end for k, v in pairs(S.fields(e, r.asset)) do out[k] = v end return outend-- Stage play: the scene as a world, at drawing scale — the viewport's zoom does the rest.local function world(scene) local at, by_id = placements(scene.entities), {} for _, e in ipairs(scene.entities) do by_id[e.id] = e end local w, h = scene.size[1], scene.size[2] -- Entities are the world's own positional entries: a nested list would read as one entity. local spec = { id = "stage-play", width = w, height = h, fill = C.floor } for i, e in ipairs(scene.entities) do spec[i] = world_entity(e, by_id, at) end return ui.world(spec)end-- "edit" and "play" for a stage, made with the editor's own chip.function S.bar(chip) local playing = play ~= nil and play.name == M.name local scene = M.asset.scene return ui.row({ gap = 6, chip("stage:edit", "edit", not playing, function() play = nil end), chip("stage:play", "play", playing, function() if scene then start(scene.entities) end end), })endfunction S.view() local scene = M.asset.scene if not scene then return ui.text({ "opening the stage…", color = C.muted, no_wrap = true }) end if play and play.name == M.name then return world(scene) end local w, h = scene.size[1], scene.size[2] last = placements(scene.entities) local items = { backdrop(w, h) } for _, e in ipairs(scene.entities) do local r, p = look(e), last[e.id] if r and p then local place = { 1, 0, 0, 1, p[1], p[2] } table.insert(items, gfx.instance({ id = e.id, visual = r.frame, transform = place })) end end for _, mark in ipairs(body_marks(scene.entities)) do table.insert(items, mark) end for _, mark in ipairs(socket_marks()) do table.insert(items, mark) end return ui.frame({ id = "stage-canvas", visual = gfx.frame({ width = w, height = h, table.unpack(items) }), on_drag = function(e) on_drag(e, scene.entities) end, })end-- The scene's entities, for the side panel: id, the asset it shows, and what it is mounted on.function S.rows() local scene = M.asset.scene local rows = {} for _, e in ipairs(scene and scene.entities or {}) do local where = e.mount and ("on " .. e.mount.to .. "." .. e.mount.socket) or e.asset table.insert(rows, { id = e.id, note = where }) end return rowsendreturn S
-- The selected part's fields as inputs. A field holds a draft while you type; Enter checks it and-- writes once, Esc drops it. A value that doesn't parse turns the field red and writes nothing,-- so the drawing never sees it.local C = require("theme")local M = require("model")local I = { rev = 0 } -- bumped by every write: the canvas rebuilds from the patched mirrorlocal drafts = {} -- field id -> the text being typedlocal showing = nil -- whose fields `drafts` belongs tolocal function number(s) return tonumber(s)endlocal function positive(s) local n = tonumber(s) return n and n > 0 and n or nilend-- What the asset's var `name` holds: "colour", "number", or nil when it has no such var.local function var_kind(name) local v = M.asset.vars and M.asset.vars[name] return type(v) == "string" and "colour" or type(v) == "number" and "number" or nilendlocal function plain_colour(s) return pcall(gfx.solid, s) and s or nilend-- A colour field takes a colour or `@name` of a colour var; typing a colour unlinks the shape.local function colour(s) local name = s:match("^@(.+)$") if name then return var_kind(name) == "colour" and s or nil end return plain_colour(s)end-- A width takes a positive number or `@name` of a number var.local function length(s) local name = s:match("^@(.+)$") if name then return var_kind(name) == "number" and s or nil end return positive(s)end-- A small square of the colour a value shows: a var's, when it names one.local function swatch(v) local name = type(v) == "string" and v:match("^@(.+)$") local c = name and M.asset.vars and M.asset.vars[name] or v local ok = type(c) == "string" and plain_colour(c) return ui.row({ w = 14, h = 14, no_shrink = true, radius = 3, fill = ok and c or C.canvas, stroke = { 1, C.muted } })end-- `base` extended by more segments: a new table, so the base can be shared.local function at(base, ...) local out = table.clone(base) for _, seg in ipairs({ ... }) do table.insert(out, seg) end return outend-- `lead` (optional) sits between the label and the input: a colour's swatch.local function field(id, label, value, parse, path, lead) local text = drafts[id] or tostring(value) local bad = drafts[id] ~= nil and parse(drafts[id]) == nil local function commit() local v = drafts[id] and parse(drafts[id]) if v ~= nil then M.asset:set(path, v) drafts[id] = nil I.rev += 1 end end return ui.row({ gap = 6, align_center = true, ui.row({ w = 72, ui.text({ label, color = C.muted, font_size = 11, no_wrap = true }) }), lead or false, ui.input({ id = id, value = text, grow = true, h = 26, px = 8, radius = 4, fill = C.canvas, stroke = { 1, bad and C.bad or C.row_selected }, color = bad and C.bad or C.text, font_size = 12, on_input = function(v) drafts[id] = v end, on_enter = commit, on_esc = function() drafts[id] = nil end, }), })endlocal function note(label, text) return ui.row({ gap = 6, ui.row({ w = 72, ui.text({ label, color = C.muted, font_size = 11, no_wrap = true }) }), ui.text({ text, color = C.muted, font_size = 12, no_wrap = true }), })end-- One shape's fields: a gradient fill is shown but not edited yet; a missing stroke isn't offered.local function shape_fields(base, i, s, out) local id = "field:" .. base[4] .. ":" .. i .. ":" local here = at(base, "shapes", i) table.insert(out, ui.text({ "shape " .. i, color = C.text, font_size = 12, mt = 6, no_wrap = true })) if s.shadow then local sh = at(here, "shadow") table.insert(out, field(id .. "blur", "blur", s.shadow.blur, length, at(sh, "blur"))) table.insert(out, field(id .. "color", "colour", s.shadow.color, colour, at(sh, "color"), swatch(s.shadow.color))) return end if type(s.fill) == "string" then table.insert(out, field(id .. "fill", "fill", s.fill, colour, at(here, "fill"), swatch(s.fill))) elseif s.fill then table.insert(out, note("fill", next(s.fill) .. " gradient")) end if s.stroke then table.insert(out, field(id .. "width", "stroke", s.stroke[1], length, at(here, "stroke", 1))) table.insert(out, field(id .. "ink", "ink", s.stroke[2], colour, at(here, "stroke", 2), swatch(s.stroke[2]))) endend-- The asset's vars, one field each: edit one and every shape naming it changes. A var holds a-- plain value — no var names another.function I.vars() local vars = M.asset.vars local names = {} for name in pairs(vars or {}) do table.insert(names, name) end if #names == 0 then return {} end table.sort(names) local out = { ui.text({ "vars", color = C.accent, font_size = 13, mt = 12, no_wrap = true }) } for _, name in ipairs(names) do local v = vars[name] local is_colour = type(v) == "string" local parse = is_colour and plain_colour or positive local lead = is_colour and swatch(v) or nil table.insert(out, field("var:" .. name, name, v, parse, { "vars", name }, lead)) end return outend-- The fields of part `p`, or nothing for no part.function I.view(p) local who = p and M.name .. ":" .. p.id if who ~= showing then -- drafts belong to the part they were typed for showing, drafts = who, {} end if not p then return {} end local base = { "views", M.view, "parts", p.id } local id = "field:" .. p.id .. ":pivot:" local out = { ui.text({ p.id, color = C.accent, font_size = 13, mt = 12, no_wrap = true }) } table.insert(out, field(id .. "x", "pivot x", p.pivot[1], number, at(base, "pivot", 1))) table.insert(out, field(id .. "y", "pivot y", p.pivot[2], number, at(base, "pivot", 2))) for i, s in ipairs(p.shapes or {}) do shape_fields(base, i, s, out) end return outendreturn I
local C = require("theme")-- The selected part's path points as canvas dots. Each dot is its own part, so a press on it-- names it: `pt:<shape>:<command>:<k>`, k counting the command's points — its last is the end-- point (filled), any before it curve controls (hollow, tied to their end by a guide line).local H = {}-- Sizes are on screen: `s`, the viewport's zoom, divides them out so they never grow with it.local R = 4 -- a dot's radiuslocal GRAB = 10 -- how near a press must land to take it: a hand is not as exact as the dotlocal function circle(x, y, r) local k = r * 0.5523 -- a circle's cubic handle length return { { "move", x + r, y }, { "cubic", x + r, y + k, x + k, y + r, x, y + r }, { "cubic", x - k, y + r, x - r, y + k, x - r, y }, { "cubic", x - r, y - k, x - k, y - r, x, y - r }, { "cubic", x + k, y - r, x + r, y - k, x + r, y }, { "close" }, }endlocal function dot(id, x, y, control, s) local fill = control and C.canvas or C.accent local ring = control and C.accent or C.canvas local shapes = { { path = circle(x, y, GRAB / s), fill = "#00000000" }, -- unseen, but the part's to hit { path = circle(x, y, R / s), fill = fill, stroke = { 1.5 / s, ring } }, } return { id = id, pivot = { x, y }, shapes = shapes }end-- A curve's first control leans on where the curve starts; every later one, on where it ends.local function guide(lines, from, to, s) local path = { { "move", from[1], from[2] }, { "line", to[1], to[2] } } table.insert(lines, { path = path, stroke = { 1 / s, C.accent } })end-- Parts to append to the canvas drawing for `part`, at canvas scale `z`, seen at zoom `s`.function H.parts(part, z, s) local lines, controls, ends = {}, {}, {} for i, shape in ipairs(part.shapes or {}) do local last = nil -- the previous command's end: this one's start for j, cmd in ipairs(shape.path or {}) do -- a shadow has no path local pts = {} for k = 2, #cmd - 1, 2 do table.insert(pts, { cmd[k] * z, cmd[k + 1] * z }) end local stop = pts[#pts] for k, p in ipairs(pts) do if k < #pts then guide(lines, k == 1 and last or stop, p, s) end local control = k < #pts local d = dot("pt:" .. i .. ":" .. j .. ":" .. k, p[1], p[2], control, s) table.insert(control and controls or ends, d) end last = stop or last end end -- The guides go first, under the dots; a press on one is no point. Ends go last, over every -- control: at 1× a cubic's end sits 3 units from its controls, and the top dot takes a press. local out = #lines > 0 and { { id = "pt:guides", pivot = { 0, 0 }, shapes = lines } } or {} for _, d in ipairs(controls) do table.insert(out, d) end for _, d in ipairs(ends) do table.insert(out, d) end return outend-- The controls that ride with point `k` of command `j`, so a curve keeps its shape as its end-- moves: the cubic's control into it and the next cubic's out of it. A quad's one control-- belongs to both its ends, so it stays; a control moves alone.function H.riders(path, j, k) local cmd, after, out = path[j], path[j + 1], {} if k < (#cmd - 1) / 2 then return out end if cmd[1] == "cubic" then table.insert(out, { j = j, k = 2 }) end if after and after[1] == "cubic" then table.insert(out, { j = j + 1, k = 1 }) end return outendreturn H
return { bg = "#12131c", canvas = "#1c1e2b", floor = "#2a3b33", -- the stage's backdrop panel = "#171925", row_hover = "#23263a", row_selected = "#2f3552", text = "#e7e9f3", muted = "#8a8fa8", accent = "#ffd166", bad = "#ff6b6b", -- a field whose text does not parse zoom = 2.5, -- drawing units to canvas units panel_w = 220,}
-- A drawing module: pure data, drawing-absolute coordinates. List order is draw order;-- `parent` is the hierarchy, so the far arm belongs to the body yet draws behind it.return { size = { 160, 240 }, -- Its inputs: shapes say "@shirt", so a variant or one edit recolours every shape that does. -- The `_far` shades are the far limb, a touch darker. vars = { ink = "#1b1b3a", line = 2, skin = "#ffd9b3", skin_far = "#e8b894", hair = "#3b2f5c", shirt = "#f84aa7", shirt_shade = "#d63d8f", shirt_far = "#c23a86", trousers = "#2e3a59", }, parts = { { id = "arm_far", parent = "body", pivot = { 56, 98 }, shapes = { { path = { {"move",49,100}, {"quad",49,93,56,93}, {"quad",63,93,63,100}, {"line",63,146}, {"quad",63,153,56,153}, {"quad",49,153,49,146}, {"close"} }, fill = "@shirt_far", stroke = { "@line", "@ink" } }, { path = { {"move",62,156}, {"cubic",62,159.31,59.31,162,56,162}, {"cubic",52.69,162,50,159.31,50,156}, {"cubic",50,152.69,52.69,150,56,150}, {"cubic",59.31,150,62,152.69,62,156}, {"close"} }, fill = "@skin_far", stroke = { "@line", "@ink" } }, } }, { id = "leg_l", parent = "body", pivot = { 68, 164 }, shapes = { { path = { {"move",61,164}, {"line",75,164}, {"line",75,212}, {"line",61,212}, {"close"} }, fill = "@trousers", stroke = { "@line", "@ink" } }, { path = { {"move",58,210}, {"line",76,210}, {"quad",82,210,82,216}, {"line",82,220}, {"line",56,220}, {"line",56,214}, {"quad",56,210,58,210}, {"close"} }, fill = "@ink" }, } }, { id = "leg_r", parent = "body", pivot = { 92, 164 }, shapes = { { path = { {"move",85,164}, {"line",99,164}, {"line",99,212}, {"line",85,212}, {"close"} }, fill = "@trousers", stroke = { "@line", "@ink" } }, { path = { {"move",82,210}, {"line",100,210}, {"quad",106,210,106,216}, {"line",106,220}, {"line",80,220}, {"line",80,214}, {"quad",80,210,82,210}, {"close"} }, fill = "@ink" }, } }, { id = "body", pivot = { 80, 166 }, shapes = { { path = { {"move",66,88}, {"line",94,88}, {"quad",106,88,106,100}, {"line",106,154}, {"quad",106,166,94,166}, {"line",66,166}, {"quad",54,166,54,154}, {"line",54,100}, {"quad",54,88,66,88}, {"close"} }, fill = "@shirt", stroke = { "@line", "@ink" } }, { path = { {"move",92,90}, {"quad",104,90,104,100}, {"line",104,154}, {"quad",104,164,94,164}, {"line",90,164}, {"quad",98,128,92,90}, {"close"} }, fill = "@shirt_shade" }, } }, { id = "head", parent = "body", pivot = { 80, 88 }, shapes = { { path = { {"move",110,58}, {"cubic",110,74.57,96.57,88,80,88}, {"cubic",63.43,88,50,74.57,50,58}, {"cubic",50,41.43,63.43,28,80,28}, {"cubic",96.57,28,110,41.43,110,58}, {"close"} }, fill = "@skin", stroke = { "@line", "@ink" } }, { path = { {"move",50,56}, {"cubic",50,30,70,24,84,26}, {"cubic",100,28,112,40,110,58}, {"quad",96,40,78,42}, {"quad",62,44,50,56}, {"close"} }, fill = "@hair", stroke = { "@line", "@ink" } }, { path = { {"move",74,58}, {"cubic",74,60.21,72.21,62,70,62}, {"cubic",67.79,62,66,60.21,66,58}, {"cubic",66,55.79,67.79,54,70,54}, {"cubic",72.21,54,74,55.79,74,58}, {"close"} }, fill = "@ink" }, { path = { {"move",94,58}, {"cubic",94,60.21,92.21,62,90,62}, {"cubic",87.79,62,86,60.21,86,58}, {"cubic",86,55.79,87.79,54,90,54}, {"cubic",92.21,54,94,55.79,94,58}, {"close"} }, fill = "@ink" }, { path = { {"move",72,71}, {"quad",80,78,88,71} }, stroke = { "@line", "@ink" } }, } }, { id = "arm_near", parent = "body", pivot = { 104, 98 }, shapes = { { path = { {"move",97,100}, {"quad",97,93,104,93}, {"quad",111,93,111,100}, {"line",111,146}, {"quad",111,153,104,153}, {"quad",97,153,97,146}, {"close"} }, fill = "@shirt", stroke = { "@line", "@ink" } }, { path = { {"move",110,156}, {"cubic",110,159.31,107.31,162,104,162}, {"cubic",100.69,162,98,159.31,98,156}, {"cubic",98,152.69,100.69,150,104,150}, {"cubic",107.31,150,110,152.69,110,156}, {"close"} }, fill = "@skin", stroke = { "@line", "@ink" } }, } }, },}
-- Animation as data. Keys are {time, value, easing?}; `in_out` shapes the segment arriving at-- the key. Values offset the rest pose: x/y in drawing units, rot in degrees about the pivot.return { -- Breathing: the body bobs, the head lags a little, the arms sway. idle = { length = 1.2, loop = true, tracks = { body = { y = { {0, 0}, {0.6, -3, "in_out"}, {1.2, 0, "in_out"} } }, head = { rot = { {0, 0}, {0.6, 3, "in_out"}, {1.2, 0, "in_out"} } }, arm_near = { rot = { {0, 0}, {0.6, -6, "in_out"}, {1.2, 0, "in_out"} } }, arm_far = { rot = { {0, 0}, {0.6, 6, "in_out"}, {1.2, 0, "in_out"} } }, }, }, -- Walking in place: legs and arms swing in opposition, the body dips on each step. The -- world moves the hero; this only has to look like walking. walk = { length = 0.5, loop = true, tracks = { leg_l = { rot = { {0, 22}, {0.25, -22, "in_out"}, {0.5, 22, "in_out"} } }, leg_r = { rot = { {0, -22}, {0.25, 22, "in_out"}, {0.5, -22, "in_out"} } }, arm_near = { rot = { {0, -18}, {0.25, 18, "in_out"}, {0.5, -18, "in_out"} } }, arm_far = { rot = { {0, 18}, {0.25, -18, "in_out"}, {0.5, 18, "in_out"} } }, body = { y = { {0, 0}, {0.125, -3, "in_out"}, {0.25, 0, "in_out"}, {0.375, -3, "in_out"}, {0.5, 0, "in_out"}, } }, }, }, -- Walking in profile: legs stride forward and back about the hip, arms counter-swing. walk_side = { length = 0.5, loop = true, tracks = { leg_l = { rot = { {0, 28}, {0.25, -28, "in_out"}, {0.5, 28, "in_out"} } }, leg_r = { rot = { {0, -28}, {0.25, 28, "in_out"}, {0.5, -28, "in_out"} } }, arm_near = { rot = { {0, 24}, {0.25, -24, "in_out"}, {0.5, 24, "in_out"} } }, arm_far = { rot = { {0, -24}, {0.25, 24, "in_out"}, {0.5, -24, "in_out"} } }, body = { y = { {0, 0}, {0.125, -3, "in_out"}, {0.25, 0, "in_out"}, {0.375, -3, "in_out"}, {0.5, 0, "in_out"}, } }, }, }, -- A jump, played once: the body — and everything hanging off it — rises and falls while the -- entity's feet stay where they are, so draw order ignores the jump. Legs tuck, arms lift. jump = { length = 0.45, tracks = { body = { y = { {0, 0}, {0.1, -28}, {0.225, -40, "in_out"}, {0.35, -28, "in_out"}, {0.45, 0}, } }, leg_l = { y = { {0, 0}, {0.225, -10, "in_out"}, {0.45, 0, "in_out"} } }, leg_r = { y = { {0, 0}, {0.225, -10, "in_out"}, {0.45, 0, "in_out"} } }, arm_near = { rot = { {0, 0}, {0.225, -30, "in_out"}, {0.45, 0, "in_out"} } }, arm_far = { rot = { {0, 0}, {0.225, 30, "in_out"}, {0.45, 0, "in_out"} } }, }, }, -- Holding something in front: both arms turn in toward the middle, the body breathes. carry = { length = 1.2, loop = true, tracks = { body = { y = { {0, 0}, {0.6, -2, "in_out"}, {1.2, 0, "in_out"} } }, arm_near = { rot = { {0, 35} } }, arm_far = { rot = { {0, -35} } }, }, }, -- Walking while holding: the walk's legs and bob, the carry's arms. One clip plays at a time -- (no layers yet), so the two are combined here by hand. carry_walk = { length = 0.5, loop = true, tracks = { leg_l = { rot = { {0, 22}, {0.25, -22, "in_out"}, {0.5, 22, "in_out"} } }, leg_r = { rot = { {0, -22}, {0.25, 22, "in_out"}, {0.5, -22, "in_out"} } }, arm_near = { rot = { {0, 35} } }, arm_far = { rot = { {0, -35} } }, body = { y = { {0, 0}, {0.125, -3, "in_out"}, {0.25, 0, "in_out"}, {0.375, -3, "in_out"}, {0.5, 0, "in_out"}, } }, }, }, -- The lid swings back about its hinge and holds there. open = { length = 0.5, tracks = { lid = { rot = { {0, 0}, {0.5, -100, "in_out"} } } } }, close = { length = 0.4, tracks = { lid = { rot = { {0, -100}, {0.4, 0, "in_out"} } } } },}
-- A day/night toggle: a sky pill with clouds, a sun knob that rolls across and becomes a moon,-- and a night sky with stars. The helpers only generate paths; what lands in the asset is data.---- The rest pose shows every part at once (handy for editing). The states are clips: `day` and-- `night` hold a state, `to_night` and `to_day` animate between them. Hidden parts are scaled to-- zero — there is no opacity track yet.local K = 0.5523 -- a quarter circle's cubic handle, as a fraction of the radiuslocal function circle(cx, cy, r, reverse) local k = K * r local s = reverse and -1 or 1 -- reversed winding cuts a hole in a non-zero fill return { { "move", cx + r, cy }, { "cubic", cx + r, cy + s * k, cx + k, cy + s * r, cx, cy + s * r }, { "cubic", cx - k, cy + s * r, cx - r, cy + s * k, cx - r, cy }, { "cubic", cx - r, cy - s * k, cx - k, cy - s * r, cx, cy - s * r }, { "cubic", cx + k, cy - s * r, cx + r, cy - s * k, cx + r, cy }, { "close" }, }endlocal function ring(cx, cy, outer, inner) local path = circle(cx, cy, outer) for _, cmd in ipairs(circle(cx, cy, inner, true)) do table.insert(path, cmd) end return pathend-- A stadium from x0..x1, y0..y1: the ends are half circles.local function pill(x0, y0, x1, y1) local r = (y1 - y0) / 2 local k, cy = K * r, y0 + r return { { "move", x0 + r, y0 }, { "line", x1 - r, y0 }, { "cubic", x1 - r + k, y0, x1, cy - k, x1, cy }, { "cubic", x1, cy + k, x1 - r + k, y1, x1 - r, y1 }, { "line", x0 + r, y1 }, { "cubic", x0 + r - k, y1, x0, cy + k, x0, cy }, { "cubic", x0, cy - k, x0 + r - k, y0, x0 + r, y0 }, { "close" }, }end-- A four-pointed sparkle: curves pinched toward the centre.local function sparkle(x, y, s) return { { "move", x, y - s }, { "quad", x, y, x + s, y }, { "quad", x, y, x, y + s }, { "quad", x, y, x - s, y }, { "quad", x, y, x, y - s }, { "close" }, }endlocal SKY = pill(4, 4, 196, 80)local KNOB = { 42, 42 } -- the knob's centre by day; by night it has rolled 116 to the rightlocal function shapes_of(list, fill) local out = {} for _, c in ipairs(list) do table.insert(out, { path = circle(c[1], c[2], c[3]), fill = fill }) end return outendlocal stars = {}for _, s in ipairs({ { 34, 26, 5 }, { 58, 52, 4 }, { 80, 24, 3.5 }, { 98, 46, 5 }, { 46, 64, 3 }, { 114, 30, 3 } }) do table.insert(stars, { path = sparkle(s[1], s[2], s[3]), fill = "#fff8d6" })endfor _, d in ipairs({ { 66, 36, 1.2 }, { 24, 46, 1 }, { 88, 62, 1.1 }, { 120, 52, 0.9 } }) do table.insert(stars, { path = circle(d[1], d[2], d[3]), fill = "#e8ecff" })end-- Each cloud is overlapping circles: a shaded copy a little lower, then the white on top.local cloud = { { 130, 56, 12 }, { 146, 48, 15 }, { 162, 56, 12 }, { 146, 60, 12 }, { 172, 38, 8 }, { 182, 42, 7 }, { 164, 42, 7 } }local cloud_shade = {}for _, c in ipairs(cloud) do table.insert(cloud_shade, { c[1], c[2] + 2.5, c[3] })endlocal clouds = shapes_of(cloud_shade, "#c4dbf3")for _, s in ipairs(shapes_of(cloud, "#ffffff")) do table.insert(clouds, s)endlocal craters = {}for _, c in ipairs({ { 32, 48, 6 }, { 52, 34, 4 }, { 50, 54, 3 }, { 36, 31, 2.5 } }) do table.insert(craters, { path = circle(c[1], c[2], c[3]), fill = "#b3b9c9" }) table.insert(craters, { path = circle(c[1] + 0.8, c[2] + 0.8, c[3] * 0.7), fill = "#c8cddb" })endlocal moon = { { path = circle(42, 42, 30), fill = { radial = { center = KNOB, radius = 30, focus = { 34, 32 }, focus_radius = 2, stops = { { 0, "#ffffff" }, { 0.6, "#dfe3ec" }, { 1, "#a9b0c2" } } } } },}for _, c in ipairs(craters) do table.insert(moon, c)endtable.insert(moon, { path = ring(42, 42, 31, 29), fill = { sweep = { center = KNOB, stops = { { 0, "#f4f6fb" }, { 0.25, "#8c93a6" }, { 0.5, "#f4f6fb" }, { 0.75, "#8c93a6" }, { 1, "#f4f6fb" },} } } })local parts = { { id = "track", pivot = { 100, 42 }, shapes = { { shadow = { rect = { 4, 9, 192, 76 }, radius = 38, blur = 5, color = "#0b1a3a66" } }, { path = SKY, fill = { linear = { from = { 0, 4 }, to = { 0, 80 }, stops = { { 0, "#8fd3ff" }, { 1, "#3b8ee8" } } } } }, } }, -- Grows from where the moon lands: a convex pill scaled about a point inside it stays inside. { id = "night_sky", pivot = { 158, 42 }, shapes = { { path = SKY, fill = { linear = { from = { 0, 4 }, to = { 0, 80 }, stops = { { 0, "#2a2f74" }, { 1, "#0b0e2e" } } } } }, } }, { id = "stars", pivot = { 70, 42 }, shapes = stars }, { id = "clouds", pivot = { 152, 52 }, shapes = clouds }, { id = "rim", pivot = { 100, 42 }, shapes = { { path = SKY, stroke = { 2, "#1d3f73" } } } }, { id = "glow", parent = "knob", pivot = KNOB, shapes = { { path = circle(42, 42, 37), fill = { radial = { center = KNOB, radius = 37, stops = { { 0.7, "#ffd16699" }, { 1, "#ffd16600" } } } } }, } }, { id = "knob", pivot = KNOB, shapes = { { shadow = { rect = { 12, 15, 60, 60 }, radius = 30, blur = 4, color = "#00000055" } }, { path = circle(42, 42, 30), fill = { radial = { center = KNOB, radius = 30, focus = { 34, 32 }, focus_radius = 2, stops = { { 0, "#fff6c4" }, { 0.55, "#ffc93c" }, { 1, "#f48c06" } } } } }, { path = ring(42, 42, 31, 29), fill = { sweep = { center = KNOB, stops = { { 0, "#ffe08a" }, { 0.5, "#e07a00" }, { 1, "#ffe08a" }, } } } }, } }, { id = "moon", parent = "knob", pivot = KNOB, shapes = moon },}-- The knob rolls: 116 across a circle of radius 30 is 222 degrees of turn, eased together.local roll = { x = { { 0, 0 }, { 0.6, 116, "in_out" } }, rot = { { 0, 0 }, { 0.6, 222, "in_out" } } }local unroll = { x = { { 0, 116 }, { 0.6, 0, "in_out" } }, rot = { { 0, 222 }, { 0.6, 0, "in_out" } } }local clips = { day = { length = 0.1, tracks = { night_sky = { scale = { { 0, 0 } } }, stars = { scale = { { 0, 0 } } }, moon = { scale = { { 0, 0 } } }, } }, night = { length = 0.1, tracks = { knob = { x = { { 0, 116 } }, rot = { { 0, 222 } } }, clouds = { scale = { { 0, 0 } } }, glow = { scale = { { 0, 0 } } }, } }, to_night = { length = 0.6, tracks = { knob = roll, moon = { scale = { { 0, 0 }, { 0.2, 0 }, { 0.5, 1, "in_out" } } }, glow = { scale = { { 0, 1 }, { 0.3, 0, "in_out" } } }, night_sky = { scale = { { 0, 0 }, { 0.6, 1, "in_out" } } }, clouds = { scale = { { 0, 1 }, { 0.25, 0, "in_out" } }, y = { { 0, 0 }, { 0.25, 8, "in_out" } } }, stars = { scale = { { 0, 0 }, { 0.35, 0 }, { 0.6, 1, "in_out" } } }, } }, to_day = { length = 0.6, tracks = { knob = unroll, moon = { scale = { { 0, 1 }, { 0.3, 0, "in_out" } } }, glow = { scale = { { 0, 0 }, { 0.3, 0 }, { 0.6, 1, "in_out" } } }, night_sky = { scale = { { 0, 1 }, { 0.6, 0, "in_out" } } }, clouds = { scale = { { 0, 0 }, { 0.3, 0 }, { 0.6, 1, "in_out" } }, y = { { 0, 8 }, { 0.3, 8 }, { 0.6, 0, "in_out" } } }, stars = { scale = { { 0, 1 }, { 0.25, 0, "in_out" } } }, } },}return { size = { 200, 84 }, parts = parts, clips = clips }
-- The lid hinges at its back edge, so an "open" clip is one rotation about that pivot.return { size = { 96, 80 }, parts = { { id = "base", pivot = { 48, 80 }, shapes = { { path = { {"move",8,36}, {"line",88,36}, {"line",88,76}, {"quad",88,80,84,80}, {"line",12,80}, {"quad",8,80,8,76}, {"close"} }, fill = "#9a5b2e", stroke = { 2.5, "#2b1a10" } }, { path = { {"move",8,50}, {"line",88,50}, {"line",88,56}, {"line",8,56}, {"close"} }, fill = "#e0b04a" }, } }, { id = "lid", parent = "base", pivot = { 8, 36 }, shapes = { { path = { {"move",8,36}, {"line",8,24}, {"cubic",8,6,88,6,88,24}, {"line",88,36}, {"close"} }, fill = "#b36a36", stroke = { 2.5, "#2b1a10" } }, { path = { {"move",42,30}, {"line",54,30}, {"line",54,42}, {"line",42,42}, {"close"} }, fill = "#e0b04a", stroke = { 2, "#2b1a10" } }, } }, }, -- The lid swings back about its hinge and holds there. clips = { open = { length = 0.5, tracks = { lid = { rot = { {0, 0}, {0.5, -100, "in_out"} } } } }, close = { length = 0.4, tracks = { lid = { rot = { {0, -100}, {0.4, 0, "in_out"} } } } }, }, -- Solid along its base; its sensor is the reach of whoever comes to open it. world = { collider = { rect = { 80, 24 }, at = { 8, 56 } }, sensor = { circle = 72, at = { 48, 68 } } },}
-- A top hat. Its plug is the middle of the brim: the point that sits on a `hat` socket.local K = 0.5523 -- a quarter ellipse's cubic handle, as a fraction of the radiuslocal function ellipse(cx, cy, rx, ry) local kx, ky = K * rx, K * ry return { { "move", cx + rx, cy }, { "cubic", cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry }, { "cubic", cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy }, { "cubic", cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry }, { "cubic", cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy }, { "close" }, }endlocal ink = { 2, "#11121c" }return { size = { 64, 50 }, plug = { kind = "hat", at = { 32, 44 } }, parts = { { id = "hat", pivot = { 32, 44 }, shapes = { { shadow = { rect = { 6, 40, 52, 10 }, radius = 5, blur = 2.5, color = "#00000044" } }, { path = ellipse(32, 43, 30, 6), fill = "#1d1e2c", stroke = ink }, -- The crown: slightly wider at the top, lit from the left. { path = { { "move", 15, 42 }, { "line", 12, 6 }, { "quad", 32, 0, 52, 6 }, { "line", 49, 42 }, { "quad", 32, 46, 15, 42 }, { "close" } }, fill = { linear = { from = { 12, 0 }, to = { 52, 0 }, stops = { { 0, "#4a4e6e" }, { 0.35, "#2b2d42" }, { 1, "#15161f" } } } }, stroke = ink }, { path = { { "move", 14, 31 }, { "quad", 32, 35, 50, 31 }, { "line", 49, 39 }, { "quad", 32, 43, 15, 39 }, { "close" } }, fill = { linear = { from = { 0, 31 }, to = { 0, 43 }, stops = { { 0, "#ff5d6c" }, { 1, "#b81f2e" } } } } }, } }, },}
-- A wooden crate. Loose by default: anything that walks into it pushes it.local S = 48return { size = { S, S }, parts = { { id = "box", pivot = { S / 2, S / 2 }, shapes = { { path = { {"move",1,1}, {"line",S-1,1}, {"line",S-1,S-1}, {"line",1,S-1}, {"close"} }, fill = "#a8743f", stroke = { 2.5, "#2b1a10" } }, { path = { {"move",4,4}, {"line",S-4,S-4} }, stroke = { 3, "#6b4423" } }, { path = { {"move",S-4,4}, {"line",4,S-4} }, stroke = { 3, "#6b4423" } }, } }, }, world = { collider = { rect = { S, S } }, loose = true },}
-- A stage: assets placed together. Positions are the top-left of each asset's box; a mounted-- entity has no position of its own — it sits on its host's socket. An entity's world fields-- (`collider`, `sensor`, `loose`, `group`, `blocks`) replace its asset's `world` ones; `false`-- takes one away. `controller` is the room's alone.return { scene = { size = { 720, 400 }, entities = { { id = "chest", asset = "chest", pos = { 460, 264 } }, -- The room's keys drive this hero; the rival, the same asset, has none. { id = "hero", asset = "hero", pos = { 140, 110 }, clip = "idle", controller = { speed = 160, axis_x = { neg = "KeyA", pos = "KeyD" }, axis_y = { neg = "KeyW", pos = "KeyS" }, } }, { id = "crate", asset = "crate", pos = { 40, 308 } }, -- On the floor: drag its brim onto the hero's head and it equips onto `head_top`. { id = "hat", asset = "hat", pos = { 320, 300 } }, -- The same hero asset in a teal shirt: an entity's `vars` make a variant of its asset. { id = "rival", asset = "hero", pos = { 560, 100 }, clip = "idle", vars = { shirt = "#2ec4b6", shirt_shade = "#25a89c", shirt_far = "#1f8f85" } }, }, },}
06 / PHYSICS
A 2D physics engine, driven from Lua.
Gravity, wells, forces, joints and a grabbing hand are properties in the app description. This took a Rust engine change plus a Lua surface; the scene itself is one app.
-- Siege: a side-on gravity world with a chain of causes. A slingshot fires a stone (dotted arc-- previews the shot under the world's gravity) at a castle of stone, wood and glass; the collapse-- topples a run of dominoes, the last pushes a marble down a ramp into a bucket that lights up.-- Pigs score when they fall to the ground. Also a toy: a swinging wrecking ball on a chain-- (rope joints), a seesaw (pin joint), bomb (`blast`), moon/flip gravity, a gravity well, and any-- loose body can be dragged and thrown (`grab` / `release`). Nothing runs per frame: Lua-- describes bodies and hears zones; Rapier does the moving. Lua cannot read a body's position, so-- nothing here depends on one.local W, H, FY = 960, 520, 480 -- the world, and the floor's toplocal C = { bg = "#0b0d16", panel = "#1a1e2e", text = "#e8ebf5", muted = "#8f96b0", accent = "#ffd166", button = "#2a3050", button_hover = "#3a4270", lit = "#ffe066",}local G_NORMAL, G_MOON, G_FLIP = { 0, 900 }, { 0, 280 }, { 0, -900 }-- ── drawing ───────────────────────────────────────────────────────────────────────────────────local function rectp(x, y, w, h) return { { "move", x, y }, { "line", x + w, y }, { "line", x + w, y + h }, { "line", x, y + h }, { "close" } }endlocal function disc(cx, cy, r) local k = r * 0.5523 return { { "move", cx, cy - r }, { "cubic", cx + k, cy - r, cx + r, cy - k, cx + r, cy }, { "cubic", cx + r, cy + k, cx + k, cy + r, cx, cy + r }, { "cubic", cx - k, cy + r, cx - r, cy + k, cx - r, cy }, { "cubic", cx - r, cy - k, cx - k, cy - r, cx, cy - r }, { "close" } }endlocal function ellipse(cx, cy, rx, ry) local kx, ky = rx * 0.5523, ry * 0.5523 return { { "move", cx, cy - ry }, { "cubic", cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy }, { "cubic", cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry }, { "cubic", cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy }, { "cubic", cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry }, { "close" } }endlocal function drawing(w, h, shapes) return gfx.drawing({ size = { w, h }, parts = { { id = "it", pivot = { w / 2, h / 2 }, shapes = shapes } } })endlocal cache = {}local function memo(key, make) if not cache[key] then cache[key] = make() end return cache[key]end-- Interpolates two #rrggbb colours: stacked bands make a gradient, since a drawing's fill is solid.local function mix(a, b, t) local function ch(s, i) return tonumber(s:sub(i, i + 1), 16) end local out = "#" for i = 2, 6, 2 do out = out .. string.format("%02x", math.floor(ch(a, i) + (ch(b, i) - ch(a, i)) * t + 0.5)) end return outendlocal function bands(x, y, w, h, stops, n) local shapes = {} for i = 0, n - 1 do local t = i / (n - 1) * (#stops - 1) local s = math.min(#stops - 2, math.floor(t)) shapes[#shapes + 1] = { path = rectp(x, y + h * i / n, w, h / n + 1), fill = mix(stops[s + 1], stops[s + 2], t - s) } end return shapesendlocal function sky() return memo("sky", function() local s = bands(0, 0, W, FY, { "#0e1432", "#2c2457", "#7a3a6e", "#e0667a", "#ffb56b" }, 30) for i = 1, 40 do -- stars, thinning toward the horizon local x, y = (i * 137) % W, (i * 71) % 200 s[#s + 1] = { path = disc(x, y, 0.8 + (i % 3) * 0.4), fill = "#ffffff" .. (i % 2 == 0 and "cc" or "77") } end s[#s + 1] = { path = disc(780, 90, 34), fill = "#fff1c9" } s[#s + 1] = { path = disc(792, 84, 30), fill = "#ffe29a44" } -- far hills s[#s + 1] = { path = { { "move", 0, FY }, { "line", 0, 380 }, { "quad", 140, 330, 260, 372 }, { "quad", 380, 410, 520, 360 }, { "quad", 660, 310, 800, 372 }, { "quad", 900, 410, W, 380 }, { "line", W, FY }, { "close" } }, fill = "#3b2a5acc" } s[#s + 1] = { path = { { "move", 0, FY }, { "line", 0, 430 }, { "quad", 200, 390, 360, 440 }, { "quad", 560, 480, 700, 430 }, { "quad", 840, 390, W, 440 }, { "line", W, FY }, { "close" } }, fill = "#2a1f45" } return drawing(W, FY, s) end)endlocal function ground() return memo("ground", function() local s = bands(0, 0, W, H - FY, { "#4a7a3a", "#2f5a2c", "#2a2018", "#18120d" }, 14) s[#s + 1] = { path = rectp(0, 0, W, 3), fill = "#8fd45a" } return drawing(W, H - FY, s) end)end-- A lit block: body, bright top edge, dark bottom edge, outline.local function slab(w, h, base, hi, lo, edge) return memo(table.concat({ "slab", w, h, base }, ":"), function() return drawing(w, h, { { path = rectp(0, 0, w, h), fill = base }, { path = rectp(0, 0, w, math.min(3, h / 3)), fill = hi }, { path = rectp(0, h - math.min(3, h / 3), w, math.min(3, h / 3)), fill = lo }, { path = rectp(0.5, 0.5, w - 1, h - 1), stroke = { 1, edge or "#00000066" } }, }) end)endlocal function ball(r, base, hi, lo, mark) return memo(table.concat({ "ball", r, base }, ":"), function() local s = { { path = disc(r, r, r), fill = lo }, { path = disc(r - 1, r - 1, r - 2), fill = base }, { path = disc(r * 0.7, r * 0.65, r * 0.38), fill = hi } } if mark then s[#s + 1] = { path = rectp(r - 1.5, 2, 3, r * 0.6), fill = mark } end s[#s + 1] = { path = disc(r, r, r - 0.5), stroke = { 1, "#00000077" } } return drawing(2 * r, 2 * r, s) end)endlocal function pig(r) return memo("pig" .. r, function() local e = r * 0.42 return drawing(2 * r, 2 * r, { { path = disc(r, r, r), fill = "#4f9d3a" }, { path = disc(r - 1, r - 1, r - 2), fill = "#7bd357" }, { path = disc(r, r * 1.2, r * 0.42), fill = "#5aa840" }, -- snout { path = disc(r - 3, r * 1.2, 1.6), fill = "#2d5a1f" }, { path = disc(r + 3, r * 1.2, 1.6), fill = "#2d5a1f" }, { path = disc(r - e, r * 0.75, 3.2), fill = "#ffffff" }, { path = disc(r + e, r * 0.75, 3.2), fill = "#ffffff" }, { path = disc(r - e, r * 0.78, 1.5), fill = "#102010" }, { path = disc(r + e, r * 0.78, 1.5), fill = "#102010" }, { path = disc(r, r, r - 0.5), stroke = { 1, "#1f4a14" } }, }) end)endlocal function invisible(w, h) return memo("inv" .. w .. "x" .. h, function() return drawing(w, h, { { path = rectp(0, 0, w, h), fill = "#00000000" } }) end)endlocal KIND = { -- how each block is drawn and made stone = { base = "#8d93a6", hi = "#c5cad9", lo = "#5b6075", mat = { bounce = 0.05, friction = 0.02, grip = 0.9, density = 3 } }, wood = { base = "#c58b4e", hi = "#e8b27a", lo = "#8a5a2c", mat = { bounce = 0.3, friction = 0.02, grip = 0.7, density = 0.7 } }, glass = { base = "#8fe3f8aa", hi = "#ffffffcc", lo = "#4aa7c4aa", edge = "#e8fbffcc", mat = { bounce = 0.5, friction = 0.02, grip = 0.4, density = 0.8 } }, domino = { base = "#e0505a", hi = "#ff9aa0", lo = "#9a2a35", mat = { bounce = 0.2, friction = 0.02, grip = 0.9, density = 1.5 } },}-- ── the scene as data ─────────────────────────────────────────────────────────────────────────local gen = 0 -- bumps on reset: fresh ids respawn everything where it beganlocal function N(name) return gen == 0 and name or (name .. "~" .. gen) endlocal function castle() local b = {} local function add(name, kind, x, y, w, h) b[#b + 1] = { name = name, kind = kind, x = x, y = y, w = w, h = h } end for i = 0, 4 do -- two stone pillars of five add("sl" .. i, "stone", 440, FY - 28 * (i + 1), 28, 28) add("sr" .. i, "stone", 516, FY - 28 * (i + 1), 28, 28) end add("beam1", "wood", 432, 326, 120, 14) add("wl", "wood", 448, 282, 16, 44) add("wr", "wood", 524, 282, 16, 44) add("beam2", "wood", 432, 270, 120, 12) add("g1", "glass", 440, 254, 16, 16) add("g2", "glass", 536, 254, 16, 16) add("g3", "glass", 466, 300, 10, 26) add("g4", "glass", 512, 300, 10, 26) -- dominoes grow taller along the run, so each hits harder than the last; the last is a hammer local x = 600 for i, h in ipairs({ 30, 34, 38, 43, 48, 54, 60 }) do add("d" .. (i - 1), "domino", x, 440 - h, 8, h) x = x + 8 + 0.45 * h end add("crate1", "wood", 478, FY - 24, 24, 24) add("seesaw_c1", "wood", 196, FY - 58, 22, 24) return bendlocal blocks = castle()local state -- everything the game remembers; a reset replaces itlocal function fresh() return { score = 0, pigs = { pig_roof = true, pig_mid = true }, goal = false, shots = {}, nshot = 0, aim = nil, g = G_NORMAL, gname = "normal", well = false, status = "Pull the stone back and let go", pending = nil }endstate = fresh()local SLING = { 96, 392 } -- where the stone rests; pulled back, up to PULL awaylocal PULL, POWER, PIPS, BAND = 72, 11, 16, 6local grabbable = {} -- ids a drag may take; rebuilt each viewlocal function loose(id, x, y, drawing_, collider, mat, extra) grabbable[id] = true local e = { id = id, pos = { x, y }, drawing = drawing_, collider = collider, loose = mat } for k, v in pairs(extra or {}) do e[k] = v end return eend-- ── entities ──────────────────────────────────────────────────────────────────────────────────local function build() grabbable = {} local out = {} local function add(e) out[#out + 1] = e end add({ id = "sky", pos = { 0, 0 }, drawing = sky() }) -- contact shadows: soft dark ellipses on the ground under the structures add({ id = "shadow", pos = { 405, FY - 7 }, drawing = memo("shadow", function() return drawing(190, 14, { { path = ellipse(95, 7, 95, 6), fill = "#00000030" }, { path = ellipse(95, 7, 70, 4.5), fill = "#00000040" } }) end) }) add({ id = "shadow2", pos = { 580, FY - 41 }, drawing = memo("shadow2", function() return drawing(250, 5, { { path = ellipse(125, 2.5, 125, 2.5), fill = "#00000040" } }) end) }) -- floor, plinth, shelf and ramp: fixed colliders add({ id = "floor", pos = { 0, FY }, drawing = ground(), collider = { rect = { W, H - FY } } }) add({ id = "wall:t", pos = { 0, -20 }, drawing = invisible(W, 20), collider = { rect = { W, 20 } } }) -- flipped gravity piles up here add({ id = "wall:l", pos = { -20, 0 }, drawing = invisible(20, H), collider = { rect = { 20, H } } }) add({ id = "wall:r", pos = { W, 0 }, drawing = invisible(20, H), collider = { rect = { 20, H } } }) add({ id = "plinth", pos = { 82, 424 }, drawing = slab(28, 56, "#6b5b4a", "#9a8670", "#3f3428"), collider = { rect = { 28, 56 } } }) add({ id = "shelf", pos = { 580, 440 }, drawing = slab(240, 40, "#5b6075", "#8d93a6", "#3a3e50"), collider = { rect = { 240, 40 } } }) local RS, RW = 18, 4.5 for i = 0, RS - 1 do -- a slope in fine steps, the box colliders being axis-aligned local top = 440 + (i + 1) * (FY - 440 - 2) / RS add({ id = "ramp" .. i, pos = { 820 + RW * i, top }, drawing = slab(RW + 0.5, FY - top, "#5b6075", "#8d93a6", "#3a3e50", "#5b6075"), collider = { rect = { RW, FY - top } } }) end add({ id = "bucket:wall", pos = { 948, 420 }, drawing = slab(10, 60, "#7a6a55", "#a89478", "#4a3f30"), collider = { rect = { 10, 60 } } }) -- the bucket's inside lights up when the marble lands add({ id = "bucket", pos = { 900, 452 }, drawing = memo("bucket" .. tostring(state.goal), function() local c = state.goal and C.lit or "#4a4033" return drawing(48, 28, { { path = rectp(0, 0, 48, 28), fill = c .. (state.goal and "" or "55") }, { path = rectp(0, 24, 48, 4), fill = state.goal and "#fff6b0" or "#2a2118" } }) end), sensor = { rect = { 48, 28 } } }) if state.goal then add({ id = "glow", pos = { 870, 400 }, drawing = memo("glow", function() return drawing(110, 90, { { path = ellipse(55, 45, 55, 45), fill = "#ffe06622" }, { path = ellipse(55, 50, 38, 32), fill = "#ffe06633" }, { path = ellipse(55, 56, 22, 18), fill = "#fff6b055" } }) end) }) end add({ id = "goal", pos = { 906, 452 }, drawing = invisible(40, 28), sensor = { rect = { 40, 28 } } }) -- the kill zone along the ground where the castle stands: a pig that reaches it is down add({ id = "killzone", pos = { 380, FY - 18 }, drawing = invisible(240, 18), sensor = { rect = { 240, 18 } } }) if state.flash then add({ id = "flash", pos = { 0, 0 }, drawing = memo("flash", function() return drawing(W, H, { { path = rectp(0, 0, W, H), fill = "#ffffff1c" } }) end) }) end -- the sling: two posts and the stone in it (not a body: it is moved by hand with `set`) add({ id = "sling", pos = { 84, 372 }, drawing = memo("sling", function() return drawing(30, 52, { { path = rectp(10, 18, 8, 34), fill = "#5a3b22" }, { path = rectp(2, 6, 7, 20), fill = "#7a5230" }, { path = rectp(19, 6, 7, 20), fill = "#7a5230" } }) end) }) for i = 1, BAND do add({ id = "band" .. i, pos = { -40, -40 }, drawing = memo("bandpip", function() return drawing(5, 5, { { path = disc(2.5, 2.5, 2.5), fill = "#d9b07a" } }) end) }) end for i = 1, PIPS do add({ id = "arc" .. i, pos = { -40, -40 }, drawing = memo("arcpip" .. (i % 3), function() return drawing(6, 6, { { path = disc(3, 3, 3 - (i % 3) * 0.4), fill = "#ffffffcc" } }) end) }) end add({ id = "ammo", pos = { SLING[1] - 11, SLING[2] - 11 }, drawing = ball(11, "#9aa0b3", "#d9deee", "#5b6075") }) -- the castle and dominoes: loose bodies of mixed material for _, k in ipairs(blocks) do local kd = KIND[k.kind] add(loose(N(k.name), k.x, k.y, slab(k.w, k.h, kd.base, kd.hi, kd.lo, kd.edge), { rect = { k.w, k.h } }, kd.mat)) end -- pigs: scored when they fall local PIGM = { bounce = 0.35, friction = 0.02, grip = 0.5, density = 0.9 } if state.pigs.pig_roof then add(loose(N("pig_roof"), 478, 256 - 14, pig(14), { circle = 14, at = { 14, 14 } }, PIGM)) end if state.pigs.pig_mid then add(loose(N("pig_mid"), 482, 326 - 24, pig(12), { circle = 12, at = { 12, 12 } }, PIGM)) end -- the marble waiting on a post at the shelf's edge, where the hammer will strike it add({ id = "post", pos = { 788, 400 }, drawing = slab(12, 40, "#7a6a55", "#a89478", "#4a3f30"), collider = { rect = { 12, 40 } } }) add(loose(N("marble"), 784, 380, ball(10, "#6fa8ff", "#d6e6ff", "#2a4a8a"), { circle = 10, at = { 10, 10 } }, { bounce = 0.3, friction = 0.01, grip = 0.6, density = 0.5 })) -- the pendulum: a chain of small links on rope joints, ending in an iron ball local ax, ay, LINKS = 390, 24, 9 for i = 0, LINKS - 1 do add(loose(N("link" .. i), ax - 7, ay + 26 * (i + 1) - 7, ball(7, "#7d8497", "#dfe3f0", "#3d4152"), { circle = 7, at = { 7, 7 } }, { bounce = 0.1, friction = 0.05, grip = 0.1, density = 3 })) end add(loose(N("bob"), ax - 22, ay + 26 * (LINKS + 1) - 22, ball(22, "#4a4f63", "#9aa0b8", "#1f2230"), { circle = 22, at = { 22, 22 } }, { bounce = 0.2, friction = 0.03, grip = 0.3, density = 4 })) add({ id = "anchor", pos = { ax - 8, ay - 8 }, drawing = ball(8, "#d9b07a", "#fff3d0", "#7a5230") }) -- the seesaw: a plank on a pin joint, centred on a fulcrum add({ id = "fulcrum", pos = { 250, FY - 22 }, drawing = memo("fulcrum", function() return drawing(40, 22, { { path = { { "move", 20, 0 }, { "line", 40, 22 }, { "line", 0, 22 }, { "close" } }, fill = "#7a6a55" } }) end) }) add(loose(N("seesaw"), 190, FY - 22 - 12, slab(160, 10, "#c58b4e", "#e8b27a", "#8a5a2c"), { rect = { 160, 10 } }, { bounce = 0.1, friction = 0.02, grip = 0.8, density = 1 })) -- the shots fired so far for _, s in ipairs(state.shots) do add(loose(s, state[s].x - 11, state[s].y - 11, ball(11, "#9aa0b3", "#d9deee", "#5b6075"), { circle = 11, at = { 11, 11 } }, { bounce = 0.1, friction = 0.02, grip = 0.6, density = 14 })) end return outendlocal function joints() local j, LINKS = {}, 9 local function rope(id, a, b, b_at, length) j[#j + 1] = { id = id, kind = "rope", a = a, a_at = { 7, 7 }, b = b, b_at = b_at, length = length } end rope("chain0", N("link0"), nil, { 390, 24 }, 26) for i = 1, LINKS - 1 do rope("chain" .. i, N("link" .. i), N("link" .. (i - 1)), { 7, 7 }, 26) end j[#j + 1] = { id = "chainbob", kind = "rope", a = N("bob"), a_at = { 22, 22 }, b = N("link" .. (LINKS - 1)), b_at = { 7, 7 }, length = 26 } j[#j + 1] = { id = "seesaw", kind = "pin", a = N("seesaw"), a_at = { 80, 5 }, b_at = { 270, FY - 22 - 7 }, limits = { -12, 12 } } return jend-- ── interaction ───────────────────────────────────────────────────────────────────────────────local function arc_from(px, py, vx, vy) local g = state.g[2] local pts = {} for i = 1, PIPS do local t = 0.07 * i pts[i] = { px + vx * t, py + vy * t + 0.5 * g * t * t } end return ptsendlocal function show_aim(px, py) local w = world("lab") local vx, vy = (SLING[1] - px) * POWER, (SLING[2] - py) * POWER w:set("ammo", { pos = { px - 11, py - 11 } }) for i = 1, BAND do local t = i / (BAND + 1) w:set("band" .. i, { pos = { SLING[1] + (px - SLING[1]) * t - 2.5, SLING[2] + (py - SLING[2]) * t - 2.5 } }) end for i, p in ipairs(arc_from(px, py, vx, vy)) do w:set("arc" .. i, { pos = { p[1] - 3, p[2] - 3 } }) endendlocal function hide_aim() local w = world("lab") state.aim = nil w:set("ammo", { pos = { SLING[1] - 11, SLING[2] - 11 } }) for i = 1, BAND do w:set("band" .. i, { pos = { -40, -40 } }) end for i = 1, PIPS do w:set("arc" .. i, { pos = { -40, -40 } }) endendlocal function clamp_pull(x, y) local dx, dy = x - SLING[1], y - SLING[2] local d = math.sqrt(dx * dx + dy * dy) if d > PULL then dx, dy = dx / d * PULL, dy / d * PULL end return SLING[1] + dx, SLING[2] + dyendlocal function fire(px, py) local vx, vy = (SLING[1] - px) * POWER, (SLING[2] - py) * POWER state.nshot = state.nshot + 1 local id = N("shot" .. state.nshot) state[id] = { x = px, y = py } state.shots[#state.shots + 1] = id if #state.shots > 3 then table.remove(state.shots, 1) end state.pending = { id = id, vx = vx, vy = vy } state.status = "Fire!" world("lab"):after(0.03, "launch")endlocal function on_drag(e) local w = world("lab") if e.phase == "start" then state.aim, state.held = nil, nil if e.shape == "ammo" then state.aim = true elseif grabbable[e.shape] then state.held = e.shape end return end if state.aim then local px, py = clamp_pull(e.x, e.y) if e.phase == "move" then show_aim(px, py) else local dx, dy = SLING[1] - px, SLING[2] - py hide_aim() if dx * dx + dy * dy > 144 then fire(px, py) end end elseif state.held then if e.phase == "end" then w:release(state.held) state.held = nil else w:grab(state.held, { e.x, e.y }) end endendlocal function on_zone(e) if e.phase ~= "enter" then return end local who = e.who if e.id == "killzone" then for _, name in ipairs({ "pig_roof", "pig_mid" }) do if who == N(name) and state.pigs[name] then state.pigs[name] = nil state.score = state.score + 100 state.status = "Pig down! +100" end end elseif e.id == "goal" and who == N("marble") and not state.goal then state.goal = true state.score = state.score + 500 state.status = "GOAL! +500" endend-- Lua cannot read where a hit landed, so a hard one flashes the whole sky instead.local function on_hit(e) if e.speed > 450 and not state.flash then state.flash = true world("lab"):after(0.09, "unflash") endendlocal function on_timer(e) if e.name == "unflash" then state.flash = nil end if e.name == "launch" and state.pending then local p = state.pending state.pending = nil world("lab"):set(p.id, { velocity = { p.vx, p.vy } }) endendlocal function reset() gen = gen + 1 state = fresh() state.status = "Reset" world("lab"):cancel("launch")endlocal function cycle_gravity() if state.gname == "normal" then state.g, state.gname = G_MOON, "moon" elseif state.gname == "moon" then state.g, state.gname = G_FLIP, "flip" else state.g, state.gname = G_NORMAL, "normal" end state.status = "Gravity: " .. state.gnameendlocal function button(id, label, on_click) return ui.button({ id = id, px = 16, py = 8, radius = 8, fill = C.button, hover_fill = C.button_hover, press_scale = 0.97, ui.text({ label, color = C.text, font_size = 14, no_wrap = true }), on_click = on_click, })endreturn function() local lab = { id = "lab", width = W, height = H, radius = 10, gravity = state.g, wells = state.well and { { at = { 490, 330 }, radius = 200, pull = 1700 } } or {}, joints = joints(), on_drag = on_drag, on_zone = on_zone, on_timer = on_timer, on_hit = on_hit, } for _, e in ipairs(build()) do lab[#lab + 1] = e end return ui.col({ full = true, center = true, gap = 10, fill = C.bg, ui.row({ gap = 28, align_center = true, ui.text({ "SIEGE", color = C.accent, font_size = 20, no_wrap = true }), ui.text({ "score " .. state.score, id = "score", color = C.text, font_size = 16, no_wrap = true }), ui.text({ state.status, id = "status", color = C.muted, font_size = 14, no_wrap = true }), }), ui.world(lab), ui.row({ gap = 10, button("swing", "Swing", function() world("lab"):push(N("bob"), { 520, -60 }) state.status = "Wrecking ball!" end), button("bomb", "Bomb", function() world("lab"):blast({ 486, 400 }, { speed = 620, radius = 200 }) state.status = "BOOM" end), button("gravity", "Gravity: " .. state.gname, cycle_gravity), button("well", state.well and "Well: on" or "Well: off", function() state.well = not state.well state.status = state.well and "Gravity well pulls on the castle" or "Well closed" end), button("reset", "Reset", reset), }), })end
07 / VECTORS
Interactive vector graphics and data.
Diagrams, node graphs, charts and meshes are drawn natively and respond to the pointer: dragging, hovering, selecting ranges.
local C = require("theme")local V = require("voronoi")local M = require("model")local KAPPA = 0.5522847498307933local function poly_path(poly, ox, oy) local cmds = { { "move", poly[1][1] - ox, poly[1][2] - oy } } for i = 2, #poly do cmds[i] = { "line", poly[i][1] - ox, poly[i][2] - oy } end cmds[#cmds + 1] = { "close" } return gfx.path(cmds)endlocal function circle_path(cx, cy, r) local k = r * KAPPA return gfx.path({ { "move", cx + r, cy }, { "cubic", cx + r, cy + k, cx + k, cy + r, cx, cy + r }, { "cubic", cx - k, cy + r, cx - r, cy + k, cx - r, cy }, { "cubic", cx - r, cy - k, cx - k, cy - r, cx, cy - r }, { "cubic", cx + k, cy - r, cx + r, cy - k, cx + r, cy }, { "close" }, })endlocal function on_hover(e) local phase, shape, sx, sy = e.phase, e.shape, e.sx, e.sy if phase == "leave" then M.hit = nil else M.hit = shape and { shape = shape, sx = sx, sy = sy } or nil endend-- A drag past the press slop cancels the click, so selection and dragging share the frame without-- a guard between them.local function on_drag(e) local phase, x, y, dx, dy, scale, origin_x, origin_y, shape, sx, sy = e.phase, e.x, e.y, e.dx, e.dy, e.scale, e.origin_x, e.origin_y, e.shape, e.sx, e.sy if phase == "start" then M.grab(shape, sx, sy) end M.drag_to(x, y) if phase == "end" then M.drag = nil endendlocal function on_click(e) local i = M.index_of(e.shape) M.sel = M.sel ~= i and i or nilendlocal function label(text, color, size) return ui.text({ text, color = color, font_size = size, no_wrap = true })endlocal function readout(live_name) local d = M.drag if d then local s = M.sites[d.i] return string.format( "holding %s site (%.0f, %.0f) grabbed at sx %.1f sy %.1f pointer over %s", d.shape, s.x, s.y, d.sx, d.sy, live_name ) end local h = M.hit if not h then return "hover a cell" end local s = M.sites[M.index_of(h.shape)] return string.format( "%s site (%.0f, %.0f) sx %.1f sy %.1f", h.shape, s.x, s.y, h.sx, h.sy )endreturn function() local sites = M.sites local cells = V.cells(sites, C.w, C.h) -- The frame's own hit test, which is resampled every frame — so the lit cell follows a drifting -- diagram under a still pointer without a second copy of the pick math living here. local hot_kind, hot_i if M.hit then hot_kind, hot_i = M.hit.shape:match("^(%a+):(%d+)") hot_i = tonumber(hot_i) end -- Each cell is drawn in its own site's frame, so the pointer reports sx, sy as an offset from -- that site instead of repeating the frame coordinates it already gets as x, y. local items, paths, live = { width = C.w, height = C.h }, {}, 0 for i = 1, #cells do local poly = cells[i] if #poly >= 3 then live = live + 1 local s = sites[i] paths[i] = poly_path(poly, s.x, s.y) local lit = hot_kind == "cell" and hot_i == i items[#items + 1] = gfx.group({ id = "cell:" .. i, transform = { 1, 0, 0, 1, s.x, s.y }, gfx.fill({ path = paths[i], brush = gfx.solid(lit and C.cells_hot[i] or C.cells[i]), }), gfx.stroke({ path = paths[i], brush = gfx.solid(C.edge), width = C.edge_w, }), }) end end -- the halo rides an unnamed group so it paints over every cell without claiming the pointer if M.sel and paths[M.sel] then items[#items + 1] = gfx.group({ transform = { 1, 0, 0, 1, sites[M.sel].x, sites[M.sel].y }, gfx.stroke({ path = paths[M.sel], brush = gfx.solid(C.halo), width = C.halo_w, join = "round", }), }) end -- dots last: where two named shapes overlap, the later one answers the pointer. -- Each is a named group holding one dot drawn at the origin, so the pointer reports -- sx, sy as an offset from its site rather than as frame coordinates again. local rim, rim_brush = circle_path(0, 0, C.site_hit), gfx.solid(C.site_rim) local core, core_brush = circle_path(0, 0, C.site_r), gfx.solid(C.site_core) local core_lit, core_lit_brush = circle_path(0, 0, C.site_r + 1.5), gfx.solid(C.site_core_hot) for i = 1, #sites do local s = sites[i] local lit = hot_kind == "site" and hot_i == i items[#items + 1] = gfx.group({ id = "site:" .. i, transform = { 1, 0, 0, 1, s.x, s.y }, gfx.fill({ path = rim, brush = rim_brush }), gfx.fill({ path = lit and core_lit or core, brush = lit and core_lit_brush or core_brush, }), }) end local canvas = { id = "canvas", visual = gfx.frame(items), on_hover = on_hover, on_click = on_click, on_drag = on_drag, } if not M.paused then canvas.on_frame = M.step end local live_name = hot_kind and (hot_kind .. ":" .. hot_i) or "nothing" return ui.col({ id = "voronoi", grow = true, fill = C.bg, pad = 20, gap = 14, align_center = true, ui.row({ w = C.w, gap = 12, align_center = true, label("Voronoi", C.text, 16), label("every cell is clipped out of the rectangle, this frame — drag a site", C.dim, 12), ui.col({ grow = true }), label(live .. " / " .. #sites .. " cells", C.dim, 12), ui.button({ id = "pause", h = 28, px = 12, radius = 7, center = true, fill = C.accent, hover_fill = C.accent_hot, press_scale = 0.96, label(M.paused and "Resume" or "Pause", "#ffffff", 12), on_click = function() M.paused = not M.paused end, }), }), ui.frame(canvas), ui.col({ w = C.w, gap = 4, label(readout(live_name), C.text, 12), label( M.sel and ("selected cell:" .. M.sel .. " — click it again to clear") or "click to select a cell; drag a site, or anywhere in a cell, to move it", C.dim, 12 ), }), })end
local C = require("theme")local M = { sites = {}, paused = false, sel = nil, hit = nil, drag = nil,}local seeds = { { 110, 92, 1, 0.4 }, { 302, 58, -0.6, 1 }, { 524, 124, 0.3, -1 }, { 688, 74, -1, 0.5 }, { 158, 302, 0.8, -0.7 }, { 382, 248, -0.4, -1 }, { 598, 340, 1, 0.2 }, { 700, 398, -0.9, -0.6 },}for i, s in ipairs(seeds) do local len = math.sqrt(s[3] * s[3] + s[4] * s[4]) M.sites[i] = { x = s[1], y = s[2], dx = s[3] / len, dy = s[4] / len }endfunction M.step(e) local dt, elapsed = e.dt, e.elapsed local hi_x, hi_y = C.w - C.margin, C.h - C.margin local held = M.drag and M.drag.i for i = 1, #M.sites do local s = M.sites[i] if i ~= held then local turn = math.sin(elapsed * 0.27 + i * 1.7) * C.swirl * dt local c, n = math.cos(turn), math.sin(turn) s.dx, s.dy = c * s.dx - n * s.dy, n * s.dx + c * s.dy s.x = s.x + s.dx * C.speed * dt s.y = s.y + s.dy * C.speed * dt if s.x < C.margin then s.x, s.dx = C.margin, -s.dx elseif s.x > hi_x then s.x, s.dx = hi_x, -s.dx end if s.y < C.margin then s.y, s.dy = C.margin, -s.dy elseif s.y > hi_y then s.y, s.dy = hi_y, -s.dy end end endend-- Both kinds of shape are drawn in their site's own frame, so the press's `sx, sy` is the grab-- offset from that site either way. Keeping it from the press is the whole trick: the live-- `sx, sy` is measured against the transform the shape had when it was grabbed, so it drifts by-- exactly the distance dragged and cannot be used as an offset a second time.function M.grab(shape, sx, sy) local i = M.index_of(shape) M.drag = i and { i = i, shape = shape, sx = sx, sy = sy } or nilendfunction M.drag_to(x, y) local d = M.drag if not d then return end local s = M.sites[d.i] s.x = math.max(C.margin, math.min(C.w - C.margin, x - d.sx)) s.y = math.max(C.margin, math.min(C.h - C.margin, y - d.sy))endfunction M.index_of(shape) return shape and tonumber(shape:match(":(%d+)$"))endreturn M
local M = {}-- Sutherland-Hodgman against one half-plane: keep the part of `poly` at least as close to `a`-- as to `b`, i.e. where d·p <= d·m for d = b - a and m the midpoint of ab.local function clip(poly, a, b) local dx, dy = b.x - a.x, b.y - a.y local c = dx * (a.x + b.x) * 0.5 + dy * (a.y + b.y) * 0.5 local out, n = {}, #poly for i = 1, n do local p, q = poly[i], poly[i % n + 1] local dp = dx * p[1] + dy * p[2] - c local dq = dx * q[1] + dy * q[2] - c if dp <= 0 then out[#out + 1] = p end -- only a strict crossing makes a new vertex; a vertex on the line is already in `out` if (dp < 0 and dq > 0) or (dp > 0 and dq < 0) then local t = dp / (dp - dq) out[#out + 1] = { p[1] + t * (q[1] - p[1]), p[2] + t * (q[2] - p[2]) } end end return outendfunction M.cell(sites, i, w, h) local a = sites[i] local poly = { { 0, 0 }, { w, 0 }, { w, h }, { 0, h } } for j = 1, #sites do local b = sites[j] if j ~= i then if b.x == a.x and b.y == a.y then -- coincident sites have no bisector; the lower index keeps the cell so the -- cells still tile instead of two of them claiming the same ground if j < i then return {} end else poly = clip(poly, a, b) if #poly < 3 then return poly end end end end return polyendfunction M.cells(sites, w, h) local out = {} for i = 1, #sites do out[i] = M.cell(sites, i, w, h) end return outendfunction M.area(poly) local a, n = 0, #poly for i = 1, n do local p, q = poly[i], poly[i % n + 1] a = a + p[1] * q[2] - q[1] * p[2] end return a * 0.5endfunction M.nearest(sites, x, y) local best, d2 = 1, math.huge for i = 1, #sites do local dx, dy = x - sites[i].x, y - sites[i].y local d = dx * dx + dy * dy if d < d2 then best, d2 = i, d end end return bestendfunction M.contains(poly, x, y) local n = #poly for i = 1, n do local p, q = poly[i], poly[i % n + 1] if (q[1] - p[1]) * (y - p[2]) - (q[2] - p[2]) * (x - p[1]) < 0 then return false end end return trueendreturn M
The shell runs windowless on a virtual clock that only moves when asked, so layout and time-dependent behaviour are exact. Every clip here was produced by a script, not a screen recorder.
-- Physics playground: a top-down table where Rapier does all the moving. Lua describes bodies,-- hears pockets and drags, and commands velocities; nothing runs per frame. The world has no-- gravity, so "falling" is a launched velocity and pegs are fixed colliders.local C = { bg = "#12131c", felt = "#1f5c46", rim = "#4a2f1d", pocket = "#0a0b10", peg = "#e9a23b", cue = "#f4f1e8", text = "#e7e9f3", muted = "#8a8fa8", button = "#2c3048", button_hover = "#3a3f5e", aim = "#f4f1e8",}local W, H, T = 880, 500, 14 -- the table, wall thicknesslocal POCKET = 30local CUE_POS = { 150, H / 2 - 14 }-- Park-Miller, so a recording of the same clicks is the same chaos.local seed = 20261018local function rnd() seed = (seed * 16807) % 2147483647 return seed / 2147483647endlocal function disc(r, fill, stroke) local k = r * 0.5523 return { path = { {"move",0,r}, {"cubic",0,r-k,r-k,0,r,0}, {"cubic",r+k,0,2*r,r-k,2*r,r}, {"cubic",2*r,r+k,r+k,2*r,r,2*r}, {"cubic",r-k,2*r,0,r+k,0,r}, {"close"} }, fill = fill, stroke = stroke }endlocal function rect(w, h, fill) return { path = { {"move",0,0}, {"line",w,0}, {"line",w,h}, {"line",0,h}, {"close"} }, fill = fill }endlocal function drawing(w, h, shapes) return gfx.drawing({ size = { w, h }, parts = { { id = "it", pivot = { w / 2, h / 2 }, shapes = shapes } } })end-- A ball carries a light tick so its spin is visible; drawings are made once per size and colour.local balls_cache = {}local function ball_drawing(r, fill) local key = r .. fill if not balls_cache[key] then local tick = { path = { {"move",r-1.5,2}, {"line",r+1.5,2}, {"line",r+1.5,r*0.7}, {"line",r-1.5,r*0.7}, {"close"} }, fill = "#ffffff55" } balls_cache[key] = drawing(2 * r, 2 * r, { disc(r, fill, { 1.5, "#00000055" }), tick }) end return balls_cache[key]endlocal KINDS = { { r = 9, bounce = 0.98, friction = 0.08, colors = { "#ff6b6b", "#ffd166", "#ff9f43" } }, -- quick and bouncy { r = 14, bounce = 0.85, friction = 0.25, colors = { "#4dabf7", "#63e6be", "#b197fc" } }, { r = 22, bounce = 0.45, friction = 0.8, colors = { "#868e96", "#5c677d" } }, -- heavy, dies fast}local function block(id, x, y, w, h) return { id = id, pos = { x, y }, drawing = drawing(w, h, { rect(w, h, C.rim) }), collider = { rect = { w, h } } }endlocal function peg(id, cx, cy, r) return { id = id, pos = { cx - r, cy - r }, drawing = drawing(2 * r, 2 * r, { disc(r, C.peg, { 3, "#fff3" }) }), collider = { circle = r, at = { r, r } } }end-- Corner pockets: the sensor reaches past the drawn hole so a ball at the wall still counts.local function pocket(id, cx, cy) local R = POCKET return { id = id, pos = { cx - R, cy - R }, drawing = drawing(2 * R, 2 * R, { disc(R, C.pocket) }), sensor = { circle = R + 8, at = { R, R } } }endlocal balls = {} -- { id, x, y, r, bounce, friction, color }local launches = {} -- { id, vx, vy }, set once the entities existlocal serial = 0local pocketed, scratches = 0, 0local aim = nil -- { x, y } where the cue was grabbedlocal function add_ball(kind, x, y, color) serial = serial + 1 local id = "b" .. serial balls[#balls + 1] = { id = id, x = x, y = y, r = kind.r, bounce = kind.bounce, friction = kind.friction, color = color or kind.colors[1 + math.floor(rnd() * #kind.colors)] } return idendlocal function clear() balls, launches, pocketed, scratches = {}, {}, 0, 0 world("table"):cancel("launch")end-- Fifteen same-size balls in a triangle: one break shot scatters them.local function rack() clear() local kind = KINDS[2] local d = 2 * kind.r + 1 local palette = { "#ff6b6b", "#ffd166", "#4dabf7", "#63e6be", "#b197fc", "#ff9f43", "#f783ac" } for row = 0, 4 do for i = 0, row do local n = #balls add_ball(kind, 640 + row * d * 0.87, H / 2 - row * d / 2 + i * d - kind.r, palette[1 + n % #palette]) end end world("table"):set("cue", { pos = CUE_POS, velocity = { 0, 0 } })end-- A volley from the top edge, aimed down at the pegs: mixed sizes, speeds and bounce.local function burst() for i = 0, 13 do local kind = KINDS[1 + math.floor(rnd() * #KINDS)] local id = add_ball(kind, 250 + (i % 7) * 60, 40 + math.floor(i / 7) * 50) launches[#launches + 1] = { id = id, vx = (rnd() - 0.5) * 500, vy = 350 + rnd() * 450 } end world("table"):after(0.03, "launch")endlocal function button(id, label, on_click) return ui.button({ id = id, px = 18, py = 9, radius = 8, fill = C.button, hover_fill = C.button_hover, press_scale = 0.97, ui.text({ label, color = C.text, font_size = 14, no_wrap = true }), on_click = on_click, })endlocal PIPS = 6local function hide_aim() aim = nil for i = 1, PIPS do world("table"):set("pip" .. i, { pos = { -50, -50 } }) endendlocal pip = drawing(8, 8, { disc(4, C.aim) })return function() local entities = { block("wall:top", 0, 0, W, T), block("wall:bottom", 0, H - T, W, T), block("wall:left", 0, 0, T, H), block("wall:right", W - T, 0, T, H), pocket("pocket:tl", 0, 0), pocket("pocket:tr", W, 0), pocket("pocket:bl", 0, H), pocket("pocket:br", W, H), peg("peg:mid", 440, 260, 26), peg("peg:tl", 350, 200, 18), peg("peg:tr", 530, 200, 18), peg("peg:bl", 350, 320, 18), peg("peg:br", 530, 320, 18), { id = "cue", pos = CUE_POS, drawing = ball_drawing(14, C.cue), collider = { circle = 14, at = { 14, 14 } }, loose = { bounce = 0.92, friction = 0.3, grip = 0.25 } }, } for _, b in ipairs(balls) do entities[#entities + 1] = { id = b.id, pos = { b.x - b.r, b.y - b.r }, drawing = ball_drawing(b.r, b.color), collider = { circle = b.r, at = { b.r, b.r } }, loose = { bounce = b.bounce, friction = b.friction, grip = 0.2 } } end for i = 1, PIPS do entities[#entities + 1] = { id = "pip" .. i, pos = { -50, -50 }, drawing = pip } end local table_el = { id = "table", width = W, height = H, fill = C.felt, radius = 10, stroke = { 3, C.rim }, on_zone = function(e) if e.phase ~= "enter" then return end if e.who == "cue" then scratches = scratches + 1 world("table"):set("cue", { pos = CUE_POS, velocity = { 0, 0 } }) return end for i, b in ipairs(balls) do if b.id == e.who then table.remove(balls, i) pocketed = pocketed + 1 break end end end, on_timer = function(e) if e.name ~= "launch" then return end for _, l in ipairs(launches) do world("table"):set(l.id, { velocity = { l.vx, l.vy } }) end launches = {} end, -- Pull the cue back like a slingshot: dots show the line it will take. on_drag = function(e) if e.phase == "start" then aim = e.shape == "cue" and { x = e.x, y = e.y } or nil return end if not aim then return end local px, py = aim.x - e.x, aim.y - e.y if e.phase == "move" then for i = 1, PIPS do world("table"):set("pip" .. i, { pos = { aim.x + px * i / PIPS - 4, aim.y + py * i / PIPS - 4 } }) end else local len = math.sqrt(px * px + py * py) local k = len > 1400 / 6 and 1400 / len or 6 if len > 6 then world("table"):set("cue", { velocity = { px * k, py * k } }) end hide_aim() end end, } for _, en in ipairs(entities) do table_el[#table_el + 1] = en end return ui.col({ full = true, center = true, gap = 12, fill = C.bg, ui.row({ gap = 24, align_center = true, ui.text({ "Physics playground", color = C.text, font_size = 22, no_wrap = true }), ui.text({ "pocketed " .. pocketed .. " · scratches " .. scratches, id = "score", color = C.muted, font_size = 14, no_wrap = true }), }), ui.world(table_el), ui.row({ gap = 12, button("rack", "Rack", rack), button("burst", "Burst", burst), button("clear", "Clear", clear), }), })end
ALL RECORDINGS
Everything we have recorded.
Click any clip to enlarge it.
Platform
Sync, agents and the shell itself.
Physics
A 2D engine the apps drive from Lua.
Vectors and data
Interactive drawing, no webview.
STATUS
Experimental. Not for sale, not yet a product.
Nothing here is a mock-up, and nothing is packaged for download yet. The source is on GitHub; the docs describe the architecture. Tinline, from the same team, ships first.