ref: 81d1e4e01d7001e6a0107792e6167136787295d6
author: zamfofex <zamfofex@twdb.moe>
date: Tue Sep 9 07:43:34 EDT 2025
first commit (thank miyo! <3)
--- /dev/null
+++ b/.gitignore
@@ -1,0 +1,31 @@
+*
+
+!/.gitignore
+!/readme.md
+!/index.html
+!/about.html
+!/icon.svg
+!/hash.js
+
+!/assets
+!/assets/costume1.png
+!/assets/costume2.png
+
+!/areas.js
+!/compile.js
+!/core.js
+!/external.js
+!/index.js
+!/md5.js
+!/model.js
+!/music.js
+!/paint.js
+!/pen.js
+!/scratch.js
+!/stage.js
+!/syntax.js
+!/translation.js
+!/translations.js
+!/view.js
+!/zip.js
+!/worklet.js
--- /dev/null
+++ b/about.html
@@ -1,0 +1,129 @@
+<!doctype html>
+<html lang="en">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<link rel="icon" href="icon.svg">
+<title> Scrax / Let’s Learn Programming! </title>
+
+<style>
+
+::selection {
+ background: #D9F6;
+}
+
+body {
+ font-family: "DejaVu Sans", "DejaVu LGC Sans", "Verdana", "Bitstream Vera Sans", "Geneva", sans-serif;
+ background: #FAFCFF;
+ color: #000A;
+ margin: 4em 2em;
+}
+
+header {
+ text-align: center;
+ margin-bottom: 2em;
+}
+
+.logo {
+ pointer-events: none;
+ margin: 2em 0;
+ transition: 0.25s ease-in-out filter;
+ filter: drop-shadow(3px 2px 2px #0002);
+ font-size: 1em;
+}
+
+.logo:hover {
+ filter: drop-shadow(12px 8px 8px #0001);
+}
+
+.logo svg {
+ max-height: 6em;
+ pointer-events: auto;
+ transition: 0.25s ease-in-out transform;
+}
+
+.logo:hover svg {
+ transform: scale(1.125) rotate(-5deg);
+}
+
+header > p:first-of-type {
+ font-size: 1.5em;
+ font-style: italic;
+}
+
+header > p {
+ max-width: 32em;
+ margin: 1em auto;
+}
+
+footer {
+ margin: 8em 0 0;
+ text-align: center;
+ font-size: 0.75em;
+ color: #0008;
+}
+
+a {
+ color: #08E;
+}
+
+section {
+ text-align: center;
+}
+
+ul {
+ padding: 0;
+}
+
+li {
+ display: block;
+ margin: 0.5em 0;
+}
+
+section > p:first-of-type {
+ font-size: 1.5em;
+}
+
+</style>
+
+<header>
+ <h1 class="logo">
+ <svg viewbox="0.75 0.5 44.5 17.75" fill="none" stroke-linejoin="round" stroke-linecap="round">
+ <title> Scrax Logo </title>
+ <g id="scrax">
+ <path id="s" d="M9 4v4s0-3-3-3c-2 0-3 5 1 5 3 0 3 5 0 5s-3-4-3-4v4" />
+ <path id="c" d="M16 6v3s0-2-2-2-3 1-3 4c0 1 1 3 3 3 3 0 3-3 3-3" />
+ <path id="r" d="M27 14c-5 2 0-6-8-5m-1 6 4-1m-3-9 1 4v5m-2-9c8-1 8 4 1 4" />
+ <path id="a" d="m32 13 3-1m-6-2 3-1m-6 3 4 1m-2-1c1-3 1-6 2-8m-3 1c4-2 3-3 6 7" />
+ <path id="x" d="m39 5 3 1m-6 8 4-8m-5 8h3m2 0 2-1m-6-6c1 1 4 5 5 6m-7-6 3-1" />
+ </g>
+ <use href="#scrax" stroke="#444" stroke-width="6.25" />
+ <use href="#scrax" stroke="#FFF" stroke-width="6" />
+ <use href="#scrax" stroke="#444" stroke-width="1.75" filter="drop-shadow(0.25px 0.5px 0.5px #4444)" />
+ <g stroke-width="1.5">
+ <use href="#s" stroke="#9DF" />
+ <use href="#c" stroke="#F9D" />
+ <use href="#r" stroke="#9DF" />
+ <use href="#a" stroke="#F9D" />
+ <use href="#x" stroke="#D9F" />
+ </g>
+ </svg>
+ </h1>
+ <p> making programming intuitive, silly, and super fun! </p>
+ <p> Scrax is an implementation of <a href="https://scratch.mit.edu/about">Scratch</a>, which is a programming language created to teach programming intuitively and visually. </p>
+ <p> Because it’s such a simple programming language to learn, Scratch has been an inspiration to many people since its creation. People have made some very cool and impressive projects with it! </p>
+ <p> Scrax is a dedication to Scratch and its community for what they have been capable of accomplishing! </p>
+ <p> <strong>Scrax is currently a “work in progress”! Expect very rough edges.</strong> </p>
+</header>
+<section>
+ <p> ready to discover the joy of programming? <br> <a href=".">Create a New Project!</a> </p>
+ <p> or check out some demos: </p>
+ <ul>
+ <li> <a href=".?SpaceChess.sb3">Space Chess</a> </li>
+ <li> <a href=".?https://raw.githubusercontent.com/DroneBetter/Perspective3Dengine/main/Perspective 3D engine.sb3">Perspective 3D Engine</a> </li>
+ <li> <a href=".?1143869507">Conway’s Game of Life</a> </li>
+ <li> <a href=".?1158076652">Golf Islands</a> </li>
+ <li> <a href=".?396320314">Mandelbrot Set</a> </li>
+ <li> <a href=".?1078807447">Operator</a> </li>
+ </ul>
+</section>
+<footer> Scrax is made by zamfofex with passion and a fair bit of sillyheartedness </footer>
--- /dev/null
+++ b/areas.js
@@ -1,0 +1,1414 @@
+import {flatten, append, after, prepend, removeInput, setInput, save, load, compare, replace, duplicate, complement, splice} from "./model.js"
+import {Forever, Else, WaitUntil, RepeatUntil, Define, If, compareName, Custom, TextParameter, BooleanParameter} from "./core.js"
+import {toElement, query, closestBlockID, closestParameterIndex, closestSlotID, closestNamePartIndex} from "./view.js"
+import {toSyntax} from "./syntax.js"
+
+let heldElement = document.createElement("div")
+heldElement.classList.add("held")
+document.body.append(heldElement)
+
+let held
+
+function updateHeld(event)
+{
+ heldElement.style.setProperty("left", `${event.x + held.x}px`)
+ heldElement.style.setProperty("top", `${event.y + held.y}px`)
+}
+
+addEventListener("pointerup", () =>
+{
+ if (!held) return
+ if (held.canDettach && !held.canDettach?.(held.blocks[0])) held.reattach?.()
+ else held.onDettached?.(held.blocks[0])
+ heldElement.textContent = ""
+ held = undefined
+})
+
+addEventListener("pointermove", event =>
+{
+ if (!held) return
+ updateHeld(event)
+})
+
+export class Stoppable {
+
+ stoppable = this
+ #stoppables = []
+ #handlers = []
+
+ stop()
+ {
+ for (let [event, fn] of this.#handlers) removeEventListener(event, fn)
+ for (let stoppable of this.#stoppables) stoppable.stop()
+ }
+
+ addEventListener(event, fn)
+ {
+ this.#handlers.push([event, fn])
+ addEventListener(event, fn)
+ }
+
+ onStopped({stoppable})
+ {
+ if (!stoppable) return
+ this.#stoppables.push(stoppable)
+ }
+}
+
+class Attachable {
+
+ stoppable = new Stoppable()
+ #options
+ #ghostElement
+ #lastGhost
+
+ constructor(options)
+ {
+ this.#options = options
+
+ this.stoppable.addEventListener("pointerup", () =>
+ {
+ this.#dettachGhost()
+ this.#ghostElement = undefined
+ })
+
+ this.stoppable.addEventListener("pointermove", event =>
+ {
+ if (!held) return
+ if (this.#options.frozen?.()) return
+ if (!event.target.isConnected) return
+ if (!this.#options.element.contains(event.target)) {
+ this.#dettachGhost()
+ return
+ }
+ this.#attach(held.blocks[0], event, true)
+ })
+
+ this.#options.element.addEventListener("pointerup", event =>
+ {
+ if (!held) return
+ if (event.button !== 0) return
+ if (held.canDettach && !held.canDettach(held.blocks[0], this.#options.id)) {
+ held.reattach?.()
+ heldElement.textContent = ""
+ held = undefined
+ return
+ }
+ if (this.#options.canAttach && !this.#options.canAttach(held.blocks[0])) {
+ held.reattach?.()
+ heldElement.textContent = ""
+ held = undefined
+ return
+ }
+ held.onDettached?.(held.blocks[0], this.#options.id)
+ this.#attach(held.blocks[0], event)
+ this.#options.onAttached?.(held.blocks[0], held.id)
+ heldElement.textContent = ""
+ held = undefined
+ }, {capture: true})
+ }
+
+ #attach(held, event, ghost)
+ {
+ if (this.#options.frozen?.()) return
+
+ if (!this.#ghostElement) {
+ this.#ghostElement = this.#options.toElement(held)
+ for (let element of this.#ghostElement.querySelectorAll(".block")) {
+ element.classList.add("ghost")
+ }
+ }
+
+ let choices = []
+
+ if (this.#options.isTop(held)) {
+ choices.push({type: "top", y: this.#options.element.getBoundingClientRect().top})
+ for (let block of this.#options.blocks) {
+ choices.push({
+ type: "top",
+ block,
+ y: query(block.id, this.#options.element).closest(".script").getBoundingClientRect().bottom + 16,
+ })
+ }
+ }
+
+ let isHat = held.type.hat
+
+ if (held.type.shape === undefined) {
+
+ let cap = (held.parent ? held.parent.stack.at(-1) : held).type.cap && held.stack?.length !== 0
+
+ for (let top of this.#options.blocks) {
+
+ if (top.type.shape === undefined && !top.type.hat) {
+ if (!isHat || held.stack.length === 0 || !held.stack.at(-1).type.cap) {
+ let element = query(top.id, this.#options.element)
+ choices.push({block: top.parent, y: element.getBoundingClientRect().top, type: "inside"})
+ }
+ }
+
+ if (isHat) continue
+
+ for (let block of (top.parent?.stack ?? [top]).flatMap(flatten)) {
+
+ if (block.type.shape !== undefined) continue
+
+ let element = query(block.id, this.#options.element)
+ if (block.stack && (!cap || block.stack.length === 0)) {
+ let element = query(block.id, this.#options.element)
+ if (!element.matches(".line")) element = element.querySelector(".line")
+ choices.push({block, y: element.getBoundingClientRect().bottom, type: "inside"})
+ }
+
+ if (block.type.cap || block.type.hat) continue
+ if (cap && block !== block.parent.stack.at(-1)) continue
+
+ choices.push({block, y: element.getBoundingClientRect().bottom, type: "after"})
+ }
+ }
+ }
+
+ if (held.type.shape === "reporter") {
+
+ for (let top of this.#options.blocks) {
+
+ let element = query(top.id, this.#options.element).closest(".script")
+ if (!element.contains(event.target)) continue
+
+ for (let block of flatten(top.parent ?? top)) {
+
+ if (block.type.shape !== "slot") continue
+ if (block.type.canFit && !block.type.canFit(held)) continue
+
+ let element = query(block.id, this.#options.element)
+ if (!element) continue
+ let rect = element.getBoundingClientRect()
+ let x = rect.left + rect.width / 2
+ let y = rect.top + rect.height / 2
+ choices.push({x, y, block, type: "reporter"})
+ }
+ }
+ }
+
+ if (choices.length === 0) {
+ this.#dettachGhost()
+ return
+ }
+
+ let best = choices[0]
+ for (let choice of choices) {
+ choice.d = (choice.y - event.y) ** 2 + ((choice.x ?? event.x) - event.x) ** 2
+ if (choice.d < best.d) best = choice
+ }
+
+ let {block, type} = best
+
+ if (ghost && this.#lastGhost && this.#lastGhost.type === type && this.#lastGhost.block === block) return
+ this.#lastGhost = best
+ this.#dettachGhost()
+
+ if (type === "reporter") {
+ if (ghost) {
+ query(block.id).classList.add("ghost")
+ }
+ else {
+ setInput(block.parent, block.parent.inputs.indexOf(block), held)
+ query(block.id, this.#options.element).replaceWith(...this.#options.toElement(held).children)
+ }
+ return
+ }
+
+ if (type === "top") {
+ let i = this.#options.blocks.indexOf(block)
+ if (i === -1) this.#options.element.prepend(this.#ghostElement)
+ else query(this.#options.blocks[i].id, this.#options.element).closest(".script").after(this.#ghostElement)
+ if (!ghost) {
+ this.#options.blocks.splice(i + 1, 0, held)
+ for (let ghost of this.#options.element.querySelectorAll(".ghost")) ghost.classList.remove("ghost")
+ this.#lastGhost = undefined
+ }
+ return
+ }
+
+ let mouth
+ let element = query(block.id, this.#options.element)
+ if (block.parent?.type.complement === block.type) {
+ let block0 = block
+ while (block0.parent.type.complement === block0.type) block0 = block0.parent
+ let element = query(block0.id, this.#options.element)
+ mouth = element.querySelector(`.line[data-id="${block.id}"] + .mouth`)
+ }
+ else {
+ if (element) {
+ mouth = element.querySelector(".mouth")
+ }
+ else {
+ element = query(block.stack[0].id, this.#options.element)
+ mouth = element.closest(".script")
+ }
+ }
+
+ if (held.stack?.length === 0 && !isHat) {
+ let mouth1 = this.#ghostElement.querySelector(".mouth")
+ let elements
+ if (type === "after" || block.type.hat) {
+ elements = this.#options.element.querySelectorAll(`[data-id="${block.id}"] ~ *`)
+ element.after(...this.#ghostElement.children)
+ }
+ else {
+ elements = [...mouth.children]
+ mouth.prepend(...this.#ghostElement.children)
+ }
+ mouth1.append(...elements)
+ }
+ else {
+ if (type === "inside" && !block.type.hat) {
+ mouth.prepend(...this.#ghostElement.children)
+ }
+ else {
+ element.after(...this.#ghostElement.children)
+ }
+ }
+
+ if (ghost) return
+
+ if (held.stack?.length === 0 || isHat) {
+ let index = 0
+ if (type === "after") {
+ index = block.parent.stack.indexOf(block) + 1
+ block = block.parent
+ }
+ if (block.stack[index]) append(held, ...splice(block.stack[index]))
+ append(block, ...splice(held))
+ }
+ else {
+ if (type === "inside") prepend(block, ...splice(held))
+ else after(block, ...splice(held))
+ }
+
+ for (let [i, block] of this.#options.blocks.entries()) {
+ while (block?.parent) block = block.parent
+ if (!block.type.hat) block = block?.stack?.[0] ?? block
+ this.#options.blocks[i] = block
+ }
+ for (let ghost of this.#options.element.querySelectorAll(".ghost")) ghost.classList.remove("ghost")
+ this.#lastGhost = undefined
+ }
+
+ #dettachGhost()
+ {
+ this.#lastGhost = undefined
+ this.#options.element.querySelector(".ghost:not(.block)")?.classList.remove("ghost")
+ let ghost = this.#options.element.querySelector(".block.ghost")
+ if (!ghost) return
+ if (this.#ghostElement.isConnected) {
+ this.#ghostElement.remove()
+ return
+ }
+ ghost.replaceWith(...ghost.querySelectorAll(".ghost > .mouth > :not(.ghost)"))
+ this.#ghostElement.prepend(ghost)
+ while (true) {
+ let ghost = this.#options.element.querySelector(".block.ghost")
+ if (!ghost) return true
+ this.#ghostElement.append(ghost)
+ }
+ }
+}
+
+class Dettachable {
+
+ stoppable = new Stoppable()
+ #options
+
+ constructor(options)
+ {
+ this.#options = options
+
+ let resolve
+ this.stoppable.addEventListener("pointermove", () => { resolve?.() ; resolve = undefined })
+ this.stoppable.addEventListener("pointerup", () => resolve = undefined)
+
+ this.stoppable.addEventListener("pointerdown", async event =>
+ {
+ if (!this.#options.element.contains(event.target)) return
+ if (event.button !== 0) return
+ if (held) return
+ event.target.releasePointerCapture(event.pointerId)
+
+ await new Promise(resolve1 => resolve = resolve1)
+
+ let reattach
+ if (this.#options.save && this.#options.load) {
+ let structure = this.#options.save()
+ reattach = () => this.#options.load(structure)
+ }
+
+ let info = this.#dettach(event, event.ctrlKey || event.metaKey, event.shiftKey)
+ if (!info) return
+
+ held = {
+ x: info.rect.x - event.x,
+ y: info.rect.y - event.y,
+ blocks: info.block.parent?.stack?.slice() ?? [info.block],
+ canDettach: this.#options.canDettach,
+ reattach,
+ onDettached: this.#options.onDettached,
+ id: this.#options.id,
+ }
+
+ heldElement.append(toElement(held.blocks[0], {getName: this.#options.getName}))
+ updateHeld(event)
+
+ this.#options.element.dispatchEvent(new PointerEvent("pointermove", {clientX: event.x, clientY: event.y, bubbles: true}))
+ })
+ }
+
+ #dettach(event, single, duplicated)
+ {
+ if (this.#options.frozen?.()) return
+
+ let every = this.#options.blocks.flatMap(block => block.parent?.stack ?? [block]).flatMap(flatten)
+
+ let parameterInfo = closestParameterIndex(event.target)
+ if (parameterInfo) {
+ let {index, id, element} = parameterInfo
+ let block = every.find(block => block.id === id)
+ let {name, type} = block.names[0].filter(part => typeof part !== "string")[index]
+ return {block: type === "boolean" ? BooleanParameter(name) : TextParameter(name), rect: element.getBoundingClientRect()}
+ }
+
+ let id = closestBlockID(event.target)
+ if (!id) return
+
+ let block = every.find(block => block.id === id)
+ if (!block) return
+
+ let element1 = query(block.id, this.#options.element)
+ let result = {block, rect: element1.getBoundingClientRect()}
+ if (duplicated) {
+ let blocks = single ? [block] : block.parent?.stack?.slice(block.parent.stack.indexOf(block))
+ blocks = blocks.map(duplicate)
+ if (block.type.shape === undefined) Forever(...blocks)
+ result.block = blocks[0]
+ return result
+ }
+
+ let index = this.#options.blocks.indexOf(block)
+
+ if (block.type.shape === "reporter" && index < 0) {
+ let parent = block.parent
+ removeInput(block)
+ let result = {block, rect: query(block.id, this.#options.element).getBoundingClientRect()}
+ query(parent.id, this.#options.elements).replaceWith(this.#options.toElement(parent).children[0])
+ return result
+ }
+
+ let stack = block.parent?.stack
+ let blocks = !stack || single ? [block] : block.parent.stack.slice(block.parent.stack.indexOf(block))
+ Forever(...blocks)
+ if (index >= 0) {
+ this.#options.blocks.splice(index, 1, ...stack?.[0] ? [stack[0]] : [])
+ if (!stack?.[0]) element1.closest(".script").remove()
+ }
+ blocks.forEach(block => query(block.id, this.#options.element)?.remove())
+
+ return result
+ }
+}
+
+export class Scrollable {
+
+ stoppable = new Stoppable()
+
+ constructor(options = {})
+ {
+ let scrolling = false
+
+ this.stoppable.addEventListener("pointerup", () => scrolling = false)
+ options.element.addEventListener("pointerdown", event =>
+ {
+ if (event.pointerType !== "mouse") return
+ if (event.button !== 0) return
+ if (held) return
+ if (event.target.closest(".block")) return
+ scrolling = true
+ })
+
+ this.stoppable.addEventListener("pointermove", event =>
+ {
+ if (!scrolling) return
+ event.preventDefault()
+ let x = -event.movementX
+ let y = -event.movementY
+ if (!(options.x ?? true)) x = 0
+ if (!(options.y ?? true)) y = 0
+ options.element.scrollBy({left: x, top: y, behavior: "instant"})
+ })
+ }
+}
+
+export class History {
+
+ stoppable = new Stoppable()
+ #index = 0
+ #history = []
+ #loading = false
+ #areas
+
+ constructor(areas)
+ {
+ this.#areas = areas
+ this.#history.push(this.#save())
+
+ for (let area of this.#areas) area.onUpdate(() => this.save())
+
+ this.stoppable.addEventListener("keydown", event =>
+ {
+ if (!this.#areas.every(area => area.element.isConnected)) return
+ if (event.altKey) return
+ if (!event.ctrlKey && !event.metaKey) return
+ if (closestSlotID(event.target)) return
+
+ if (event.code === "KeyZ") {
+ event.preventDefault()
+ if (event.shiftKey) this.redo()
+ else this.undo()
+ }
+ if (event.code === "keyY") {
+ if (event.shiftKey) return
+ event.preventDefault()
+ this.redo()
+ }
+ })
+ }
+
+ redo()
+ {
+ if (this.#index >= this.#history.length - 1) return
+ this.#index++
+ this.#loading = true
+ for (let [area, structure] of this.#history[this.#index]) area.load(structure)
+ this.#loading = false
+ }
+
+ undo()
+ {
+ if (this.#index <= 0) return
+ this.#index--
+ this.#loading = true
+ for (let [area, structure] of this.#history[this.#index]) area.load(structure)
+ this.#loading = false
+ }
+
+ save()
+ {
+ if (this.#loading) return
+ this.#history.length = this.#index + 1
+ if ([...this.#history[this.#index]].every(([area, structure]) => area.compare?.(structure))) return
+ this.#history.push(this.#save())
+ while (this.#history.length > 128) this.#history.shift()
+ this.#index = this.#history.length - 1
+ }
+
+ #save()
+ {
+ let structure = new Map()
+ for (let area of this.#areas) structure.set(area, area.save())
+ return structure
+ }
+}
+
+class LibraryCategoryArea {
+
+ element = document.createElement("div")
+ stoppable = new Stoppable()
+ #blocks = []
+ #options
+
+ constructor(blocks, options = {})
+ {
+ this.#blocks = blocks
+ this.#options = options
+
+ this.element.classList.add("area", "library-category")
+ this.element.append(...this.#options.extra ?? [], ...this.#blocks.map(block => this.#toElement(block)))
+
+ let resolve
+ this.stoppable.addEventListener("pointermove", () => { resolve?.() ; resolve = undefined })
+ this.stoppable.addEventListener("pointerup", () => resolve = undefined)
+
+ this.stoppable.addEventListener("pointerdown", async event =>
+ {
+ if (!this.element.contains(event.target)) return
+ if (event.button !== 0) return
+ if (held) return
+ event.target.releasePointerCapture(event.pointerId)
+
+ await new Promise(resolve1 => resolve = resolve1)
+
+ let id = closestBlockID(event.target)
+ let i = this.#blocks.findIndex(block => block.id === id)
+ if (i < 0) return
+
+ let rect = query(id, this.element).getBoundingClientRect()
+ let block = duplicate(this.#blocks[i])
+ if (block.type.shape === undefined && !block.type.hat) Forever(block)
+ held = {x: rect.x - event.x, y: rect.y - event.y, blocks: block.parent?.stack?.slice() ?? [block]}
+ heldElement.append(toElement(held.blocks[0], {getName: this.#options.getName}))
+ updateHeld(event)
+ })
+
+ this.stoppable.onStopped(new ContextMenu({
+ element: this.element,
+ getBlock: id => this.#blocks.find(block => block.id === id),
+ getName: this.#options.getName,
+ getItems: this.#options.getItems,
+ messages: this.#options.messages,
+ }))
+ }
+
+ #toElement(block)
+ {
+ let self = this
+
+ function replace1(block0, block1, update)
+ {
+ let index = self.#blocks.indexOf(block0)
+ if (index < 0) replace(block0, block1)
+ else self.#blocks[index] = block1
+ if (update) query(block0.id, self.element).replaceWith(self.#toElement(block1).children[0])
+ }
+
+ return toElement(block, {
+ getName: this.#options.getName,
+ replace: replace1,
+ variables: this.#options.variables,
+ lists: this.#options.lists,
+ addValue: this.#options.addValue,
+ getAddMessage: this.#options.getAddMessage,
+ })
+ }
+
+ getBlock(id)
+ {
+ return this.#blocks.find(block => block.id === id)
+ }
+}
+
+export class LibraryArea {
+
+ element = document.createElement("div")
+ #areas = new Map()
+ #options
+ #categorise
+ #blocks
+
+ constructor(blocks, categorise, options = {})
+ {
+ this.#options = options
+ this.#blocks = blocks
+ this.#categorise = categorise
+ this.element.classList.add("area", "library")
+ this.update()
+ }
+
+ update()
+ {
+ let scroll = this.element.scrollTop
+
+ this.element.textContent = ""
+
+ let categories = new Map()
+
+ for (let block of this.#blocks) {
+ let which = this.#categorise(block)
+ if (!categories.has(which)) categories.set(which, [])
+ categories.get(which).push(block)
+ }
+
+ for (let category of this.#options.extra?.keys() ?? []) {
+ if (!categories.has(category)) categories.set(category, [])
+ }
+
+ if (this.#options.order) {
+ let categories1 = []
+ for (let category of this.#options.order) {
+ if (!categories.has(category)) continue
+ categories1.push([category, categories.get(category)])
+ categories.delete(category)
+ }
+ categories1.push(...categories)
+ categories = categories1
+ }
+
+ for (let [category, area] of [...this.#areas]) {
+ this.#areas.delete(category)
+ area.stoppable.stop()
+ }
+
+ for (let [category, blocks] of categories) {
+
+ let categoryElement = document.createElement("div")
+ categoryElement.classList.add("category")
+ this.element.append(categoryElement)
+
+ let nameElement = document.createElement("div")
+ nameElement.classList.add("category-name")
+ nameElement.append(this.#options.toName?.(category) ?? category)
+ nameElement.classList.add("category-name-" + category)
+
+ let area = new LibraryCategoryArea(blocks, {
+ extra: this.#options.extra?.get(category) ?? [],
+ getName: this.#options.getName,
+ getItems: this.#options.getItems,
+ variables: this.#options.variables,
+ lists: this.#options.lists,
+ messages: this.#options.messages,
+ addValue: this.#options.addValue,
+ getAddMessage: this.#options.getAddMessage,
+ })
+ categoryElement.append(nameElement, area.element)
+ this.#areas.set(category, area)
+ }
+
+ this.element.scrollTo({top: scroll, behavior: "instant"})
+ }
+
+ stop()
+ {
+ for (let area of this.#areas.values()) area.stoppable.stop()
+ }
+
+ getBlock(id)
+ {
+ return [...this.#areas.values()].map(area => area.getBlock(id)).find(Boolean)
+ }
+}
+
+export class ProgramArea {
+
+ element = document.createElement("div")
+ stoppable = new Stoppable()
+ #options
+ #blocks
+ #updateListeners = []
+ #pasteListeners = []
+ #id = Symbol()
+
+ constructor(blocks, options = {})
+ {
+ this.#options = options
+ this.#blocks = blocks
+ this.element.classList.add("area", "program")
+
+ this.stoppable.onStopped(new Dettachable({
+ id: this.#id,
+ element: this.element,
+ blocks: this.#blocks,
+ toElement: block => this.#toElement(block),
+ canDettach: (block, id) => this.#canDettach(block, id),
+ onDettached: (block, id) =>
+ {
+ if (id !== this.#id) this.#options.onDettached?.(block)
+ this.#updateListeners.forEach(fn => fn())
+ },
+ save: () => this.save(),
+ load: structure => this.load(structure),
+ getName: this.#options.getName,
+ }))
+
+ this.stoppable.onStopped(new Attachable({
+ id: this.#id,
+ element: this.element,
+ blocks: this.#blocks,
+ toElement: block => this.#toElement(block),
+ canAttach: block => this.#canAttach(block),
+ isTop: () => true,
+ onAttached: (block, id) =>
+ {
+ if (id !== this.#id) this.#options.onAttached?.(block)
+ this.#updateListeners.forEach(fn => fn())
+ },
+ getName: this.#options.getName,
+ }))
+
+ this.stoppable.onStopped(new ContextMenu({
+ element: this.element,
+ getBlock: id => this.#blocks.flatMap(block => flatten(block.parent ?? block)).find(block => block.id === id),
+ blocks: this.#blocks,
+ fromSyntax: this.#options.fromSyntax,
+ update: () =>
+ {
+ this.update()
+ for (let fn of this.#pasteListeners) fn()
+ },
+ getItems: this.#options.getItems,
+ getName: this.#options.getName,
+ messages: this.#options.messages,
+ }))
+
+ this.update()
+ }
+
+ update()
+ {
+ let scroll = this.element.scrollTop
+ this.element.textContent = ""
+ for (let block of this.#blocks) this.element.append(this.#toElement(block))
+ this.element.scrollTo({top: scroll, behavior: "instant"})
+ for (let fn of this.#updateListeners) fn()
+ }
+
+ onUpdate(fn)
+ {
+ this.#updateListeners.push(fn)
+ }
+
+ onPaste(fn)
+ {
+ this.#pasteListeners.push(fn)
+ }
+
+ save()
+ {
+ let structure = save(this.#blocks)
+ return {structure, blocks: this.#blocks.slice()}
+ }
+
+ load({structure, blocks})
+ {
+ load(structure)
+ this.#blocks.length = 0
+ this.#blocks.push(...blocks)
+ this.update()
+ }
+
+ compare({structure, blocks})
+ {
+ let other = this.save()
+ if (!compare(structure, other.structure)) return false
+ if (blocks.length !== other.blocks.length) return false
+ if (!other.blocks.every((block, i) => block === blocks[i])) return false
+ return true
+ }
+
+ #toElement(block)
+ {
+ let replace1
+ let frozen = this.#options.frozen?.()
+
+ if (!frozen) {
+ replace1 = (block0, block1, update) =>
+ {
+ let index = this.#blocks.indexOf(block0)
+ if (index < 0) replace(block0, block1)
+ else this.#blocks[index] = block1
+ if (update) query(block0.id, this.element).replaceWith(this.#toElement(block1).children[0])
+ }
+ }
+
+ return toElement(block, {
+ frozen,
+ replace: replace1,
+ variables: this.#options.variables,
+ lists: this.#options.lists,
+ getName: this.#options.getName,
+ addValue: this.#options.addValue,
+ getAddMessage: this.#options.getAddMessage,
+ })
+ }
+
+ #canDettach(block, id)
+ {
+ if (id === this.#id) return true
+ if (block.type !== Define.type) return true
+ for (let other of this.#blocks.flatMap(flatten)) {
+ if (other.type.category !== "custom") continue
+ if (other === block) continue
+ if (compareName(other.names[0], block.names[0])) return false
+ }
+ return true
+ }
+
+ #canAttach(block)
+ {
+ if (block.type === Define.type) {
+ for (let other of this.#blocks.flatMap(flatten)) {
+ if (other.type !== Define.type) continue
+ if (compareName(other.names[0], block.names[0])) continue
+ return
+ }
+ }
+ return true
+ }
+}
+
+class ContextMenu {
+
+ #options
+
+ constructor(options = {})
+ {
+ this.#options = options
+ this.#options.element.addEventListener("contextmenu", event => this.#handle(event))
+ }
+
+ #handle(event)
+ {
+ event.preventDefault()
+ if (held) return
+
+ let id = closestBlockID(event.target)
+ let blockElement = id && query(id, this.#options.element)
+ let block = id && this.#options.getBlock(id)
+
+ let items = []
+
+ if (block && this.#options.update && block.type.shape === undefined && block.type.category === "custom") {
+ items.push({
+ label: "edit block",
+ run: async () =>
+ {
+ let define = this.#options.blocks.find(other => other.type === Define.type && compareName(other.names[0], block.names[0]))
+ let renamed = await addBlock({
+ name: structuredClone(define.value),
+ atomic: define.atomic,
+ scripts: this.#options.blocks,
+ block: define,
+ getName: this.#options.getName,
+ messages: this.#options.messages,
+ })
+ if (!renamed) return
+ let usages = this.#options.blocks.flatMap(flatten).filter(other => other.type.shape === undefined && other.type.categories === "custom" && compareName(other.names[0], block.names[0]))
+ define.value = renamed.value
+ define.atomic = renamed.atomic
+ for (let block of usages) {
+ let other = Custom(define.names[0], ...block.inputs)
+ replace(block, other)
+ let index = this.#options.blocks.indexOf(block)
+ if (index >= 0) this.#options.blocks[index] = other
+ }
+ this.#options.update()
+ },
+ })
+ }
+
+ if (block) {
+ items.push({
+ label: "duplicate",
+ run: () =>
+ {
+ let index = block.parent?.stack?.indexOf(block) ?? -1
+ let blocks = index < 0 ? [block] : block.parent.stack.slice(index)
+ blocks = blocks.map(duplicate)
+ if (block.type.shape === undefined) Forever(...blocks)
+ let rect = blockElement.getBoundingClientRect()
+ held = {x: rect.x - event.x, y: rect.y - event.y, blocks}
+ heldElement.append(toElement(blocks[0], {getName: this.#options.getName}))
+ updateHeld(event)
+ }
+ }, {
+ label: block.type.shape === undefined ? "copy stack" : "copy block",
+ run: () => navigator.clipboard.writeText(toSyntax(block, {getName: this.#options.getName})),
+ })
+ }
+
+ if (this.#options.blocks) {
+ items.push({
+ label: "copy all",
+ run: () => navigator.clipboard.writeText(this.#options.blocks.map(block => toSyntax(block, {getName: this.#options.getName})).join("\n\n")),
+ disabled: this.#options.blocks.length === 0,
+ })
+ }
+
+ if (this.#options.update && !block) {
+ items.push({
+ label: "paste",
+ run: async () =>
+ {
+ await this.#fromClipboard()
+ this.#options.update()
+ },
+ })
+ }
+
+ if (this.#options.update && block) {
+
+ items.push({
+ label: "paste",
+ run: async () =>
+ {
+ await this.#fromClipboard(block)
+ this.#options.update()
+ },
+ })
+
+ if (block.type === If.type) {
+ if (!block.complement) {
+ items.push({
+ label: `add "else"`,
+ run: () =>
+ {
+ complement(block, Else())
+ this.#options.update()
+ },
+ })
+ }
+ else {
+ items.push({
+ label: `remove "else"`,
+ run: () =>
+ {
+ block.complement = undefined
+ this.#options.update()
+ },
+ disabled: block.complement.stack.length > 0,
+ })
+ }
+ }
+
+ if (block.type === "until") {
+ items.push({
+ label: `change to "wait until"`,
+ run: () => this.#replace(block, WaitUntil(block.inputs[0])),
+ disabled: block.stack.length > 0,
+ })
+ }
+
+ if (block.type === "wait-until") {
+ items.push({
+ label: `change to "repeat until"`,
+ run: () => this.#replace(block, RepeatUntil(block.inputs[0])),
+ })
+ }
+ }
+
+ items.push(...this.#options.getItems?.(block) ?? [])
+ if (items.length === 0) return
+
+ let element = document.createElement("div")
+ element.style.setProperty("top", `${event.y}px`)
+ element.style.setProperty("left", `${event.x}px`)
+ element.classList.add("ctx-menu")
+ element.addEventListener("focusout", event => element.contains(event.relatedTarget) || element.remove())
+ element.addEventListener("click", () => element.remove())
+ element.addEventListener("contextmenu", event => event.preventDefault())
+ document.body.append(element)
+
+ for (let item of items) {
+ let button = document.createElement("button")
+ element.append(button)
+ button.append(item.label)
+ button.addEventListener("click", () => item.run())
+ if (item.disabled) button.disabled = true
+ }
+
+ let button = element.querySelector("button:not(:disabled)")
+ if (button) button.focus()
+ else element.remove()
+ }
+
+ async #fromClipboard(block)
+ {
+ let blocks = this.#options.fromSyntax(await navigator.clipboard.readText().catch(() => ""))
+ let index = 0
+ if (block) {
+ let block0 = block
+ while (!this.#options.blocks.includes(block0.parent?.stack?.[0] ?? block0)) block0 = block0.parent
+ index = this.#options.blocks.indexOf(block0.parent?.stack?.[0] ?? block0)
+ if (index < 0) index = this.#options.blocks.length
+ }
+ this.#options.blocks.splice(index, 0, ...blocks)
+ this.#options.update?.()
+ }
+
+ #replace(block0, block1)
+ {
+ replace(block0, block1)
+ let index = this.#options.blocks.indexOf(block0)
+ if (index >= 0) this.#options.blocks[index] = block1
+ this.#options.update()
+ }
+}
+
+export function addList(options = {})
+{
+ return addVariable({title: options.messages?.getList?.() ?? "make a list", ...options})
+}
+
+export function addVariable(options = {})
+{
+ function update()
+ {
+ let name = getName()
+ let names = options.names ?? (globalInput.checked ? options.globals : options.locals) ?? []
+ saveButton.disabled = name === "" || names.includes(name)
+ }
+
+ function getName()
+ {
+ return input.value.normalize().replace(/\s+/ug, " ").trim()
+ }
+
+ let dialogue = document.createElement("dialog")
+ let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve() }))
+
+ let form = document.createElement("form")
+ form.method = "dialog"
+ dialogue.append(form)
+
+ let p1 = document.createElement("p")
+ let p2 = document.createElement("p")
+ let p3 = document.createElement("p")
+ let p4 = document.createElement("p")
+ let p5 = document.createElement("p")
+
+ let saveButton = document.createElement("button")
+ saveButton.append(options.messages?.getOK?.() ?? "OK")
+ saveButton.disabled = true
+ saveButton.classList.add("ok")
+ saveButton.addEventListener("click", () =>
+ {
+ if (globalInput.checked) options.addGlobal(getName())
+ else options.addLocal(getName())
+ })
+
+ let cancelButton = document.createElement("button")
+ cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
+ cancelButton.classList.add("cancel")
+
+ let input = document.createElement("input")
+ input.maxLength = 80
+ input.addEventListener("input", update)
+
+ let strong = document.createElement("strong")
+ strong.append(options.title ?? options.messages?.getVariable?.() ?? "make a variable")
+
+ let globalInput = document.createElement("input")
+ globalInput.type = "radio"
+ globalInput.name = "scope"
+
+ let localInput = document.createElement("input")
+ localInput.type = "radio"
+ localInput.name = "scope"
+
+ if (options.addGlobal) globalInput.checked = true
+ else localInput.checked = true
+
+ let global = document.createElement("label")
+ let local = document.createElement("label")
+
+ global.append(globalInput, options.messages?.getGlobal?.() ?? "for all sprites")
+ local.append(localInput, options.messages?.getLocal?.() ?? "for this sprite only")
+
+ global.addEventListener("checked", update)
+ local.addEventListener("checked", update)
+
+ global.classList.add("choose-scope", "choose-global")
+ local.classList.add("choose-scope", "choose-local")
+
+ p1.append(strong)
+ p2.append(input)
+ p3.append(global)
+ p4.append(local)
+ p5.append(saveButton, " ", cancelButton)
+
+ form.append(p1, p2, p3, p4, p5)
+ if (!options.addGlobal) {
+ p3.remove()
+ p4.remove()
+ }
+
+ document.body.append(dialogue)
+ dialogue.showModal()
+
+ return promise
+}
+
+export function addBlock(options = {})
+{
+ function update()
+ {
+ let element = toElement(block, {getName: options.getName})
+ p2.textContent = ""
+ p2.append(element)
+ addLabelButton.disabled = true
+
+ for (let partElement of element.querySelectorAll(".define > .line > .custom > .line > span")) {
+
+ let {index} = closestNamePartIndex(partElement)
+ let input = document.createElement("input")
+
+ if (typeof block.names[0][index] === "string") {
+ input.value = block.names[0][index]
+ partElement.replaceWith(input)
+ }
+ else {
+ addLabelButton.disabled = false
+ input.value = block.names[0][index].name
+ partElement.textContent = ""
+ partElement.append(input)
+ }
+
+ resize(input)
+
+ input.addEventListener("input", () => resize(input))
+
+ input.addEventListener("blur", () =>
+ {
+ let value = input.value.normalize().replace(/\s+/ug, " ").trim()
+
+ if (typeof block.names[0][index] === "string") {
+ block.names[0][index] = value
+ update()
+ return
+ }
+
+ if (value !== "") {
+ block.names[0][index].name = value
+ update()
+ return
+ }
+
+ let [{}, text] = block.names[0].splice(index, 2)
+ if (text) block.names[0][index - 1] += " " + text
+
+ update()
+ })
+ }
+
+ saveButton.disabled = false
+
+ let names = block.names[0].filter(part => typeof part !== "string").map(part => part.name + (part.type === "text" ? "(s)" : "(b)"))
+ if (names.length !== new Set(names).size) {
+ saveButton.disabled = true
+ return
+ }
+
+ if (block.names[0].length === 1 && block.names[0][0] === "") {
+ saveButton.disabled = true
+ return
+ }
+
+ let name = block.names[0].filter(Boolean)
+
+ for (let block of options.scripts) {
+ if (block.type === Define.type && block !== options.block && compareName(block.names[0], name)) {
+ saveButton.disabled = true
+ return
+ }
+ }
+ }
+
+ function selectInput(n)
+ {
+ let inputs = form.querySelector(".line").querySelectorAll("input")
+ let input = inputs[inputs.length - n]
+ if (!input) return
+ input.focus()
+ }
+
+ let dialogue = document.createElement("dialog")
+ let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve(result) }))
+ let result
+
+ let form = document.createElement("form")
+ form.method = "dialog"
+ dialogue.append(form)
+
+ let p1 = document.createElement("p")
+ let p2 = document.createElement("p")
+ let p3 = document.createElement("p")
+ let p4 = document.createElement("p")
+ let p5 = document.createElement("p")
+ let p6 = document.createElement("p")
+ let p7 = document.createElement("p")
+
+ let block = options.name ? Define(options.names[0]) : Define([options.messages?.getBlockName?.() ?? "block name"])
+ block.atomic = Boolean(options.atomic)
+
+ let saveButton = document.createElement("button")
+ saveButton.append(options.messages?.getOK?.() ?? "OK")
+ saveButton.disabled = true
+ saveButton.classList.add("ok")
+ saveButton.addEventListener("click", () => { block.names[0] = block.names[0].filter(Boolean) ; result = block })
+
+ let cancelButton = document.createElement("button")
+ cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
+ cancelButton.classList.add("cancel")
+
+ let strong = document.createElement("strong")
+ strong.append(options.title ?? options.messages?.getBlock?.() ?? "make a block")
+
+ let addTextInputButton = document.createElement("button")
+ addTextInputButton.addEventListener("click", () => { block.names[0].push({type: "text", name: options.messages?.getTextInputName?.() ?? "my input"}, "") ; update() ; selectInput(2) })
+ addTextInputButton.append(options.messages?.getTextInput?.() ?? "add number/text input")
+ addTextInputButton.classList.add("add-input", "add-text-input")
+ addTextInputButton.type = "button"
+
+ let addBooleanInputButton = document.createElement("button")
+ addBooleanInputButton.addEventListener("click", () => { block.names[0].push({type: "boolean", name: options.messages?.getBooleanInputName?.() ?? "my input"}, "") ; update() ; selectInput(2) })
+ addBooleanInputButton.append(options.messages?.getBooleanInput?.() ?? "add boolean input")
+ addBooleanInputButton.classList.add("add-input", "add-boolean-input")
+ addBooleanInputButton.type = "button"
+
+ let addLabelButton = document.createElement("button")
+ addLabelButton.addEventListener("click", () => selectInput(1))
+ addLabelButton.append(options.messages?.getLabel?.() ?? "add label")
+ addLabelButton.classList.add("add-input", "add-label")
+ addLabelButton.type = "button"
+
+ let atomicInput = document.createElement("input")
+ atomicInput.type = "checkbox"
+ atomicInput.checked = block.atomic
+
+ let atomicLabel = document.createElement("label")
+ atomicLabel.append(atomicInput, options.messages?.getAtomic?.() ?? "run without screen refresh")
+ atomicLabel.classList.add("atomic-message")
+ atomicLabel.addEventListener("change", () => { block.atomic = atomicInput.checked ; update() })
+
+ p1.append(strong)
+ p3.append(addTextInputButton)
+ p4.append(addBooleanInputButton)
+ p5.append(addLabelButton)
+ p6.append(atomicLabel)
+ p7.append(saveButton, " ", cancelButton)
+
+ form.append(p1, p2, p3, p4, p5, p6, p7)
+
+ document.body.append(dialogue)
+ dialogue.showModal()
+ update()
+ p2.querySelector("input").focus()
+
+ return promise
+}
+
+export function addValue(options = {})
+{
+ let dialogue = document.createElement("dialog")
+ let promise = new Promise(resolve => dialogue.addEventListener("close", () => { dialogue.remove() ; resolve(result) }))
+ let result
+
+ let form = document.createElement("form")
+ form.method = "dialog"
+ dialogue.append(form)
+
+ let p1 = document.createElement("p")
+ let p2 = document.createElement("p")
+ let p3 = document.createElement("p")
+
+ let saveButton = document.createElement("button")
+ saveButton.append(options.messages?.getOK?.() ?? "OK")
+ saveButton.classList.add("ok")
+ saveButton.addEventListener("click", () => result = input.value.normalize().replace(/\s+/ug, " ").trim())
+
+ let cancelButton = document.createElement("button")
+ cancelButton.append(options.messages?.getCancel?.() ?? "cancel")
+ cancelButton.classList.add("cancel")
+
+ let input = document.createElement("input")
+ input.maxLength = 80
+
+ let strong = document.createElement("strong")
+ strong.append(options.title ?? options.messages?.getAddMessage?.() ?? "add a message")
+
+ p1.append(strong)
+ p2.append(input)
+ p3.append(saveButton, " ", cancelButton)
+
+ form.append(p1, p2, p3)
+
+ document.body.append(dialogue)
+ dialogue.showModal()
+
+ return promise
+}
+
+function resize(input)
+{
+ let span = document.createElement("span")
+ span.style.setProperty("white-space", "pre")
+ span.append(input.value)
+ span.style.setProperty("position", "fixed")
+ span.style.setProperty("width", "max-content")
+ input.after(span)
+ let width = span.offsetWidth
+ span.remove()
+ input.style.setProperty("width", `${width}px`)
+}
+
+let css = `
+.held {
+ position: fixed;
+ z-index: 5000;
+ filter: drop-shadow(2px 4px 8px #0004);
+}
+
+.held, .held *, .held ::before, .held ::after,
+.ghost, .ghost *, .ghost ::before, .ghost ::after {
+ pointer-events: none !important;
+}
+
+.ghost > .line, .ghost > .mouth::before, .ghost.reporter {
+ filter: brightness(0) invert(85%);
+ z-index: 2;
+}
+
+.ghost > .input, .ghost.input, select.ghost {
+ box-shadow: 0 0 0.75em #FFF, 0 0 0.75em #FFF;
+}
+
+.ghost.slot {
+ box-shadow: 0 0 1em #FFFC inset;
+}
+
+.ctx-menu {
+ position: fixed;
+ display: grid;
+ z-index: 5000;
+ background: #FFF;
+ border: 1px solid #888;
+ border-radius: 0.25em;
+ padding: 0.25em 0;
+ box-shadow: 0 0 8px #0002;
+}
+
+.ctx-menu:dir(rtl) {
+ transform: translate(-100%);
+}
+
+.ctx-menu button {
+ margin: 0;
+ border: 0;
+ color: #444;
+ background: none;
+ font-size: 1em;
+ border-radius: 0;
+ padding: 0 0.5em;
+ transition: none;
+}
+
+.ctx-menu button:hover {
+ background: #DEF;
+}
+
+.ctx-menu button:disabled {
+ background: #0000;
+ color: #AAA;
+}
+
+.line > input, .parameter > input {
+ color: inherit;
+ font: inherit;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ min-width: 0.5em;
+}
+`
+
+let style = document.createElement("style")
+style.textContent = css
+document.head.append(style)
binary files /dev/null b/assets/costume1.png differ
binary files /dev/null b/assets/costume2.png differ
--- /dev/null
+++ b/compile.js
@@ -1,0 +1,427 @@
+import {flatten} from "./model.js"
+import * as core from "./core.js"
+import {BooleanParameter, TextParameter, Define} from "./core.js"
+
+let map = new Map(Object.entries({
+
+ // stack blocks
+ SetVariable: (ctx, a, block) => `${variableName(block.names[0])}.value = ${ctx.cc(a)}`,
+ ChangeVariable: (ctx, a, block) => `${variableName(block.names[0])}.value = (+${variableName(block.names[0])}.value || 0) + ${ctx.ccNumber(a)}`,
+ WhenFlagClicked: (ctx, before, block) => `onDispatch(${block.id}, event => event === "start" ? start${block.id} : undefined)\nfunction * start${block.id}(tick)\n${ctx.ccStack(block, {before: [before, `let id = ${block.id}`].filter(Boolean).join("\n")})}`,
+ If: (ctx, condition, block) => `if ${ctx.cc(condition)} ${ctx.ccStack(block)}${block.complement ? "\nelse " + ctx.ccStack(block.complement) : ""}`,
+ Forever: (ctx, block) => `for (;;) ${ctx.ccStack(block)}`,
+ RepeatUntil: (ctx, condition, block) => `while (!${ctx.cc(condition)}) ${ctx.ccStack(block)}`,
+ WaitUntil: (ctx, condition) => `while (!${ctx.cc(condition)}) ${ctx.ccTick() || "{ }"}`,
+ Repeat: (ctx, count, block) => `for (let i = ${ctx.ccNumber(count)} - 0.5 ; i > 0 ; i--) ${ctx.ccStack(block)}`,
+ Append: (ctx, value, block) => `${listName(block.names[0])}.push${ctx.cc(value)}`,
+ Clear: (_ctx, block) => `${listName(block.names[0])}.length = 0`,
+ Delete: (ctx, index, block) => `{ let i = ${ctx.ccInteger(index)}, l = ${listName(block.names[0])} ; if (i > 0 && i <= l.length) l.splice(i - 1, 1) }`,
+ Insert: (ctx, index, value, block) => `{ let i = ${ctx.ccInteger(index)}, l = ${listName(block.names[0])} ; if (i > 0 && i <= l.length + 1) l.splice(i - 1, 0, ${ctx.cc(value)}) }`,
+ Replace: (ctx, index, value, block) => `{ let i = ${ctx.ccInteger(index)}, l = ${listName(block.names[0])} ; if (i > 0 && i <= l.length) l[i - 1] = ${ctx.cc(value)} }`,
+ Stop: () => "return",
+
+ // reporters/booleans
+ Variable: (_ctx, block) => `${variableName(block.names[0])}.value`,
+ List: (_ctx, block) => `${listName(block.names[0])}.join(" ")`,
+ Add: (ctx, a, b) => `${ctx.ccNumber(a)} + ${ctx.ccNumber(b)}`,
+ Subtract: (ctx, a, b) => `${ctx.ccNumber(a)} - ${ctx.ccNumber(b)}`,
+ Multiply: (ctx, a, b) => `${ctx.ccNumber(a)} * ${ctx.ccNumber(b)}`,
+ Divide: (ctx, a, b) => `${ctx.ccNumber(a)} / ${ctx.ccNumber(b)}`,
+ Modulo: (ctx, a, b) => `mod(${ctx.ccNumber(a)}, ${ctx.ccNumber(b)})`,
+ LessThan: (ctx, a, b) => `compare(${ctx.cc(a)}, ${ctx.cc(b)}) < 0`,
+ GreaterThan: (ctx, a, b) => `compare(${ctx.cc(a)}, ${ctx.cc(b)}) > 0`,
+ Equals: (ctx, a, b) => `compare(${ctx.cc(a)}, ${ctx.cc(b)}) === 0`,
+ And: (ctx, a, b) => `!!(${ctx.cc(a)} && ${ctx.cc(b)})`,
+ Or: (ctx, a, b) => `!!(${ctx.cc(a)} || ${ctx.cc(b)})`,
+ Not: (ctx, a) => `!${ctx.cc(a)}`,
+ Join: (ctx, a, b) => `${ctx.ccString(a)} + ${ctx.ccString(b)}`,
+ Index: (ctx, i, block) => `fallback(${listName(block.names[0])}[${ctx.ccInteger(i)} - 1], "")`,
+ Find: (ctx, a, block) => `${listName(block.names[0])}.findIndex(v => compare(v, ${ctx.cc(a)}) === 0) + 1`,
+ Length: (_ctx, block) => `${listName(block.names[0])}.length`,
+ Contains: (ctx, a, block) => `${listName(block.names[0])}.some(v => compare(v, ${ctx.cc(a)}) === 0)`,
+ TextIndex: (ctx, a, i) => `fallback(${ctx.ccString(a)}[${ctx.ccInteger(i)} - 1], "")`,
+ TextLength: (ctx, a) => `${ctx.ccString(a)}.length`,
+ TextContains: (ctx, a, b) => `${ctx.ccLower(a)}.includes${ctx.ccLower(b)}`,
+
+ Define: (ctx, before0, block) =>
+ {
+ if (block.atomic && ctx.tick) ctx = ctx.with({tick: false})
+ let names = customBlockInputNames(block.names[0])
+ let before = []
+ if (ctx.debug) before.push(`let debug = id => debug0(id, {parameters: new Map([${names.map(n => `[${JSON.stringify(n[0])}, ${n[1]}]`).join(", ")}])})`)
+ if (before0) before.push(before0)
+ return `function * ${customBlockName(block.names[0])}(${["id, tick", ...names.map(n => n[1])].join(", ")})\n${ctx.ccStack(block, {before: before.join("\n")})}`
+ },
+}).map(([name, fn]) => [core[name].type, fn]))
+
+function customName(name)
+{
+ let result = ""
+ for (let part of name) {
+ if (typeof part !== "string") {
+ if (part.type === "boolean") result += "$b$"
+ else result += "$s$"
+ continue
+ }
+ for (let ch of part) {
+ if (ch === " ") {
+ result += "_"
+ continue
+ }
+ if (/^[a-zA-Z0-9]$/.test(ch)) {
+ result += ch
+ continue
+ }
+ result += `$u${ch.codePointAt().toString(16)}$`
+ }
+ }
+
+ return result
+}
+
+function parameterName(value, type)
+{
+ return "parameter_" + type + "_" + customName([value])
+}
+
+function customBlockName(name)
+{
+ return "procedure_" + customName(name)
+}
+
+function variableName(name)
+{
+ return "variable_" + customName([name])
+}
+
+function listName(name)
+{
+ return "list_" + customName([name])
+}
+
+function customBlockInputNames(name)
+{
+ let names = []
+ for (let part of name) {
+ if (typeof part === "string") continue
+ names.push([part.name, parameterName(part.name, part.type)])
+ }
+ return names
+}
+
+function compileReporter(block, ctx)
+{
+ if (block.type.shape === "slot") return JSON.stringify(block.value)
+
+ if (block.type === TextParameter.type || block.type === BooleanParameter.type) {
+ let type = block.type === BooleanParameter.type ? "boolean" : "text"
+ let block1 = block
+ while (block1 && block1.type !== Define.type) block1 = block1.parent
+ if (block1?.names[0].some(part => typeof part !== "string" && part.name === block.names[0] && part.type === type)) {
+ return parameterName(block.names[0], type)
+ }
+ if (block.type === BooleanParameter.type) return "false"
+ return `""`
+ }
+
+ let fn = map.get(block.type)
+ if (fn) return fn(ctx, ...block.inputs, block)
+
+ let name = ctx.extensions?.get(block.type)
+ if (!name) {
+ let type1 = block.type.output ?? "unknown"
+ if (["number", "integer", "natural", "positive"].includes(type1)) return "0"
+ if (type1 === "boolean") return "false"
+ return `""`
+ }
+
+ let references = block.names?.map((name, i) => block.type.references[i] === "list" ? listName(name) : variableName(name)) ?? []
+ let inputs = [...references, ...block.inputs.map(block => compileReporter(block, ctx))]
+ let w = block.type.async ? "yield " : ""
+ return w + `${name}(${inputs.join(", ")})`
+}
+
+function compileStackBlock(block, ctx)
+{
+ let inputs = block.inputs
+
+ if (block.type.category === "custom" && block.type.shape === undefined && !block.type.hat) {
+ let types = block.names[0].map(part => typeof part !== "string").map(part => part.type)
+ return `yield * ${customBlockName(block.names[0])}(${[ctx.tick ? "id, tick" : "id, () => { }", ...inputs.map((block, i) => types[i] === "boolean" ? ctx.ccBoolean(block) : ctx.cc(block))].join(", ")})`
+ }
+
+ let before = ""
+ if (ctx.debug) before = `yield debug(${block.id})`
+
+ if (!map.has(block.type) && block.type.hat) {
+ let name = ctx.extensions?.get(block.type)
+ if (!name) return ""
+ inputs = inputs.map(block => compileReporter(block, ctx))
+ return `onDispatch(${block.id}, event => ${name}(${["event", ...inputs].join(", ")}) ? block${block.id} : undefined)\nfunction * block${block.id}(tick)\n${compileStack(block, {before: [before, `let id = ${block.id}`].filter(Boolean).join("\n")}, ctx)}\n`
+ }
+
+ if (block.type.hat) {
+ inputs = inputs.slice()
+ inputs.push(before)
+ before = ""
+ }
+
+ if (before) before += "\n"
+
+ let fn = map.get(block.type)
+ if (fn) return before + fn(ctx, ...inputs, block)
+
+ let name = ctx.extensions?.get(block.type)
+ if (!name) return ""
+
+ inputs = inputs.map(block => compileReporter(block, ctx))
+ let after = block.type.shape === "cap" ? "\nreturn" : ""
+ let w = block.type.async ? "yield " : ""
+ return before + w + `${name}(${[...inputs, "{id}"].join(", ")})` + after
+}
+
+function compileConverting(block, convert, compile, type, ctx)
+{
+ if (block.type.shape === "slot") return "(" + JSON.stringify(convert(block.value)) + ")"
+ let type1 = block.type.output ?? "unknown"
+ if (type1 === type || type === "number" && ["integer", "natural", "positive"].includes(type1) || ["integer", "positive"].includes(type) && type1 === "natural") {
+ return "(" + compileReporter(block, ctx) + ")"
+ }
+ return "(" + compile("(" + compileReporter(block, ctx) + ")") + ")"
+}
+
+function compileStack(block, {before, after} = {}, ctx)
+{
+ let result = [...before ? [before] : [], ...block.stack?.map(block => compileStackBlock(block, ctx)).filter(Boolean) ?? [], ...after ? [after] : []]
+ if (ctx.debug) result.push(`yield debug(${block.id})`)
+ if (block.type.loop) result.push(ctx.ccTick())
+ result = result.filter(Boolean)
+ if (result.length === 0) return "{\n}"
+ return `{\n${result.join("\n").replace(/^/mg, "\t")}\n}`
+}
+
+export function compileForDispatcher(scripts, {variables = {}, lists = {}, debug, dispatcher: dispatcher0 = dispatcher} = {})
+{
+ let extensions = new Map()
+ let every = scripts.flatMap(flatten)
+ let names = []
+ let values = []
+
+ let id = 1
+ for (let block of every) {
+ if (block.type.category === "custom") continue
+ if (map.has(block.type)) continue
+ if (extensions.has(block.type)) continue
+ extensions.set(block.type, `extension_${id++}`)
+ }
+
+ function onDispatch(id, fn)
+ {
+ dispatcher0.onDispatch(id, event =>
+ {
+ let fn1 = fn(event)
+ if (!fn1) return
+ return fn2
+ async function fn2(tick, info)
+ {
+ for (let value of fn1(tick)) {
+ if (typeof value?.then === "function") {
+ if (value.sync) await info.pause(value)
+ else await value
+ }
+ if (info.done) break
+ }
+ }
+ })
+ }
+
+ let stuff = {
+ fallback: (a, b) => a ?? b,
+ debug, debug0: debug,
+ mod, compare,
+ onDispatch,
+ }
+
+ for (let [name, value] of Object.entries(stuff)) {
+ names.push(name)
+ values.push(value)
+ }
+
+ for (let name of variables.keys()) {
+ names.push(variableName(name))
+ values.push(variables.get(name))
+ }
+
+ for (let name of lists.keys()) {
+ names.push(listName(name))
+ values.push(lists.get(name))
+ }
+
+ for (let [type, name] of extensions) {
+ names.push(name)
+ values.push(type.run ?? (() => { }))
+ }
+
+ let ctx = new Context({extensions, debug: Boolean(debug), tick: true})
+ let compiled = scripts.filter(block => block.type.hat).map(block => compileStackBlock(block, ctx)).filter(Boolean).join("\n\n").replace(/^/mg, "\t")
+ Function(...names, compiled)(...values)
+}
+
+class Context {
+ constructor(properties) { Object.assign(this, properties) }
+ with(properties) { return new Context({...this, ...properties}) }
+ cc(block) { return "(" + compileReporter(block, this) + ")" }
+ ccStack(block, extra = {}) { return compileStack(block, extra, this) }
+ ccString(block) { return compileConverting(block, String, n => `"" + ${n}`, "text", this) }
+ ccLower(block) { return compileConverting(block, n => String(n).toLowerCase(), n => `("" + ${n}).toLowerCase()`, "-", this) }
+ ccNumber(block) { return compileConverting(block, toNumber, n => `+${n} || 0`, "number", this) }
+ ccInteger(block) { return compileConverting(block, toInteger, n => `Math.floor${n} || 0`, "-", this) }
+ ccBoolean(block) { return compileConverting(block, toBoolean, n => `!!${n}`, "boolean", this) }
+ ccTick() { if (this.tick) return "yield tick()" }
+}
+
+export function toBoolean(v)
+{
+ return Boolean(v)
+}
+
+export function toString(v)
+{
+ return String(v)
+}
+
+export function toNumber(n)
+{
+ return Number(n) || 0
+}
+
+export function toInteger(n)
+{
+ return Math.floor(toNumber(n))
+}
+
+function mod(a, b)
+{
+ return (a % b + b) % b
+}
+
+function compare(a, b)
+{
+ let n = a - b
+ if (n === n && a !== "" && b !== "") return n
+ a = String(a).toLowerCase()
+ b = String(b).toLowerCase()
+ if (a < b) return -1
+ if (a > b) return 1
+ return 0
+}
+
+export class Ticker {
+
+ #done = true
+ #running = false
+ #listeners = []
+ #frequency
+
+ constructor(frequency)
+ {
+ this.#frequency = frequency ?? 30
+ this.start()
+ }
+
+ start()
+ {
+ this.#start()
+ }
+
+ pause()
+ {
+ this.#running = false
+ }
+
+ tick()
+ {
+ return new Promise(resolve => this.#listeners.push(resolve))
+ }
+
+ async #start()
+ {
+ if (this.#running || !this.#done) return
+ this.#done = false
+ this.#running = true
+ let t0 = performance.now()
+ while (this.#running) {
+ for (let fn of this.#listeners.splice(0)) fn()
+ t0 += 1000 / this.#frequency
+ let t1 = performance.now()
+ if (t0 < t1) t0 = t1
+ await new Promise(resolve => setTimeout(resolve, t0 - t1))
+ }
+ this.#done = true
+ }
+}
+
+export class Dispatcher {
+
+ #tick0 = () => ticker.tick()
+ #pause
+ #fns = new Map()
+ #fns2 = new Map()
+
+ constructor(tick)
+ {
+ if (tick) this.#tick0 = tick
+ }
+
+ onDispatch(id, fn)
+ {
+ this.#fns.set(id, fn)
+ }
+
+ async start(ids)
+ {
+ await this.dispatch("start", ids)
+ }
+
+ stop(ids)
+ {
+ for (let id of ids ?? [...this.#fns2.keys()]) {
+ this.#fns2.get(id)?.()
+ }
+ }
+
+ remove(ids)
+ {
+ for (let id of ids ?? [...this.#fns.keys()]) {
+ this.#fns.delete(id)
+ }
+ }
+
+ all()
+ {
+ return [...this.#fns.keys()]
+ }
+
+ dispatch(event, ids)
+ {
+ return Promise.all([...this.#fns].map(([id, fn]) =>
+ {
+ if (ids && !ids.includes(id)) return
+ let fn1 = fn(event)
+ if (!fn1) return
+ this.#fns2.get(id)?.()
+ this.#fns2.set(id, () => info.done = true)
+ let info = {done: false, pause: promise => this.#pause = promise.then(() => this.#pause = undefined)}
+ return Promise.resolve().then(() => this.#tick()).then(() => fn1(() => this.#tick(), info))
+ }))
+ }
+
+ async #tick()
+ {
+ await this.#pause
+ return this.#tick0()
+ }
+}
+
+export let ticker = new Ticker()
+export let dispatcher = new Dispatcher()
--- /dev/null
+++ b/core.js
@@ -1,0 +1,153 @@
+import {MakeBlock, MakeSlot, complement} from "./model.js"
+
+// slots
+export let TextSlot = MakeSlot()
+export let BooleanSlot = MakeSlot({numeric: true, normalise: () => "", canFit: block => block.type.output === "boolean"})
+export let NumberSlot = MakeSlot({numeric: true, normalise: n => Number.isFinite(Number(n)) ? String(n) : ""})
+export let IntegerSlot = MakeSlot({numeric: true, normalise: n => Number.isInteger(Number(n)) ? String(n) : ""})
+export let PositiveSlot = MakeSlot({numeric: true, normalise: n => { let m = Number(n) ; return Number.isFinite(m) && m >= 0 ? String(n) : "" }})
+export let NaturalSlot = MakeSlot({numeric: true, normalise: n => { let m = Number(n) ; return Number.isInteger(m) && m >= 0 ? String(n) : "" }})
+export function EnumeratedSlot(options, type = {})
+{
+ return MakeSlot({canFit: () => false, ...type, normalise: n => String(options.includes(String(n)) ? n : options[0]), options})
+}
+
+// events
+export let WhenFlagClicked = MakeBlock({hat: true, name: () => ["when", [getIcon(), "green flag", "flag", "gf", "@greenFlag"], "clicked"], category: "events"})
+
+// control
+export let Else = MakeBlock({stack: true, name: () => ["else"]})
+export let If = MakeBlock({slots: [BooleanSlot], stack: true, name: condition => ["if", condition, "then"], category: "control", complement: Else.type})
+export let Forever = MakeBlock({stack: true, name: () => ["forever"], category: "control", loop: true, cap: true})
+export let WaitUntil = MakeBlock({slots: [BooleanSlot], name: condition => ["wait until", condition], category: "control"})
+export let RepeatUntil = MakeBlock({slots: [BooleanSlot], stack: true, name: condition => ["repeat until", condition], category: "control", loop: true})
+export let Repeat = MakeBlock({slots: [NaturalSlot], stack: true, name: seconds => ["repeat", seconds], category: "control", defaults: [10], loop: true})
+export let Stop = MakeBlock({name: () => ["stop this script"], category: "control", cap: true})
+export function IfElse(...args)
+{
+ let block = If(...args)
+ complement(block, Else())
+ return block
+}
+
+// operators
+export let Add = MakeBlock({shape: "reporter", slots: [NumberSlot, NumberSlot], name: (a, b) => [a, "+", b], category: "operators", output: "number"})
+export let Subtract = MakeBlock({shape: "reporter", slots: [NumberSlot, NumberSlot], name: (a, b) => [a, "-", b], category: "operators", output: "number"})
+export let Multiply = MakeBlock({shape: "reporter", slots: [NumberSlot, NumberSlot], name: (a, b) => [a, "*", b], category: "operators", output: "number"})
+export let Divide = MakeBlock({shape: "reporter", slots: [NumberSlot, NumberSlot], name: (a, b) => [a, "/", b], category: "operators", output: "number"})
+export let Modulo = MakeBlock({shape: "reporter", slots: [NumberSlot, NumberSlot], name: (a, b) => [a, "mod", b], category: "operators", output: "number"})
+export let LessThan = MakeBlock({shape: "reporter", slots: [TextSlot, TextSlot], name: (a, b) => [a, "<", b], category: "operators", defaults: ["", 50], output: "boolean"})
+export let GreaterThan = MakeBlock({shape: "reporter", slots: [TextSlot, TextSlot], name: (a, b) => [a, ">", b], category: "operators", defaults: ["", 50], output: "boolean"})
+export let Equals = MakeBlock({shape: "reporter", slots: [TextSlot, TextSlot], name: (a, b) => [a, "=", b], category: "operators", defaults: ["", 50], output: "boolean"})
+export let And = MakeBlock({shape: "reporter", slots: [BooleanSlot, BooleanSlot], name: (a, b) => [a, "and", b], category: "operators", output: "boolean"})
+export let Or = MakeBlock({shape: "reporter", slots: [BooleanSlot, BooleanSlot], name: (a, b) => [a, "or", b], category: "operators", output: "boolean"})
+export let Not = MakeBlock({shape: "reporter", slots: [BooleanSlot], name: a => ["not", a], category: "operators", output: "boolean"})
+export let Join = MakeBlock({shape: "reporter", slots: [TextSlot, TextSlot], name: (a, b) => ["join", a, b], category: "operators", defaults: ["apple ", "banana"], output: "text"})
+export let TextIndex = MakeBlock({shape: "reporter", slots: [TextSlot, NaturalSlot], name: (a, b) => ["letter", b, "of", a], category: "operators", defaults: ["apple", 1], output: "text"})
+export let TextLength = MakeBlock({shape: "reporter", slots: [TextSlot], name: a => ["length of", a], category: "operators", defaults: ["apple"], output: "natural"})
+export let TextContains = MakeBlock({shape: "reporter", slots: [TextSlot, TextSlot], name: (a, b) => [a, "contains", b, "?"], category: "operators", defaults: ["apple", "a"], output: "boolean"})
+
+// variables
+export let Variable = MakeBlock({shape: "reporter", category: "variables", references: ["variable"]})
+export let SetVariable = MakeBlock({slots: [TextSlot], name: (name, value) => ["set", name, "to", value], category: "variables", references: ["variable"], defaults: [0]})
+export let ChangeVariable = MakeBlock({slots: [NumberSlot], name: (name, value) => ["change", name, "by", value], category: "variables", references: ["variable"], defaults: [1]})
+
+// lists
+export let List = MakeBlock({shape: "reporter", category: "lists", references: ["list"], output: "text"})
+export let Append = MakeBlock({slots: [TextSlot], name: (name, value) => ["add", value, "to", name], category: "lists", references: ["list"], defaults: ["thing"]})
+export let Delete = MakeBlock({slots: [NaturalSlot], name: (name, index) => ["delete", index, "of", name], category: "lists", references: ["list"], defaults: [1]})
+export let Clear = MakeBlock({name: name => ["delete all of", name], category: "lists", references: ["list"]})
+export let Insert = MakeBlock({slots: [NaturalSlot, TextSlot], name: (name, index, value) => ["insert", value, "at", index, "of", name], category: "lists", references: ["list"], defaults: [1, "thing"]})
+export let Replace = MakeBlock({slots: [NaturalSlot, TextSlot], name: (name, index, value) => ["replace item", index, "of", name, "with", value], category: "lists", references: ["list"], defaults: [1, "thing"]})
+export let Index = MakeBlock({shape: "reporter", slots: [NaturalSlot], name: (name, index) => ["item", index, "of", name], category: "lists", references: ["list"], defaults: [1]})
+export let Find = MakeBlock({shape: "reporter", slots: [TextSlot], name: (name, value) => ["item # of", value, "in", name], category: "lists", references: ["list"], defaults: ["thing"], output: "natural"})
+export let Length = MakeBlock({shape: "reporter", name: name => ["length of", name], category: "lists", references: ["list"], output: "natural"})
+export let Contains = MakeBlock({shape: "reporter", slots: [TextSlot], name: (name, value) => [name, "contains", value, "?"], category: "lists", references: ["list"], defaults: ["thing"], output: "boolean"})
+
+// my blocks
+export let Define = MakeBlock({hat: true, category: "custom", references: ["custom"], complete: block => block.atomic = false, name: prototype => ["define", prototype]})
+export let TextParameter = MakeBlock({shape: "reporter", category: "custom", references: ["text-parameter"]})
+export let BooleanParameter = MakeBlock({shape: "reporter", category: "custom", references: ["boolean-parameter"], output: "boolean"})
+
+function MakeCustom(name)
+{
+ return MakeBlock({
+ category: "custom",
+ name: (...inputs) =>
+ {
+ let name1 = []
+ let i = 0
+ for (let part of name) {
+ if (typeof part === "string") name1.push(part)
+ else name1.push(inputs[i++])
+ }
+ return name1
+ },
+ slots: name.filter(part => typeof part !== "string").map(part => part.type === "boolean" ? BooleanSlot : TextSlot),
+ references: ["custom"],
+ defaults: name.filter(part => typeof part !== "string").map(() => ""),
+ })
+}
+
+export function Custom(name, ...inputs)
+{
+ return MakeCustom(name)(name, ...inputs)
+}
+
+let variableFns = [SetVariable, ChangeVariable]
+let listFns = [Append, Delete, Clear, Insert, Replace, Index, Find, Length, Contains]
+
+export let library = [
+ WhenFlagClicked,
+ Repeat, Forever, If, IfElse, WaitUntil, RepeatUntil, Stop,
+ Add, Subtract, Multiply, Divide, Modulo, GreaterThan, LessThan, Equals, And, Or, Not, Join, TextIndex, TextLength, TextContains,
+ ...variableFns, ...listFns,
+]
+
+export function VariableLibrary(names)
+{
+ if (names.length === 0) return []
+ let name = names[0]
+ return [
+ ...names.map(name => () => Variable(name)),
+ ...variableFns.map(fn => () => fn(name)),
+ ]
+}
+
+export function ListLibrary(names)
+{
+ if (names.length === 0) return []
+ let name = names[0]
+ return [
+ ...names.map(name => () => List(name)),
+ ...listFns.map(fn => () => fn(name)),
+ ]
+}
+
+export function compareName(name0, name1)
+{
+ if (name0.length !== name1.length) return false
+ for (let [i, part] of name0.entries()) {
+ let part1 = name1[i]
+ if ((typeof part === "string") !== (typeof part1 === "string")) return false
+ if (typeof part === "string") {
+ if (part !== part1) return false
+ }
+ else {
+ if (part.type !== part1.type) return false
+ }
+ }
+ return true
+}
+
+let icon = `<svg viewbox="0 0 24 24" fill="#6C6" stroke="#444"><path d="M20.45,5.37C20.71,4.71,20.23,4,19.52,4H13h-1H7V3c0-0.55-0.45-1-1-1h0C5.45,2,5,2.45,5,3v1v10v8h2v-8h4h1h7.52 c0.71,0,1.19-0.71,0.93-1.37L19,9L20.45,5.37z"/></svg>`
+
+function getIcon()
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icon)
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
--- /dev/null
+++ b/external.js
@@ -1,0 +1,914 @@
+import {append, flatten} from "./model.js"
+import {Forever, Variable, List, Define, TextParameter, BooleanParameter, Else, BooleanSlot, compareName} from "./core.js"
+import * as core from "./core.js"
+import {Stage} from "./stage.js"
+import {MD5} from "./md5.js"
+import {zip, unzip} from "./zip.js"
+
+let decoder = new TextDecoder()
+let missing = new Set()
+
+export async function load(id, options = {})
+{
+ let response1 = await fetch(`https://trampoline.turbowarp.org/proxy/projects/${id}`)
+ if (!response1.ok) return
+ let {project_token, title} = await response1.json()
+
+ let response2 = await fetch(`https://projects.scratch.mit.edu/${id}?token=${project_token}`)
+ if (!response2.ok) return
+ let project = await response2.json()
+
+ let stage = await fromExternal(project, ({md5ext}) => fetch(`https://assets.scratch.mit.edu/internalapi/asset/${md5ext}/get/`).then(response => response.blob()), options)
+ stage.name = title
+ return stage
+}
+
+export async function fromSB3(bytes, options = {})
+{
+ let files = await unzip(bytes)
+ let project = files?.get("project.json")
+ if (!project) return
+ let types = {png: "image/png", jpg: "image/jpeg", svg: "image/svg+xml"}
+ return fromExternal(JSON.parse(decoder.decode(project)), ({md5ext, type}) => new Blob([files.get(md5ext)], {type: types[type] ?? "application/octet-stream"}), options)
+}
+
+async function fromExternal(project, getBlob, options = {})
+{
+ let stage = new Stage()
+
+ for (let name of project.extensions) {
+ if (!await stage.addExtension(name)) {
+ console.log("could not load extension: " + name)
+ }
+ }
+
+ for (let target of project.targets) {
+
+ target = {...target, costumes: target.costumes.map(costume => ({...costume, blob: getBlob(costume)}))}
+ let sprite
+
+ if (target.isStage) {
+
+ for (let backdrop of target.costumes) {
+ stage.makeBackdrop({name: backdrop.name, blob: await backdrop.blob, x: backdrop.rotationCenterX, y: backdrop.rotationCenterY})
+ }
+
+ stage.backdrops[0].remove()
+ stage.backdrop = stage.backdrops[target.currentCostume]
+
+ for (let [name, value] of Object.values(target.variables)) {
+ stage.variables.set(name, {value})
+ }
+ for (let [name, list] of Object.values(target.lists)) {
+ stage.lists.set(name, list.slice())
+ }
+ }
+ else {
+
+ sprite = stage.makeSprite({name: target.name})
+ sprite.direction = target.direction
+ sprite.size = target.size
+ sprite.x = target.x
+ sprite.y = target.y
+ if (!target.visible) sprite.hide()
+ if (target.draggable) sprite.draggable = true
+
+ for (let costume of target.costumes) {
+ sprite.makeCostume({name: costume.name, blob: await costume.blob, x: costume.rotationCenterX, y: costume.rotationCenterY})
+ }
+
+ sprite.costumes[0].remove()
+ sprite.costume = sprite.costumes[target.currentCostume]
+
+ for (let [name, value] of Object.values(target.variables)) {
+ sprite.selfVariables.set(name, {value})
+ }
+
+ for (let [name, list] of Object.values(target.lists)) {
+ sprite.selfLists.set(name, list.slice())
+ }
+ }
+
+ let options1 = {
+ ...options,
+ MakeBlock: opcode => options.MakeBlock?.(opcode) ?? stageConversions[opcode]?.({stage, sprite, scratch: stage.scratch}),
+ variables: (sprite ?? stage).variables, lists: (sprite ?? stage).lists,
+ }
+
+ let scripts = fromExternalTarget(target, options1).filter(block => block.type.hat)
+ if (target.isStage) stage.scripts.push(...scripts)
+ else sprite.scripts.push(...scripts)
+ }
+
+ let ordered = project.targets.filter(target => !target.isStage).sort((a, b) => a.layerOrder - b.layerOrder)
+ for (let {name} of ordered) {
+ stage.getSprite(name).gotoFront()
+ }
+
+ if (missing.size > 0) console.log("missing:", ...missing)
+ return stage
+}
+
+function fromExternalTarget(target, options = {})
+{
+ let blocks = []
+
+ for (let info of Object.values(target.blocks)) {
+ if (!info.topLevel) continue
+ let stack = []
+ convert(info, target.blocks, options, stack)
+ if (stack.length === 0) continue
+ if (typeof stack[0] !== "object") continue
+ if (stack[0].type.shape === undefined && !stack[0].type.hat) Forever(...stack)
+ blocks.push(stack[0])
+ }
+
+ for (let block of blocks.flatMap(flatten)) {
+ if (block.type.shape === undefined && !block.type.hat && block.type.category === "custom" && !blocks.some(other => other.type === Define.type && compareName(other.names[0], block.names[0]))) {
+ blocks.push(Define(block.names[0].map((part, i) => typeof part === "string" ? part : {type: part.type, name: "input " + (i + 1)})))
+ }
+ }
+
+ return blocks
+}
+
+function convert(info, infos, options, stack0)
+{
+ if (!info) return
+ let convert1 = options.MakeBlock?.(info.opcode) ?? conversions[info.opcode]
+ if (!convert1) {
+ missing.add(info.opcode)
+ if (info.next) convert(infos[info.next], infos, options, stack0)
+ return
+ }
+
+ let inputs = {}
+
+ for (let [name, [_type, value]] of Object.entries(info.inputs)) {
+
+ if (value === null) continue
+
+ if (info.opcode !== "procedures_call") name = name.toLowerCase()
+ if (name.startsWith("substack")) {
+ let stack = []
+ convert(infos[value], infos, options, stack)
+ inputs[name] = stack
+ continue
+ }
+
+ if (value instanceof Array) {
+ if (value[0] === 12) {
+ inputs[name] = Variable(toName(value[1]))
+ continue
+ }
+ if (value[0] === 13) {
+ inputs[name] = List(toName(value[1]))
+ continue
+ }
+ inputs[name] = String(value[1])
+ continue
+ }
+
+ let stack = []
+ convert(infos[value], infos, options, stack)
+ if (stack.length !== 1 || typeof stack[0] === "object" && stack[0].type.shape === undefined) {
+ inputs[name] = ""
+ continue
+ }
+
+ inputs[name] = stack[0]
+ }
+
+ for (let [name, [value]] of Object.entries(info.fields)) inputs[name.toLowerCase()] = value
+
+ let block = convert1(inputs, info, infos)
+ stack0.push(block)
+
+ if (typeof block === "object" && block.type.hat) {
+ let stack = []
+ if (info.next) convert(infos[info.next], infos, options, stack)
+ for (let other of stack) append(block, other)
+ return
+ }
+
+ if (!info.next || typeof block === "object" && block.type.cap) return
+ convert(infos[info.next], infos, options, stack0)
+}
+
+function toName(name)
+{
+ return name.normalize().replace(/\s+/ug, " ").trim()
+}
+
+function customName(name, names)
+{
+ let i = 0
+ return name.split(/(%b|%s|%n)/g).map(part =>
+ {
+ if (part === "%b") return {type: "boolean", name: toName(names[i++] ?? "")}
+ if (part === "%s") return {type: "text", name: toName(names[i++] ?? "")}
+ if (part === "%n") return {type: "text", name: toName(names[i++] ?? "")}
+ return toName(part)
+ }).filter(Boolean)
+}
+
+export async function toSB3(stage)
+{
+ let missing = new Set()
+ let files = []
+ let result = {monitors: [], extensions: Object.keys(stage.extensions), meta: {semver: "3.0.0", vm: "0.0.0", agent: "Scrax"}, targets: []}
+ let fns = []
+ fns.push(async () => result.targets[0] = await toExternalStage(stage, files, missing))
+ for (let [i, sprite] of stage.sprites.entries()) fns.push(async () => result.targets[i + 1] = await toExternalSprite(sprite, files, missing))
+ await Promise.all(fns.map(fn => fn()))
+ files.push(new File([JSON.stringify(result)], "project.json"))
+ if (missing.size > 0) console.log("missing:", ...missing)
+ return zip(files)
+}
+
+function toExternalSprite(sprite, files, missing)
+{
+ let escaped = sprite.name.replaceAll("|", "||").replaceAll("/", "|")
+
+ let blocks = {}
+ let every = sprite.scripts.flatMap(flatten)
+ for (let block of every) toExternalBlock(block, blocks, {scripts: sprite.scripts, variables: sprite.selfVariables, lists: sprite.selfLists, name: escaped, missing, conversions: spriteConversions(sprite)})
+
+ let variables = {}
+ let lists = {}
+ let broadcasts = {}
+ let costumes = []
+ let sounds = []
+
+ let fns = []
+ for (let [i, costume] of sprite.costumes.entries()) fns.push(async () => costumes[i] = await toExternalCostume(costume, files))
+ for (let [name, {value}] of sprite.selfVariables) variables[escaped + "/var/" + name] = [name, value]
+ for (let [name, list] of sprite.selfLists) lists[escaped + "/list/" + name] = [name, list.slice()]
+
+ let result = {
+ isStage: false,
+ name: sprite.name,
+ variables, lists, blocks,
+ broadcasts, costumes, sounds,
+ comments: {},
+ currentCostume: sprite.costumes.indexOf(sprite.costume),
+ volume: sprite.volume,
+ visible: sprite.visible,
+ draggable: sprite.draggable,
+ x: sprite.x, y: sprite.y,
+ size: sprite.size,
+ direction: sprite.direction,
+ rotationStyle: {full: "all around", none: "don't rotate", "left-right": "left-right"}[sprite.rotationStyle],
+ }
+
+ return Promise.all(fns.map(fn => fn())).then(() => result)
+}
+
+function toExternalStage(stage, files, missing)
+{
+ let blocks = {}
+ let every = stage.scripts.flatMap(flatten)
+ for (let block of every) toExternalBlock(block, blocks, {scripts: stage.scripts, missing, conversions: stageConversions1(stage)})
+
+ let variables = {}
+ let lists = {}
+ let broadcasts = {}
+ let costumes = []
+ let sounds = []
+
+ let fns = []
+ for (let [i, backdrop] of stage.backdrops.entries()) fns.push(async () => costumes[i] = await toExternalCostume(backdrop, files))
+ for (let [name, {value}] of stage.variables) variables["stage/var/" + name] = [name, value]
+ for (let [name, list] of stage.lists) lists["stage/list/" + name] = [name, list.slice()]
+
+ let result = {
+ isStage: true,
+ name: "Stage",
+ variables, lists, blocks,
+ broadcasts, costumes, sounds,
+ comments: {},
+ currentCostume: stage.backdrops.indexOf(stage.backdrop),
+ volume: stage.volume,
+ tempo: stage.extensions.music?.tempo ?? 60,
+ }
+
+ return Promise.all(fns.map(fn => fn())).then(() => result)
+}
+
+async function toExternalCostume(costume, files)
+{
+ let md5 = MD5(new Uint8Array(await costume.blob.arrayBuffer()))
+ let ext = {"image/png": "png", "image/jpeg": "jpg", "image/svg+xml": "svg"}[costume.blob.type] ?? "bin"
+ files.push(new File([costume.blob], md5 + "." + ext))
+ return {
+ name: costume.name,
+ assetId: md5,
+ md5ext: md5 + "." + ext,
+ dataFormat: ext,
+ rotationCenterX: costume.x,
+ rotationCenterY: costume.y,
+ }
+}
+
+function toExternalBlock(block, blocks, {scripts, variables, lists, name, missing, conversions})
+{
+ if (block.type === Variable.type) return
+ if (block.type === List.type) return
+ if (block.type.shape === "slot") return
+
+ if (block.type === Define.type) {
+
+ let inputs = {}
+ let values = block.names[0].filter(part => typeof part !== "string")
+ for (let [i, part] of values.entries()) {
+ inputs[`param/${part.type}/${part.name}`] = [1, block.id + "/" + i]
+ blocks[block.id + "/" + i] = {
+ opcode: part.type === "boolean" ? "argument_reporter_boolean" : "argument_reporter_string_number",
+ parent: block.id + "/x",
+ inputs: {},
+ fields: {VALUE: [part.name, null]},
+ shadow: true,
+ topLevel: false,
+ }
+ }
+
+ blocks[block.id] = {opcode: "procedures_definition", inputs: {custom_block: [1, block.id + "/x"]}, fields: {}, topLevel: true, parent: null}
+ blocks[block.id + "/x"] = {
+ opcode: "procedures_prototype",
+ inputs, fields: {},
+ parent: String(block.id),
+ shadow: true,
+ topLevel: true,
+ mutation: {
+ tagName: "mutation",
+ children: [],
+ proccode: block.names[0].map(part => typeof part === "string" ? part : part.type === "boolean" ? "%b" : "%s").join(" "),
+ argumentids: JSON.stringify(values.map(part => `param/${part.type}/${part.name}`)),
+ argumentnames: JSON.stringify(values.map(part => part.name)),
+ argumentdefaults: JSON.stringify(values.map(() => "")),
+ warp: JSON.stringify(block.atomic),
+ },
+ }
+
+ if (block.stack.length > 0) blocks[block.id].next = String(block.stack[0].id)
+
+ return
+ }
+
+ function toVariable(name1)
+ {
+ return [name1, (variables?.has(name1) ? name + "/var/" : "stage/var/") + name1]
+ }
+
+ function toList(name1)
+ {
+ return [name1, (lists?.has(name1) ? name + "/list/" : "stage/list/") + name1]
+ }
+
+ let previous = block.parent?.stack?.[block.parent.stack.indexOf(block) - 1]
+ previous ??= block.parent
+
+ if (block.type.shape === undefined && !block.type.hat && block.type.category === "custom") {
+
+ let block0 = scripts.find(block0 => block0.type === Define.type && compareName(block0.names[0], block.names[0]))
+
+ let values = block0.names[0].filter(part => typeof part !== "string")
+ let ids = values.map(part => `param/${part.type}/${part.name}`)
+ let inputs = {}
+ blocks[block.id] = {
+ opcode: "procedures_call",
+ inputs, fields: {},
+ topLevel: false,
+ mutation: {
+ tagName: "mutation",
+ children: [],
+ proccode: block.names[0].map(part => typeof part === "string" ? part : part.type === "boolean" ? "%b" : "%s").join(" "),
+ argumentids: JSON.stringify(ids),
+ warp: JSON.stringify(block0.atomic),
+ },
+ parent: String(previous.id),
+ }
+
+ for (let [i, input] of block.inputs.entries()) {
+ inputs[ids[i]] = [3, String(input.id), [10, ""]]
+ if (input.type === Variable.type) inputs[ids[i]] = [3, [12, ...toVariable(input.names[0])], [10, ""]]
+ if (input.type === List.type) inputs[ids[i]] = [3, [13, ...toList(input.names[0])], [10, ""]]
+ if (input.type.shape === "slot") inputs[ids[i]] = [1, [10, input.value]]
+ if (input.type.shape === "slot" && values[i].type === "boolean") delete inputs[ids[i]]
+ }
+
+ let next = block.parent.stack[block.parent.stack.indexOf(block) + 1]
+ if (next) blocks[block.id].next = String(next.id)
+
+ return
+ }
+
+ if (block.type === TextParameter.type || block.type === BooleanParameter.type) {
+ let opcode = block.type === BooleanParameter.type ? "argument_reporter_boolean" : "argument_reporter_string_number"
+ blocks[block.id] = {opcode, parent: String(block.parent.id), inputs: {}, fields: {VALUE: [block.names[0], null]}, topLevel: false}
+ return
+ }
+
+ let conversion = conversions1.get(block.type) ?? conversions.get(block.type)
+
+ if (!conversion) {
+ missing.add(block.type)
+ return
+ }
+
+ let [opcode, ...args] = conversion
+ let inputs = {}
+ let fields = {}
+
+ blocks[block.id] = {opcode, inputs, fields, topLevel: block.type.hat, parent: null}
+
+ if (block.type.hat) {
+ if (block.stack.length > 0) blocks[block.id].next = String(block.stack[0].id)
+ }
+ else {
+ if (block.stack) {
+ if (block.stack.length > 0) inputs.SUBSTACK = [2, String(block.stack[0].id)]
+ }
+ blocks[block.id].parent = String(previous.id)
+ if (block.type.shape === undefined) {
+ if (block.complement?.type === Else.type) {
+ blocks[block.id].opcode = "control_if_else"
+ if (block.complement.stack.length > 0) inputs.SUBSTACK2 = [2, String(block.complement.stack[0].id)]
+ }
+ let next = block.parent.stack[block.parent.stack.indexOf(block) + 1]
+ if (next) blocks[block.id].next = String(next.id)
+ }
+ }
+
+ for (let [i, reference] of block.type.references?.entries() ?? []) {
+ if (reference === "variable") fields.VARIABLE = toVariable(block.names[i])
+ if (reference === "list") fields.LIST = toList(block.names[i])
+ }
+
+ let i = 0
+ for (let arg of args) {
+
+ if (typeof arg === "string") {
+
+ let block1 = block.inputs[i]
+ if (arg.endsWith(":u")) {
+ fields[arg.slice(0, -2)] = [block1.value.toUpperCase(), null]
+ i++
+ continue
+ }
+
+ inputs[arg] = [3, String(block1.id), [10, ""]]
+ if (block1.type === Variable.type) inputs[arg] = [3, [12, ...toVariable(block1.names[0])], [10, ""]]
+ if (block1.type === List.type) inputs[arg] = [3, [13, ...toList(block1.names[0])], [10, ""]]
+ if (block1.type.shape === "slot") inputs[arg] = [1, [10, block1.value]]
+ if (block1.type.shape === "slot" && block.type.slots[i].type === BooleanSlot.type) delete inputs[arg]
+
+ i++
+ continue
+ }
+
+ if (arg.length === 1) {
+ let block1 = block.inputs[i]
+ if (block1.type.shape === "slot") fields[arg[0]] = [block1.value, null]
+ else fields[arg[0]] = ["", null]
+ i++
+ continue
+ }
+
+ if (arg.length === 2) {
+ fields[arg[0]] = [arg[1], null]
+ continue
+ }
+
+ if (arg.length === 4) {
+ inputs[arg[0]] = [1, block.id + "/x"]
+ blocks[block.id + "/x"] = {opcode: arg[1], fields: {[arg[2]]: [arg[3], null]}, topLevel: false, shadow: true}
+ continue
+ }
+
+ let block1 = block.inputs[i]
+ inputs[arg[0]] = [3, String(block1.id), [10, ""]]
+ if (block1.type === Variable.type) inputs[arg[0]] = [3, [12, ...toVariable(block1.names[0])], [10, ""]]
+ if (block1.type === List.type) inputs[arg[0]] = [3, [13, ...toList(block1.names[0])], [10, ""]]
+ if (block1.type.shape === "slot" && block.type.slots[i].type === BooleanSlot.type) delete inputs[arg[0]]
+ if (block1.type.shape === "slot" && block.type.slots[i].type !== BooleanSlot.type) {
+ inputs[arg[0]] = [1, String(block1.id)]
+ blocks[block1.id] = {opcode: arg[1], fields: {[arg[2]]: [block1.value, null]}, topLevel: false, shadow: true}
+ }
+
+ i++
+ }
+}
+
+let conversions = {
+
+ event_whenflagclicked: () => core.WhenFlagClicked(),
+ control_forever: inputs => core.Forever(...inputs.substack ?? []),
+ control_repeat: inputs => core.Repeat(inputs.times, ...inputs.substack ?? []),
+ control_if: inputs => core.If(inputs.condition, ...inputs.substack ?? []),
+ control_if_else: inputs =>
+ {
+ let block = core.IfElse(inputs.condition, ...inputs.substack ?? [])
+ append(block.complement, ...inputs.substack2 ?? [])
+ return block
+ },
+ control_wait_until: inputs => core.WaitUntil(inputs.condition),
+ control_repeat_until: inputs => core.RepeatUntil(inputs.condition, ...inputs.substack ?? []),
+ operator_add: inputs => core.Add(inputs.num1, inputs.num2),
+ operator_subtract: inputs => core.Subtract(inputs.num1, inputs.num2),
+ operator_multiply: inputs => core.Multiply(inputs.num1, inputs.num2),
+ operator_divide: inputs => core.Divide(inputs.num1, inputs.num2),
+ operator_lt: inputs => core.LessThan(inputs.operand1, inputs.operand2),
+ operator_equals: inputs => core.Equals(inputs.operand1, inputs.operand2),
+ operator_gt: inputs => core.GreaterThan(inputs.operand1, inputs.operand2),
+ operator_and: inputs => core.And(inputs.operand1, inputs.operand2),
+ operator_or: inputs => core.Or(inputs.operand1, inputs.operand2),
+ operator_not: inputs => core.Not(inputs.operand),
+ operator_join: inputs => core.Join(inputs.string1, inputs.string2),
+ operator_letter_of: inputs => core.TextIndex(inputs.string, inputs.letter),
+ operator_length: inputs => core.TextLength(inputs.string),
+ operator_contains: inputs => core.TextContains(inputs.string1, inputs.string2),
+ operator_mod: inputs => core.Modulo(inputs.num1, inputs.num2),
+ data_setvariableto: inputs => core.SetVariable(toName(inputs.variable), inputs.value),
+ data_changevariableby: inputs => core.ChangeVariable(toName(inputs.variable), inputs.value),
+ data_addtolist: inputs => core.Append(toName(inputs.list), inputs.item),
+ data_deleteoflist: inputs =>
+ {
+ if (inputs.index === "all") return core.Clear(toName(inputs.list))
+ if (inputs.index === "last") return core.Delete(toName(inputs.list), core.Length(toName(inputs.list)))
+ return core.Delete(toName(inputs.list), inputs.index)
+ },
+ data_deletealloflist: inputs => core.Clear(toName(inputs.list)),
+ data_insertatlist: inputs => core.Insert(toName(inputs.list), inputs.index, inputs.item),
+ data_replaceitemoflist: inputs => core.Replace(toName(inputs.list), inputs.index, inputs.item),
+ data_itemoflist: inputs => core.Index(toName(inputs.list), inputs.index),
+ data_itemnumoflist: inputs => core.Find(toName(inputs.list), inputs.item),
+ data_lengthoflist: inputs => core.Length(toName(inputs.list)),
+ data_listcontainsitem: inputs => core.Contains(toName(inputs.list), inputs.item),
+ procedures_definition: (_inputs, info, infos) =>
+ {
+ let {mutation} = infos[info.inputs.custom_block[1]]
+ let block = core.Define(customName(mutation.proccode, JSON.parse(mutation.argumentnames)))
+ if (mutation.warp === "true") block.atomic = true
+ return block
+ },
+ procedures_call: (inputs, info) => core.Custom(customName(info.mutation.proccode, []), ...JSON.parse(info.mutation.argumentids).map(n => inputs[n])),
+ procedures_prototype: () => { },
+ argument_reporter_string_number: inputs => core.TextParameter(toName(inputs.value)),
+ argument_reporter_boolean: inputs => core.BooleanParameter(toName(inputs.value)),
+}
+
+let stageConversions = {
+ control_wait: ({scratch}) => inputs => scratch.Wait(inputs.duration),
+ sensing_timer: ({scratch}) => () => scratch.Timer(),
+ sensing_resettimer: ({scratch}) => () => scratch.ResetTimer(),
+ sensing_current: ({scratch}) => inputs =>
+ {
+ if (inputs.currentmenu === "YEAR") return scratch.Year()
+ if (inputs.currentmenu === "MONTH") return scratch.Month()
+ if (inputs.currentmenu === "DATE") return scratch.Day()
+ if (inputs.currentmenu === "DAYOFWEEK") return scratch.DayOfWeek()
+ if (inputs.currentmenu === "HOUR") return scratch.Hour()
+ if (inputs.currentmenu === "MINUTE") return scratch.Minute()
+ if (inputs.currentmenu === "SECOND") return scratch.Second()
+ },
+ sensing_dayssince2000: ({scratch}) => () => scratch.DaysSince2000(),
+ sensing_username: ({scratch}) => () => scratch.Username(),
+ operator_random: ({scratch}) => inputs => scratch.PickRandom(inputs.from, inputs.to),
+ operator_round: ({scratch}) => inputs => scratch.Round(inputs.num),
+ operator_mathop: ({scratch}) => inputs =>
+ {
+ let ops = {abs: scratch.Abs, ceiling: scratch.Ceiling, floor: scratch.Floor, sqrt: scratch.Sqrt, sin: scratch.Sin, cos: scratch.Cos, tan: scratch.Tan, asin: scratch.ArcSin, acos: scratch.ArcCos, atan: scratch.ArcTan, ln: scratch.Ln, log: scratch.Log, "e ^": scratch.Exp, "10 ^": scratch.Exp10}
+ return ops[inputs.operator]?.(inputs.num)
+ },
+ control_stop: ({scratch}) => inputs =>
+ {
+ if (inputs.stop_option === "this script") return core.Stop()
+ if (inputs.stop_option === "all") return scratch.StopAll()
+ if (inputs.stop_option === "other scripts in sprite") return scratch.StopOther()
+ },
+ motion_movesteps: ({sprite}) => inputs => sprite.Move(inputs.steps),
+ motion_turnright: ({sprite}) => inputs => sprite.RotateRight(inputs.degrees),
+ motion_turnleft: ({sprite}) => inputs => sprite.RotateLeft(inputs.degrees),
+ motion_pointindirection: ({sprite}) => inputs => sprite.SetDirection(inputs.direction),
+ // motion_pointtowards: ...,
+ motion_gotoxy: ({sprite}) => inputs => sprite.GotoXY(inputs.x, inputs.y),
+ // motion_goto: ...,
+ // motion_glidesecstoxy: ...,
+ // motion_glideto: ...,
+ motion_changexby: ({sprite}) => inputs => sprite.ChangeX(inputs.dx),
+ motion_setx: ({sprite}) => inputs => sprite.SetX(inputs.x),
+ motion_changeyby: ({sprite}) => inputs => sprite.ChangeY(inputs.dy),
+ motion_sety: ({sprite}) => inputs => sprite.SetY(inputs.y),
+ // motion_ifonedgebounce: ...,
+ motion_setrotationstyle: ({sprite}) => inputs =>
+ {
+ if (inputs.style === "don't rotate") return sprite.DisableRotation()
+ if (inputs.style === "all around") return sprite.EnableRotation()
+ if (inputs.style === "left-right") return sprite.EnableFlipping()
+ },
+ motion_xposition: ({sprite}) => () => sprite.GetX(),
+ motion_yposition: ({sprite}) => () => sprite.GetY(),
+ motion_direction: ({sprite}) => () => sprite.GetDirection(),
+ looks_say: ({sprite}) => inputs => sprite.Say(inputs.message),
+ looks_think: ({sprite}) => inputs => sprite.Think(inputs.message),
+ looks_sayforsecs: ({sprite}) => inputs => sprite.SayFor(inputs.message, inputs.secs),
+ looks_thinkforsecs: ({sprite}) => inputs => sprite.ThinkFor(inputs.message, inputs.secs),
+ looks_show: ({sprite}) => () => sprite.Show(),
+ looks_hide: ({sprite}) => () => sprite.Hide(),
+ looks_changeeffectby: ({sprite}) => inputs => sprite.ChangeEffect(inputs.effect.toLowerCase(), inputs.change),
+ looks_seteffectto: ({sprite}) => inputs => sprite.SetEffect(inputs.effect.toLowerCase(), inputs.value),
+ looks_cleargraphiceffects: ({sprite}) => () => sprite.ClearEffects(),
+ looks_changesizeby: ({sprite}) => inputs => sprite.ChangeSize(inputs.change),
+ looks_setsizeto: ({sprite}) => inputs => sprite.SetSize(inputs.size),
+ looks_size: ({sprite}) => () => sprite.GetSize(),
+ looks_gotofrontback: ({sprite}) => inputs =>
+ {
+ if (inputs.front_back === "front") return sprite.GotoFront()
+ if (inputs.front_back === "back") return sprite.GotoBack()
+ },
+ looks_goforwardbackwardlayers: ({sprite}) => inputs =>
+ {
+ if (inputs.forward_backward === "forward") return sprite.GoForward(inputs.num)
+ if (inputs.forward_backward === "backward") return sprite.GoBackward(inputs.num)
+ },
+ looks_switchcostumeto: ({sprite}) => inputs => sprite.SetCostume(inputs.costume),
+ looks_nextcostume: ({sprite}) => () => sprite.NextCostume(),
+ looks_costumenumbername: ({sprite}) => inputs =>
+ {
+ if (inputs.number_name === "number") return sprite.CostumeNumber()
+ if (inputs.number_name === "name") return sprite.CostumeName()
+ },
+ looks_costume: () => inputs => String(inputs.costume),
+ looks_switchbackdropto: ({stage}) => inputs => stage.SetBackdrop(inputs.backdrop),
+ // looks_switchbackdroptoandwait: ...,
+ looks_backdrops: () => inputs => String(inputs.backdrop),
+ looks_nextbackdrop: ({stage}) => () => stage.NextBackdrop(),
+ looks_backdropnumbername: ({stage}) => inputs =>
+ {
+ if (inputs.number_name === "number") return stage.BackdropNumber()
+ if (inputs.number_name === "name") return stage.BackdropName()
+ },
+ // sound_play: ...,
+ // sound_playuntildone: ...,
+ // sound_stopallsounds: ...,
+ // sound_seteffectto: ...,
+ // sound_changeeffectby: ...,
+ // sound_cleareffects: ...,
+ // sound_changevolumeby: ...,
+ // sound_setvolumeto: ...,
+ // sound_volume: ...,
+ event_whenthisspriteclicked: ({sprite}) => () => sprite.WhenClicked(),
+ event_whenstageclicked: ({stage}) => () => stage.WhenClicked(),
+ event_whenbroadcastreceived: ({stage}) => inputs => stage.WhenReceived(inputs.broadcast_option),
+ // event_whengreaterthan: ...,
+ // event_whenbackdropswitchesto: ...,
+ event_broadcast: ({stage}) => inputs => stage.Broadcast(inputs.broadcast_input),
+ event_broadcastandwait: ({stage}) => inputs => stage.BroadcastAndWait(inputs.broadcast_input),
+ event_whenkeypressed: ({stage}) => inputs => stage.WhenKeyPressed(inputs.key_option),
+ control_start_as_clone: ({sprite}) => () => sprite.WhenCloned(),
+ control_create_clone_of: ({stage, sprite}) => inputs =>
+ {
+ if (inputs.clone_option === "_myself_") return sprite.Clone()
+ return stage.CloneSprite(inputs.clone_option)
+ },
+ control_create_clone_of_menu: () => inputs => inputs.clone_option,
+ control_delete_this_clone: ({sprite}) => () => sprite.Delete(),
+ sensing_touchingobject: ({sprite}) => inputs =>
+ {
+ // todo: allow sprite selection
+ if (inputs.touchingobjectmenu === "_mouse_") return sprite.TouchingMouse()
+ },
+ sensing_touchingobjectmenu: () => inputs => inputs.touchingobjectmenu,
+ // sensing_touchingcolor: ...,
+ // sensing_coloristouchingcolor: ...,
+ sensing_distanceto: ({sprite}) => inputs => sprite.DistanceToSprite(inputs.distancetomenu),
+ sensing_distancetomenu: () => inputs => inputs.distancetomenu,
+ // sensing_askandwait: ...,
+ // sensing_answer: ...,
+ sensing_keypressed: ({stage}) => inputs => stage.KeyPressed(inputs.key_option),
+ sensing_mousedown: ({stage}) => () => stage.MouseDown(),
+ sensing_mousex: ({stage}) => () => stage.GetMouseX(),
+ sensing_mousey: ({stage}) => () => stage.GetMouseY(),
+ // sensing_setdragmode: ...,
+ // sensing_loudness: ...,
+ // sensing_of: ...,
+ sensing_keyoptions: () => inputs => String(inputs.key_option),
+ // data_showlist: ...,
+ // data_hidelist: ...,
+
+ pen_clear: ({sprite}) => () => sprite.extensions.pen.blocks.Clear(),
+ pen_stamp: ({sprite}) => () => sprite.extensions.pen.blocks.Stamp(),
+ pen_penDown: ({sprite}) => () => sprite.extensions.pen.blocks.PenDown(),
+ pen_penUp: ({sprite}) => () => sprite.extensions.pen.blocks.PenUp(),
+ pen_setPenColorToColor: ({sprite}) => inputs => sprite.extensions.pen.blocks.SetPenColor(inputs.color),
+ pen_changePenSizeBy: ({sprite}) => inputs => sprite.extensions.pen.blocks.ChangePenSize(inputs.size),
+ pen_setPenSizeTo: ({sprite}) => inputs => sprite.extensions.pen.blocks.SetPenSize(inputs.size),
+ // pen_setPenColorParamTo: ...,
+ // pen_changePenColorParamBy: ...,
+ // pen_menu_colorParam: ...,
+
+ // music_playDrumForBeats: ...,
+ // music_restForBeats: ...,
+ music_playNoteForBeats: ({sprite, stage}) => inputs => (sprite ?? stage).extensions.music.blocks.PlayNote(inputs.note, inputs.beats),
+ // music_setInstrument: ...,
+ // music_setTempo: ...,
+ // music_changeTempo: ...,
+ note: () => inputs => String(inputs.note),
+ // music_getTempo: ...,
+
+ // videoSensing_whenMotionGreaterThan: ...,
+ // videoSensing_videoOn: ...,
+ // videoSensing_videoToggle: ...,
+ // videoSensing_setVideoTransparency: ...,
+ // text2speech_speakAndWait: ...,
+ // text2speech_setVoice: ...,
+ // text2speech_setLanguage: ...,
+ // translate_getTranslate: ...,
+ // translate_getViewerLanguage: ...,
+ // boost_motorOnFor: ...,
+ // boost_motorOnForRotation: ...,
+ // boost_motorOn: ...,
+ // boost_motorOff: ...,
+ // boost_setMotorPower: ...,
+ // boost_setMotorDirection: ...,
+ // boost_getMotorPosition: ...,
+ // boost_whenColor: ...,
+ // boost_seeingColor: ...,
+ // boost_whenTilted: ...,
+ // boost_getTiltAngle: ...,
+ // boost_setLightHue: ...,
+ // ev3_motorTurnClockwise: ...,
+ // ev3_motorTurnCounterClockwise: ...,
+ // ev3_motorSetPower: ...,
+ // ev3_getMotorPosition: ...,
+ // ev3_whenButtonPressed: ...,
+ // ev3_whenDistanceLessThan: ...,
+ // ev3_whenBrightnessLessThan: ...,
+ // ev3_buttonPressed: ...,
+ // ev3_getDistance: ...,
+ // ev3_getBrightness: ...,
+ // ev3_beep: ...,
+ // gdxfor_whenGesture: ...,
+ // gdxfor_whenForcePushedOrPulled: ...,
+ // gdxfor_getForce: ...,
+ // gdxfor_whenTilted: ...,
+ // gdxfor_isTilted: ...,
+ // gdxfor_getTilt: ...,
+ // gdxfor_isFreeFalling: ...,
+ // gdxfor_getSpinSpeed: ...,
+ // gdxfor_getAcceleration: ...,
+ // makeymakey_whenMakeyKeyPressed: ...,
+ // makeymakey_whenCodePressed: ...,
+ // microbit_whenButtonPressed: ...,
+ // microbit_isButtonPressed: ...,
+ // microbit_whenGesture: ...,
+ // microbit_displaySymbol: ...,
+ // microbit_displayText: ...,
+ // microbit_displayClear: ...,
+ // microbit_whenTilted: ...,
+ // microbit_isTilted: ...,
+ // microbit_getTiltAngle: ...,
+ // microbit_whenPinConnected: ...,
+ // wedo2_motorOnFor: ...,
+ // wedo2_motorOn: ...,
+ // wedo2_motorOff: ...,
+ // wedo2_startMotorPower: ...,
+ // wedo2_setMotorDirection: ...,
+ // wedo2_setLightHue: ...,
+ // wedo2_playNoteFor: ...,
+ // wedo2_whenDistance: ...,
+ // wedo2_whenTilted: ...,
+ // wedo2_getDistance: ...,
+ // wedo2_isTilted: ...,
+ // wedo2_getTiltAngle: ...,
+}
+
+let conversions1 = new Map([
+ [core.WhenFlagClicked.type, ["event_whenflagclicked"]],
+ [core.If.type, ["control_if", "CONDITION"]],
+ [core.Forever.type, ["control_forever"]],
+ [core.WaitUntil.type, ["control_wait_until", "CONDITION"]],
+ [core.RepeatUntil.type, ["control_repeat_until", "CONDITION"]],
+ [core.Repeat.type, ["control_repeat", "TIMES"]],
+ [core.Stop.type, ["control_stop", ["STOP_OPTION", "this script"]]],
+ [core.Add.type, ["operator_add", "NUM1", "NUM2"]],
+ [core.Subtract.type, ["operator_subtract", "NUM1", "NUM2"]],
+ [core.Multiply.type, ["operator_multiply", "NUM1", "NUM2"]],
+ [core.Divide.type, ["operator_divide", "NUM1", "NUM2"]],
+ [core.Modulo.type, ["operator_mod", "NUM1", "NUM2"]],
+ [core.LessThan.type, ["operator_lt", "OPERAND1", "OPERAND2"]],
+ [core.GreaterThan.type, ["operator_gt", "OPERAND1", "OPERAND2"]],
+ [core.Equals.type, ["operator_equals", "OPERAND1", "OPERAND2"]],
+ [core.And.type, ["operator_and", "OPERAND1", "OPERAND2"]],
+ [core.Or.type, ["operator_or", "OPERAND1", "OPERAND2"]],
+ [core.Not.type, ["operator_not", "OPERAND"]],
+ [core.Join.type, ["operator_join", "STRING1", "STRING2"]],
+ [core.TextIndex.type, ["operator_letter_of", "LETTER", "STRING"]],
+ [core.TextLength.type, ["operator_length", "STRING"]],
+ [core.TextContains.type, ["operator_contains", "STRING1", "STRING2"]],
+ [core.SetVariable.type, ["data_setvariableto", "VALUE"]],
+ [core.ChangeVariable.type, ["data_changevariableby", "VALUE"]],
+ [core.Append.type, ["data_addtolist", "ITEM"]],
+ [core.Delete.type, ["data_deleteoflist", "INDEX"]],
+ [core.Clear.type, ["data_deletealloflist"]],
+ [core.Insert.type, ["data_insertatlist", "INDEX", "ITEM"]],
+ [core.Replace.type, ["data_replaceitemoflist", "INDEX", "ITEM"]],
+ [core.Index.type, ["data_itemoflist", "INDEX"]],
+ [core.Find.type, ["data_itemnumoflist", "ITEM"]],
+ [core.Length.type, ["data_lengthoflist"]],
+ [core.Contains.type, ["data_listcontainsitem", "ITEM"]],
+])
+
+let stageConversions1 = stage => new Map([
+ [stage.scratch.Wait.type, ["control_wait", "DURATION"]],
+ [stage.scratch.Timer.type, ["sensing_timer"]],
+ [stage.scratch.ResetTimer.type, ["sensing_resettimer"]],
+ [stage.scratch.Username.type, ["sensing_username"]],
+ [stage.scratch.Round.type, ["operator_round", "NUM"]],
+ [stage.scratch.DaysSince2000.type, ["sensing_dayssince2000"]],
+ [stage.scratch.Abs.type, ["operator_mathop", "NUM", ["OPERATOR", "abs"]]],
+ [stage.scratch.Ceiling.type, ["operator_mathop", "NUM", ["OPERATOR", "ceiling"]]],
+ [stage.scratch.Floor.type, ["operator_mathop", "NUM", ["OPERATOR", "floor"]]],
+ [stage.scratch.Sqrt.type, ["operator_mathop", "NUM", ["OPERATOR", "sqrt"]]],
+ [stage.scratch.Sin.type, ["operator_mathop", "NUM", ["OPERATOR", "sin"]]],
+ [stage.scratch.Cos.type, ["operator_mathop", "NUM", ["OPERATOR", "cos"]]],
+ [stage.scratch.Tan.type, ["operator_mathop", "NUM", ["OPERATOR", "tan"]]],
+ [stage.scratch.ArcSin.type, ["operator_mathop", "NUM", ["OPERATOR", "asin"]]],
+ [stage.scratch.ArcCos.type, ["operator_mathop", "NUM", ["OPERATOR", "acos"]]],
+ [stage.scratch.ArcTan.type, ["operator_mathop", "NUM", ["OPERATOR", "atan"]]],
+ [stage.scratch.Ln.type, ["operator_mathop", "NUM", ["OPERATOR", "ln"]]],
+ [stage.scratch.Log.type, ["operator_mathop", "NUM", ["OPERATOR", "log"]]],
+ [stage.scratch.Exp.type, ["operator_mathop", "NUM", ["OPERATOR", "e ^"]]],
+ [stage.scratch.Exp10.type, ["operator_mathop", "NUM", ["OPERATOR", "10 ^"]]],
+ [stage.scratch.Year.type, ["sensing_current", ["CURRENTMENU", "YEAR"]]],
+ [stage.scratch.Month.type, ["sensing_current", ["CURRENTMENU", "MONTH"]]],
+ [stage.scratch.Day.type, ["sensing_current", ["CURRENTMENU", "DATE"]]],
+ [stage.scratch.DayOfWeek.type, ["sensing_current", ["CURRENTMENU", "DAYOFWEEK"]]],
+ [stage.scratch.Hour.type, ["sensing_current", ["CURRENTMENU", "HOUR"]]],
+ [stage.scratch.Minute.type, ["sensing_current", ["CURRENTMENU", "MINUTE"]]],
+ [stage.scratch.Second.type, ["sensing_current", ["CURRENTMENU", "SECOND"]]],
+ [stage.scratch.PickRandom.type, ["operator_random", "FROM", "TO"]],
+ [stage.scratch.StopOther.type, ["control_stop", ["STOP_OPTION", "other scripts in sprite"]]],
+ [stage.scratch.StopAll.type, ["control_stop", ["STOP_OPTION", "all"]]],
+ [stage.Broadcast.type, ["event_broadcast", ["BROADCAST_INPUT", "event_broadcast_menu", "BROADCAST_OPTION"]]],
+ [stage.BroadcastAndWait.type, ["event_broadcastandwait", ["BROADCAST_INPUT", "event_broadcast_menu", "BROADCAST_OPTION"]]],
+ [stage.MouseDown.type, ["sensing_mousedown"]],
+ [stage.getMouseX.type, ["sensing_mousex"]],
+ [stage.getMouseY.type, ["sensing_mousey"]],
+ [stage.SpriteX.type, ["sensing_of", ["PROPERTY", "x position"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+ [stage.SpriteY.type, ["sensing_of", ["PROPERTY", "y position"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+ [stage.SpriteDirection.type, ["sensing_of", ["PROPERTY", "direction"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+ [stage.SpriteSize.type, ["sensing_of", ["PROPERTY", "size"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+ [stage.SpriteCostumeName.type, ["sensing_of", ["PROPERTY", "costume name"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+ [stage.SpriteCostumeNumber.type, ["sensing_of", ["PROPERTY", "costume #"], ["OBJECT", "sensing_of_object_menu", "OBJECT"]]],
+])
+
+let spriteConversions = sprite => new Map([
+ ...stageConversions1(sprite.stage),
+ [sprite.Move.type, ["motion_movesteps", "STEPS"]],
+ [sprite.RotateRight.type, ["motion_turnleft", "DEGREES"]],
+ [sprite.RotateLeft.type, ["motion_turnright", "DEGREES"]],
+ [sprite.GotoXY.type, ["motion_gotoxy", "X", "Y"]],
+ [sprite.SetDirection.type, ["motion_pointindirection", "DIRECTION"]],
+ [sprite.SetX.type, ["motion_setx", "X"]],
+ [sprite.SetY.type, ["motion_sety", "Y"]],
+ [sprite.ChangeX.type, ["motion_changexby", "DX"]],
+ [sprite.ChangeY.type, ["motion_changeyby", "DY"]],
+ [sprite.SetSize.type, ["looks_setsizeto", "SIZE"]],
+ [sprite.ChangeSize.type, ["looks_changesizeby", "CHANGE"]],
+ [sprite.GetX.type, ["motion_xposition"]],
+ [sprite.GetY.type, ["motion_yposition"]],
+ [sprite.GetDirection.type, ["motion_direction"]],
+ [sprite.GetSize.type, ["looks_size"]],
+ [sprite.Show.type, ["looks_show"]],
+ [sprite.Hide.type, ["looks_hide"]],
+ [sprite.SetCostume.type, ["looks_switchcostumeto", ["COSTUME", "looks_costume", "COSTUME"]]],
+ [sprite.NextCostume.type, ["looks_nextcostume"]],
+ [sprite.CostumeName.type, ["looks_costumenumbername", ["NUMBER_NAME", "name"]]],
+ [sprite.CostumeNumber.type, ["looks_costumenumbername", ["NUMBER_NAME", "number"]]],
+ [sprite.GotoFront.type, ["looks_gotofrontback", ["FRONT_BACK", "front"]]],
+ [sprite.GotoBack.type, ["looks_gotofrontback", ["FRONT_BACK", "back"]]],
+ [sprite.GoForward.type, ["looks_goforwardbackwardlayers", ["FORWARD_BACKWARD", "forward"]]],
+ [sprite.GoBackward.type, ["looks_goforwardbackwardlayers", ["FORWARD_BACKWARD", "backward"]]],
+ [sprite.TouchingMouse.type, ["sensing_touchingobject", ["TOUCHINGOBJECTMENU", "sensing_touchingobjectmenu", "TOUCHINGOBJECTMENU", "_mouse_"]]],
+ [sprite.SetEffect.type, ["looks_seteffectto", "EFFECT:u", "VALUE"]],
+ [sprite.ChangeEffect.type, ["looks_changeeffectby", "EFFECT:u", "CHANGE"]],
+ [sprite.ClearEffects.type, ["looks_cleargraphiceffects"]],
+ [sprite.EnableRotation.type, ["motion_setrotationstyle", ["STYLE", "all around"]]],
+ [sprite.EnableFlipping.type, ["motion_setrotationstyle", ["STYLE", "left-right"]]],
+ [sprite.DisableRotation.type, ["motion_setrotationstyle", ["STYLE", "don't rotate"]]],
+ [sprite.Say.type, ["looks_say", "MESSAGE"]],
+ [sprite.Think.type, ["looks_think", "MESSAGE"]],
+ [sprite.SayFor.type, ["looks_sayforsecs", "MESSAGE", "SECS"]],
+ [sprite.ThinkFor.type, ["looks_thinkforsecs", "MESSAGE", "SECS"]],
+ [sprite.DistanceToSprite.type, ["sensing_distanceto", ["DISTANCETOMENU", "sensing_distancetomenu", "DISTANCETOMENU"]]],
+ [sprite.Clone.type, ["control_create_clone_of", ["CLONE_OPTION", "control_create_clone_of_menu", "CLONE_OPTION", "_myself_"]]],
+ [sprite.WhenCloned.type, ["control_start_as_clone"]],
+ [sprite.extensions.pen?.blocks.Clear.type, ["pen_clear"]],
+ [sprite.extensions.pen?.blocks.PenDown.type, ["pen_penDown"]],
+ [sprite.extensions.pen?.blocks.PenUp.type, ["pen_penUp"]],
+ [sprite.extensions.pen?.blocks.SetPenColor.type, ["pen_setPenColorToColor", "COLOR"]],
+ [sprite.extensions.pen?.blocks.SetPenSize.type, ["pen_setPenSizeTo", "SIZE"]],
+ [sprite.extensions.pen?.blocks.Stamp.type, ["pen_stamp"]],
+])
--- /dev/null
+++ b/hash.js
@@ -1,0 +1,49 @@
+import fs from "node:fs/promises"
+import {MD5} from "./md5.js"
+import {spawn} from "node:child_process"
+import {Writable} from "node:stream"
+
+let packages = ["scratch-l10n@6.0.38", "path-data-polyfill@1.0.10"]
+let names0 = ["index.html", "about.html", "icon.svg"]
+let names = ["areas", "compile", "core", "external", "index", "md5", "model", "music", "paint", "pen", "scratch", "stage", "syntax", "translation", "view", "zip", "worklet"]
+
+await fs.rm("hash", {recursive: true, force: true})
+await fs.mkdir("hash/assets", {recursive: true})
+for (let arg of await fs.readdir("assets")) {
+ if (!arg.endsWith(".mp3") && !arg.endsWith(".png")) continue
+ let name = "assets/" + arg
+ await fs.copyFile(name, `hash/${name}`)
+}
+
+let imports = {}
+
+for (let spec of packages) {
+ let [name, version] = spec.split("@")
+ await fs.mkdir(`hash/packages/${name}`, {recursive: true})
+ let response = await fetch(`https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`)
+ if (!response.ok) throw new Error("response not ok")
+ let process = spawn("tar", ["--strip-components=1", "-xf", "-"], {cwd: `hash/packages/${name}`})
+ response.body.pipeTo(Writable.toWeb(process.stdin))
+ await new Promise(resolve => process.on("close", resolve))
+ imports[name + "/"] = `./packages/${name}/`
+}
+
+for (let name of await fs.readdir("hash/packages", {recursive: true})) {
+ name = `hash/packages/${name}`
+ if (name.endsWith(".mjs")) await fs.rename(name, `${name.slice(0, -4)}.js`)
+}
+
+for (let name of names) {
+ let file = await fs.readFile(`${name}.js`)
+ let md5 = MD5(file).slice(0, 8)
+ imports[`./${name}.js`] = `./${name}-${md5}.js`
+ await fs.writeFile(`hash/${name}-${md5}.js`, file)
+}
+
+let importMap = JSON.stringify({imports})
+
+for (let name of names0) {
+ let file = await fs.readFile(name, "utf-8")
+ if (name.endsWith(".html")) file = file.replace(/(<script type="importmap">).*?(<\/script>)/mg, `$1${importMap}$2`)
+ await fs.writeFile(`hash/${name}`, file)
+}
--- /dev/null
+++ b/icon.svg
@@ -1,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="31 2 14 15" fill="none" stroke-linejoin="round" stroke-linecap="round">
+ <path id="x" d="m39 5 3 1m-6 8 4-8m-5 8h3m2 0 2-1m-6-6c1 1 4 5 5 6m-7-6 3-1" />
+ <use href="#x" stroke="#FFF" stroke-width="6" />
+ <use href="#x" stroke="#A4E" stroke-width="4" />
+ <use href="#x" stroke-width="1.5" stroke="#FFF" />
+</svg>
--- /dev/null
+++ b/index.html
@@ -1,0 +1,76 @@
+<!doctype html>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<link rel="icon" href="icon.svg">
+<title> Scrax </title>
+
+<script type="importmap">{"imports": {"scratch-l10n/locales/blocks-msgs.js": "./translations.js", "scratch-l10n/locales/editor-msgs.js": "./translations.js", "scratch-l10n/src/supported-locales.js": "./translations.js", "path-data-polyfill/path-data-polyfill.js": "https://esm.sh/path-data-polyfill@1.0.10"}}</script>
+
+<script type="module">
+
+addEventListener("error", error)
+addEventListener("unhandledrejection", error)
+import("./index.js").then(removeListener)
+
+function error(event)
+{
+ var error = event.reason || event.error
+ var message = String(error) + "\n"
+ if (error.stack) message += String(error.stack) + "\n"
+ document.body.textContent = "early error: " + message
+}
+
+function removeListener()
+{
+ document.querySelector(".throbber").remove()
+ removeEventListener("error", error)
+ removeEventListener("unhandledrejection", error)
+}
+
+</script>
+
+<style>
+
+body:has(.throbber) {
+ background: #EDF;
+}
+
+.throbber {
+ max-width: 25%;
+ margin: 0 auto;
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.throbber circle {
+ animation: throb 1s ease-in-out alternate infinite;
+}
+
+@keyframes throb {
+ from {
+ transform: scale(0.75);
+ opacity: 0.25;
+ }
+}
+
+body:has(> input) .throbber {
+ display: none;
+}
+
+</style>
+
+<svg class="throbber" viewbox="-8 -8 16 16" fill="#A4E">
+ <circle r="1" style="animation-delay: -0.75s;" cx="-6" />
+ <circle r="1" style="animation-delay: -0.5s;" cx="-2" />
+ <circle r="1" style="animation-delay: -0.25s;" cx="2" />
+ <circle r="1" style="animation-delay: 0s;" cx="6" />
+</svg>
+
+<script nomodule> document.body.textContent = "module support is required" </script>
+
+<noscript>
+<style> .throbber { display: none; } </style>
+<p> scripting is required </p>
+</noscript>
--- /dev/null
+++ b/index.js
@@ -1,0 +1,850 @@
+import {categories} from "./view.js"
+import {Stage, SpriteAreas, StageAreas} from "./stage.js"
+import {load, fromSB3, toSB3} from "./external.js"
+import {Scrollable} from "./areas.js"
+import {CostumeAreas} from "./paint.js"
+import {getMessage, getName, locale} from "./translation.js"
+import * as core from "./core.js"
+
+let buttonsElement = document.createElement("div")
+let spriteListElement = document.createElement("div")
+let categoryListElement = document.createElement("div")
+let startButton = document.createElement("button")
+let stopButton = document.createElement("button")
+let expandButton = document.createElement("button")
+let downloadButton = document.createElement("button")
+let uploadButton = document.createElement("a")
+let logoElement = document.createElement("a")
+let controlElement = document.createElement("div")
+let codeTabButton = document.createElement("button")
+let costumeTabButton = document.createElement("button")
+let soundTabButton = document.createElement("button")
+let addSpriteButton = document.createElement("button")
+let errorButton = document.createElement("button")
+
+buttonsElement.classList.add("buttons")
+spriteListElement.classList.add("sprite-list")
+categoryListElement.classList.add("categories")
+codeTabButton.classList.add("code-tab")
+costumeTabButton.classList.add("costume-tab")
+soundTabButton.classList.add("sound-tab")
+controlElement.classList.add("sprite-control")
+addSpriteButton.classList.add("add-sprite")
+startButton.classList.add("start-button")
+stopButton.classList.add("stop-button")
+expandButton.classList.add("expand-button")
+downloadButton.classList.add("download-button")
+uploadButton.classList.add("upload-button", "button")
+logoElement.classList.add("logo")
+
+startButton.title = getMessage("gui.controls.go") ?? "go"
+stopButton.title = getMessage("gui.controls.stop") ?? "stop"
+expandButton.title = getMessage("gui.stageHeader.stageSizeFull") ?? "full screen"
+downloadButton.title = getMessage("gui.menuBar.downloadToComputer") ?? "download"
+uploadButton.title = getMessage("gui.sharedMessages.loadFromComputerTitle") ?? "upload"
+codeTabButton.title = getMessage("gui.gui.codeTab") ?? "code"
+soundTabButton.title = getMessage("gui.gui.soundsTab") ?? "sounds"
+
+startButton.append(getIcon("green-flag"))
+stopButton.append(getIcon("stop-sign"))
+expandButton.append(getIcon("expand"))
+downloadButton.append(getIcon("download"))
+uploadButton.append(getIcon("upload"))
+codeTabButton.append(getIcon("code"))
+costumeTabButton.append(getIcon("costumes"))
+soundTabButton.append(getIcon("sounds"))
+logoElement.insertAdjacentHTML("beforeend", getLogo())
+addSpriteButton.append(getIcon("add"))
+errorButton.append(getIcon("error"))
+
+buttonsElement.append(logoElement, startButton, stopButton, expandButton, uploadButton, downloadButton, errorButton)
+controlElement.append(codeTabButton, costumeTabButton, soundTabButton)
+spriteListElement.append(addSpriteButton)
+
+uploadButton.href = "?upload"
+logoElement.href = "about"
+errorButton.style.setProperty("display", "none", "important")
+soundTabButton.style.setProperty("display", "none")
+
+startButton.addEventListener("click", () => stage.start())
+stopButton.addEventListener("click", () => stage.stop())
+expandButton.addEventListener("click", () => stage.element.requestFullscreen())
+downloadButton.addEventListener("click", async () => download(await toSB3(stage), stage.name + ".sb3"))
+addSpriteButton.addEventListener("click", addSprite)
+codeTabButton.addEventListener("click", codeTab)
+costumeTabButton.addEventListener("click", costumeTab)
+addEventListener("error", ({error}) => logError(error))
+addEventListener("unhandledrejection", ({reason}) => logError(reason))
+errorButton.addEventListener("click", showErrors)
+
+let categoryNames = new Map()
+
+for (let category of categories) {
+ if (category === "lists") continue
+ let key = "CATEGORY_" + category.toUpperCase()
+ if (category === "custom") key = "CATEGORY_MYBLOCKS"
+ if (category === "extensions") key = "gui.gui.addExtension"
+ let names = {extensions: "add extension", custom: "my blocks"}
+ let name = getMessage(key) ?? names[category] ?? category
+ categoryNames.set(category, name)
+ let button = document.createElement("button")
+ button.classList.add(category)
+ button.append(name)
+ categoryListElement.append(button)
+ button.addEventListener("click", () =>
+ {
+ let element = document.querySelector(".category-name-" + category)
+ if (!element) return
+ element.closest(".library").scrollTo({top: element.offsetTop - 16, behavior: "smooth"})
+ })
+}
+
+new Scrollable({element: spriteListElement, y: false})
+let spriteListScroll = addScrollbars(spriteListElement, ["horizontal"])
+spriteListScroll.classList.add("sprite-list-scroll")
+
+let map = new Map()
+
+async function main()
+{
+ try {
+ return await loadStage() ?? makeStage()
+ }
+ catch (error) {
+ if (location.search !== "") location.href = location.pathname
+ throw error
+ }
+}
+
+async function fetchCostume(sprite, url, x, y)
+{
+ let response = await fetch(url)
+ if (!response.ok) throw new Error()
+ let blob = await response.blob()
+ return sprite.makeCostume({blob, x, y})
+}
+
+async function loadStage()
+{
+ let query = location.search.slice(1)
+
+ if (query === "upload") {
+ let input = document.createElement("input")
+ input.type = "file"
+ input.style.cssText = "font-size: 2em; position: fixed; bottom: 50%; right: 50%; transform: translate(50%, 50%);"
+ document.body.append(input)
+ let handler = event => input.contains(event.target) || input.click()
+ addEventListener("click", handler)
+ let buffer = await new Promise((resolve, reject) =>
+ {
+ input.addEventListener("change", () =>
+ {
+ if (input.files.length === 0) return
+ let reader = new FileReader()
+ reader.readAsArrayBuffer(input.files[0])
+ reader.addEventListener("error", () => reject(reader.error))
+ reader.addEventListener("load", () => resolve(reader.result))
+ })
+ })
+ input.remove()
+ removeEventListener("click", handler)
+ let stage = await fromSB3(new Uint8Array(buffer))
+ if (stage) {
+ let name = input.files[0].name
+ if (name.endsWith(".sb3")) name = name.slice(0, -4)
+ stage.name = name
+ }
+ return stage
+ }
+
+ if (/[^0-9]/.test(query)) {
+ let response = await fetch(query)
+ if (!response.ok) return
+ let stage = await fromSB3(new Uint8Array(await response.arrayBuffer()))
+ if (stage) {
+ let name = decodeURIComponent(new URL(query, location.href).pathname.split("/").at(-1) ?? "")
+ if (name.endsWith(".sb3")) name = name.slice(0, -4)
+ if (name) stage.name = name
+ }
+ return stage
+ }
+
+ if (query) return load(query)
+}
+
+async function makeStage()
+{
+ let stage = new Stage({name: getMessage("gui.gui.defaultProjectTitle")})
+ stage.variables.set(getMessage("gui.defaultProject.variable") ?? "my variable", {value: 0})
+
+ let sprite = stage.makeSprite({name: getMessage("gui.SpriteInfo.sprite", m => m + "1")})
+ sprite.costumes[0].remove()
+
+ try {
+ sprite.costume = await fetchCostume(sprite, "assets/costume1.png", 140, 180)
+ await fetchCostume(sprite, "assets/costume2.png", 150, 190)
+ }
+ catch (error) {
+ logError(error)
+ }
+
+ return stage
+}
+
+function addSprite()
+{
+ let sprite = stage.makeSprite({name: getMessage("gui.SpriteInfo.sprite", m => m + (stage.sprites.length + 1))})
+ registerSprite(sprite)
+ show(sprite)
+ spriteListElement.scrollTo({left: spriteListElement.scrollWidth, behavior: "smooth"})
+}
+
+function registerSprite(sprite, Areas = SpriteAreas, name = sprite.name)
+{
+ let areas = new Areas(sprite, {
+ Make,
+ getName: (...args) => getName(sprite.stage ?? stage, ...args),
+ toCategoryName: name => categoryNames.get(name),
+ getExtensionName: name => getMessage(`gui.extension.${name}.name`) ?? name,
+ messages: {
+ getMissing: name => name === "motion" ? getMessage("MOTION_STAGE_SELECTED") ?? "stage selected: no motion blocks" : undefined,
+ getOK: () => getMessage("gui.prompt.ok"),
+ getCancel: () => getMessage("gui.prompt.cancel"),
+ getVariable: () => getMessage("NEW_VARIABLE"),
+ getList: () => getMessage("NEW_LIST"),
+ getBlock: () => getMessage("NEW_PROCEDURE"),
+ getAtomic: () => getMessage("gui.customProcedures.runWithoutScreenRefresh"),
+ getTextInput: () => getMessage("gui.customProcedures.addAnInputNumberText", m => m + ` (${getMessage("gui.customProcedures.numberTextType")})`),
+ getBooleanInput: () => getMessage("gui.customProcedures.addAnInputBoolean", m => m + ` (${getMessage("gui.customProcedures.booleanType")})`),
+ getTextInputName: () => getMessage("gui.customProcedures.numberTextType"),
+ getBooleanInputName: () => getMessage("gui.customProcedures.booleanType"),
+ getBlockName: () => getMessage("PROCEDURE_DEFAULT_NAME"),
+ getLocal: () => getMessage("gui.gui.variableScopeOptionSpriteOnly"),
+ getGlobal: () => getMessage("gui.gui.variableScopeOptionAllSprites"),
+ getMessage: () => getMessage("DEFAULT_BROADCAST_MESSAGE_NAME"),
+ getAddMessage: () => getMessage("NEW_BROADCAST_MESSAGE"),
+ getLabel: () => getMessage("gui.customProcedures.addALabel"),
+ },
+ })
+
+ let isStage = Areas === StageAreas
+ let button = document.createElement("button")
+ let costumeAreas
+ if (isStage) costumeAreas = new CostumeAreas(sprite.backdrops, {select: backdrop => sprite.backdrop = backdrop})
+ else costumeAreas = new CostumeAreas(sprite.costumes, {select: costume => sprite.costume = costume})
+
+ button.append(name)
+ addSpriteButton.before(button)
+ button.addEventListener("click", () => show(sprite))
+
+ let libraryScroll = addScrollbars(areas.libraryArea.element, ["vertical"])
+ let programScroll = addScrollbars(areas.programArea.element)
+ libraryScroll.classList.add("library-scroll")
+ programScroll.classList.add("program-scroll")
+
+ map.set(sprite, {
+ sprite,
+ areas, costumeAreas,
+ button, isStage,
+ elements: {
+ library: libraryScroll,
+ program: programScroll,
+ costumes: costumeAreas.costumeListElement,
+ paint: costumeAreas.paintElement,
+ toolbox: costumeAreas.toolboxElement,
+ },
+ })
+}
+
+function Make(area, make)
+{
+ if (locale === "en") return
+ if (make.type === core.Join.type) return () => make(getMessage("OPERATORS_JOIN_APPLE", m => m + " "), getMessage("OPERATORS_JOIN_BANANA"))
+ if (make.type === core.TextIndex.type) return () => make(getMessage("OPERATORS_JOIN_APPLE"))
+ if (make.type === core.TextLength.type) return () => make(getMessage("OPERATORS_JOIN_APPLE"))
+ if (make.type === core.TextContains.type) return () => make(getMessage("OPERATORS_JOIN_APPLE"), getMessage("OPERATORS_LETTEROF_APPLE"))
+ let [list] = (area.sprite ?? area.stage).lists.keys()
+ if (make.type === core.Append.type) return () => make(list, getMessage("DEFAULT_LIST_ITEM"))
+ if (make.type === core.Insert.type) return () => make(list, getMessage("DEFAULT_LIST_ITEM"))
+ if (make.type === core.Replace.type) return () => make(list, 1, getMessage("DEFAULT_LIST_ITEM"))
+ if (make.type === core.Find.type) return () => make(list, getMessage("DEFAULT_LIST_ITEM"))
+ if (make.type === core.Contains.type) return () => make(list, getMessage("DEFAULT_LIST_ITEM"))
+ if (make.type === area.stage.WhenReceived.type) return () => make(getMessage("DEFAULT_BROADCAST_MESSAGE_NAME"))
+ if (make.type === area.stage.Broadcast.type) return () => make(getMessage("DEFAULT_BROADCAST_MESSAGE_NAME"))
+ if (make.type === area.stage.BroadcastAndWait.type) return () => make(getMessage("DEFAULT_BROADCAST_MESSAGE_NAME"))
+ if (!area.sprite) return
+ if (make.type === area.sprite.Say.type) return () => make(getMessage("LOOKS_HELLO"))
+ if (make.type === area.sprite.SayFor.type) return () => make(getMessage("LOOKS_HELLO"))
+ if (make.type === area.sprite.Think.type) return () => make(getMessage("LOOKS_HMM"))
+ if (make.type === area.sprite.ThinkFor.type) return () => make(getMessage("LOOKS_HMM"))
+}
+
+let shown
+function show(sprite)
+{
+ if (shown) {
+ shown.elements.library.remove()
+ shown.elements.program.remove()
+ shown.elements.paint.remove()
+ shown.elements.costumes.remove()
+ shown.elements.toolbox.remove()
+ shown.button.disabled = false
+ }
+
+ shown = map.get(sprite)
+ shown.button.disabled = true
+
+ if (shown.isStage) costumeTabButton.title = getMessage("gui.gui.backdropsTab") ?? "backdrops"
+ else costumeTabButton.title = getMessage("gui.gui.costumesTab") ?? "costumes"
+
+ codeTab()
+}
+
+function codeTab()
+{
+ codeTabButton.disabled = true
+ costumeTabButton.disabled = false
+ soundTabButton.disabled = false
+
+ shown.elements.paint.remove()
+ shown.elements.costumes.remove()
+ shown.elements.toolbox.remove()
+ document.body.append(shown.elements.library, shown.elements.program, categoryListElement)
+
+ shown.areas.programArea.element.dispatchEvent(new Event("scroll"))
+ shown.areas.libraryArea.element.dispatchEvent(new Event("scroll"))
+
+ shown.areas.update()
+}
+
+function costumeTab()
+{
+ codeTabButton.disabled = false
+ costumeTabButton.disabled = true
+ soundTabButton.disabled = false
+
+ categoryListElement.remove()
+ shown.elements.library.remove()
+ shown.elements.program.remove()
+ document.body.append(shown.elements.toolbox, shown.elements.costumes, shown.elements.paint)
+
+ shown.elements.costumes.querySelectorAll(".costume-list > button")[(shown.isStage ? shown.sprite.backdropNumber() : shown.sprite.costumeNumber()) - 1].click()
+}
+
+function addScrollbar(element, type)
+{
+ let scrollbar = document.createElement("div")
+
+ scrollbar.classList.add("scrollbar", "scrollbar-" + type)
+ scrollbar.style.setProperty("--x", "0")
+
+ let down = false
+ addEventListener("pointerup", () => down = false)
+ scrollbar.addEventListener("pointerdown", update)
+
+ addEventListener("pointermove", event => down && update(event))
+
+ element.addEventListener("scroll", () =>
+ {
+ let progress
+ if (type === "vertical") progress = element.scrollTop / (element.scrollHeight - element.clientHeight)
+ if (type === "horizontal") progress = element.scrollLeft / (element.scrollWidth - element.clientWidth)
+ scrollbar.style.setProperty("--x", String(progress || 0))
+ })
+
+ scrollbar.addEventListener("contextmenu", event => event.preventDefault())
+
+ function update(event)
+ {
+ down = true
+ let rect = scrollbar.getBoundingClientRect()
+ if (type === "vertical") {
+ let progress = (event.y - rect.y - rect.height * 0.125) / rect.height / 0.75
+ element.scrollTo({top: progress * (element.scrollHeight - element.clientHeight), behavior: "instant"})
+ }
+ if (type === "horizontal") {
+ if (scrollbar.matches(":dir(rtl)")) {
+ let progress = (- rect.right + rect.width * 0.125) / rect.width / 0.75
+ element.scrollTo({left: progress * (element.scrollWidth - element.clientWidth), behavior: "instant"})
+ }
+ else {
+ let progress = (event.x - rect.x - rect.width * 0.125) / rect.width / 0.75
+ element.scrollTo({left: progress * (element.scrollWidth - element.clientWidth), behavior: "instant"})
+ }
+ }
+ }
+
+ return scrollbar
+}
+
+function addScrollbars(element, types = ["horizontal", "vertical"])
+{
+ let div = document.createElement("div")
+ div.append(element, ...types.map(type => addScrollbar(element, type)))
+ return div
+}
+
+function download(blob, name)
+{
+ let a = document.createElement("a")
+ document.body.append(a)
+ let url = URL.createObjectURL(blob)
+ a.href = url
+ a.download = name
+ a.click()
+ URL.revokeObjectURL(url)
+ a.remove()
+}
+
+let errors = ""
+
+function logError(error)
+{
+ errorButton.removeAttribute("style")
+ errors += String(error) + "\n"
+ if (error.stack) errors += String(error.stack) + "\n"
+ errors += "\n"
+}
+
+function showErrors()
+{
+ let dialogue = document.createElement("dialog")
+ dialogue.addEventListener("close", () => dialogue.remove())
+
+ let pre = document.createElement("pre")
+ pre.append(errors.trimEnd())
+ dialogue.append(pre)
+
+ let form = document.createElement("form")
+ form.method = "dialog"
+ dialogue.append(form)
+
+ let button = document.createElement("button")
+ button.append("okay")
+ form.append(button)
+
+ document.body.append(dialogue)
+ dialogue.showModal()
+}
+
+function getIcon(name)
+{
+ let icons = {
+ "green-flag": `<svg viewbox="0 0 24 24" fill="#6C6" stroke="#444"><path d="M20.45,5.37C20.71,4.71,20.23,4,19.52,4H13h-1H7V3c0-0.55-0.45-1-1-1h0C5.45,2,5,2.45,5,3v1v10v8h2v-8h4h1h7.52 c0.71,0,1.19-0.71,0.93-1.37L19,9L20.45,5.37z"/></svg>`,
+ "stop-sign": `<svg viewbox="0 0 24 24" fill="#E24" stroke="#444"><path d="M14.9,3H9.1C8.57,3,8.06,3.21,7.68,3.59l-4.1,4.1C3.21,8.06,3,8.57,3,9.1v5.8c0,0.53,0.21,1.04,0.59,1.41l4.1,4.1 C8.06,20.79,8.57,21,9.1,21h5.8c0.53,0,1.04-0.21,1.41-0.59l4.1-4.1C20.79,15.94,21,15.43,21,14.9V9.1c0-0.53-0.21-1.04-0.59-1.41 l-4.1-4.1C15.94,3.21,15.43,3,14.9,3z"/></svg>`,
+ expand: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M6 14c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h3c.55 0 1-.45 1-1s-.45-1-1-1H7v-2c0-.55-.45-1-1-1zm0-4c.55 0 1-.45 1-1V7h2c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1zm11 7h-2c-.55 0-1 .45-1 1s.45 1 1 1h3c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1s-1 .45-1 1v2zM14 6c0 .55.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1V6c0-.55-.45-1-1-1h-3c-.55 0-1 .45-1 1z"/></svg>`,
+ download: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M16.59 9H15V4c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1v5H7.41c-.89 0-1.34 1.08-.71 1.71l4.59 4.59c.39.39 1.02.39 1.41 0l4.59-4.59c.63-.63.19-1.71-.7-1.71zM5 19c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1z"/></svg>`,
+ upload: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M10 16h4c.55 0 1-.45 1-1v-5h1.59c.89 0 1.34-1.08.71-1.71L12.71 3.7c-.39-.39-1.02-.39-1.41 0L6.71 8.29c-.63.63-.19 1.71.7 1.71H9v5c0 .55.45 1 1 1zm-4 2h12c.55 0 1 .45 1 1s-.45 1-1 1H6c-.55 0-1-.45-1-1s.45-1 1-1z"/></svg>`,
+ add: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M18 13h-5v5c0 .55-.45 1-1 1s-1-.45-1-1v-5H6c-.55 0-1-.45-1-1s.45-1 1-1h5V6c0-.55.45-1 1-1s1 .45 1 1v5h5c.55 0 1 .45 1 1s-.45 1-1 1z"/></svg>`,
+ code: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M4,7v2c0,0.55-0.45,1-1,1h0c-0.55,0-1,0.45-1,1v2c0,0.55,0.45,1,1,1h0c0.55,0,1,0.45,1,1v2c0,1.66,1.34,3,3,3h2 c0.55,0,1-0.45,1-1v0c0-0.55-0.45-1-1-1H7c-0.55,0-1-0.45-1-1v-2c0-1.3-0.84-2.42-2-2.83v-0.34C5.16,11.42,6,10.3,6,9V7 c0-0.55,0.45-1,1-1h2c0.55,0,1-0.45,1-1v0c0-0.55-0.45-1-1-1H7C5.34,4,4,5.34,4,7z"/><path d="M21,10c-0.55,0-1-0.45-1-1V7c0-1.66-1.34-3-3-3h-2c-0.55,0-1,0.45-1,1v0c0,0.55,0.45,1,1,1h2c0.55,0,1,0.45,1,1v2 c0,1.3,0.84,2.42,2,2.83v0.34c-1.16,0.41-2,1.52-2,2.83v2c0,0.55-0.45,1-1,1h-2c-0.55,0-1,0.45-1,1v0c0,0.55,0.45,1,1,1h2 c1.66,0,3-1.34,3-3v-2c0-0.55,0.45-1,1-1h0c0.55,0,1-0.45,1-1v-2C22,10.45,21.55,10,21,10L21,10z"/></svg>`,
+ costumes: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`,
+ sounds: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z"/></svg>`,
+ error: `<svg viewbox="0 0 24 24" fill="#EA4" stroke="#444"><path d="M4.47 21h15.06c1.54 0 2.5-1.67 1.73-3L13.73 4.99c-.77-1.33-2.69-1.33-3.46 0L2.74 18c-.77 1.33.19 3 1.73 3zM12 14c-.55 0-1-.45-1-1v-2c0-.55.45-1 1-1s1 .45 1 1v2c0 .55-.45 1-1 1zm1 4h-2v-2h2v2z"/></svg>`,
+ }
+
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icons[name])
+ span.classList.add("icon")
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+function getLogo()
+{
+ return `
+<svg viewbox="0.75 0.5 44.5 17.75" fill="none" stroke-linejoin="round" stroke-linecap="round">
+ <g id="scrax">
+ <path id="s" d="M9 4v4s0-3-3-3c-2 0-3 5 1 5 3 0 3 5 0 5s-3-4-3-4v4" />
+ <path id="c" d="M16 6v3s0-2-2-2-3 1-3 4c0 1 1 3 3 3 3 0 3-3 3-3" />
+ <path id="r" d="M27 14c-5 2 0-6-8-5m-1 6 4-1m-3-9 1 4v5m-2-9c8-1 8 4 1 4" />
+ <path id="a" d="m32 13 3-1m-6-2 3-1m-6 3 4 1m-2-1c1-3 1-6 2-8m-3 1c4-2 3-3 6 7" />
+ <path id="x" d="m39 5 3 1m-6 8 4-8m-5 8h3m2 0 2-1m-6-6c1 1 4 5 5 6m-7-6 3-1" />
+ </g>
+ <use href="#scrax" stroke="#444" stroke-width="6.25" />
+ <use href="#scrax" stroke="#FFF" stroke-width="6" />
+ <use href="#scrax" stroke="#444" stroke-width="1.75" filter="drop-shadow(0.25px 0.5px 0.5px #4444)" />
+ <g stroke-width="1.5">
+ <use href="#s" stroke="#9DF" />
+ <use href="#c" stroke="#F9D" />
+ <use href="#r" stroke="#9DF" />
+ <use href="#a" stroke="#F9D" />
+ <use href="#x" stroke="#D9F" />
+ </g>
+</svg>`
+}
+
+let css = `
+*, ::before, ::after {
+ box-sizing: border-box;
+ scrollbar-width: none;
+}
+
+::-webkit-scrollbar {
+ display: none;
+}
+
+html, body {
+ height: 100%;
+ touch-action: pan-x pan-y;
+}
+
+body {
+ font-family:
+ "DejaVu Sans",
+ "DejaVu LGC Sans",
+ "Verdana",
+ "Bitstream Vera Sans",
+ "Geneva",
+ sans-serif;
+ margin: 0;
+ display: grid;
+ grid-template:
+ "buttons sprites control" auto
+ "stage main main" auto
+ "top main main" auto
+ "bottom main main" 1fr
+ / 360px 1fr auto;
+ -moz-user-select: -moz-none;
+ -webkit-user-select: none;
+ user-select: none;
+ background: #FAFCFF;
+ gap: 8px;
+ padding: 8px;
+}
+
+.buttons { grid-area: buttons; }
+.library-scroll { grid-area: bottom; }
+.program-scroll { grid-area: main; }
+.stage { grid-area: stage; }
+.sprite-list-scroll { grid-area: sprites; }
+.categories { grid-area: top; }
+.sprite-control { grid-area: control; }
+.paint-container { grid-area: main; }
+.costume-list { grid-area: bottom; }
+.toolbox-container { grid-area: top; }
+
+.library-scroll, .program-scroll, .sprite-list-scroll {
+ min-width: 0;
+ min-height: 0;
+ position: relative;
+ display: grid;
+}
+
+.stage, .program, .paint-container, .library {
+ background: #FFF;
+ border: 1px solid #68A;
+ border-radius: 8px;
+}
+
+.stage {
+ background: #DEF;
+ width: 100%;
+}
+
+.stage:fullscreen {
+ border: none;
+ border-radius: 0;
+}
+
+.sprite-list {
+ display: grid;
+ gap: 8px;
+ justify-content: start;
+ overflow: auto hidden;
+ grid-auto-flow: column;
+ margin: -4px 0 -4px;
+ margin-inline-start: -9px;
+ border-left: 1px solid #68A;
+ border-right: 1px solid #68A;
+ padding: 4px 8px;
+}
+
+.sprite-list button {
+ overflow: hidden;
+ width: 128px;
+ min-width: max-content;
+}
+
+.sprite-list .add-sprite {
+ width: auto;
+}
+
+.program {
+ position: relative;
+ overflow: auto scroll;
+ touch-action: pan-x pan-y;
+ padding: 32px;
+ display: grid;
+ gap: 32px;
+ align-content: start;
+ min-height: 100%;
+}
+
+.categories {
+ font-size: 0.5em;
+ display: grid;
+ grid-template: 1fr 1fr / 1fr 1fr 1fr 1fr 1fr;
+ gap: 8px;
+}
+
+.categories button {
+ display: grid;
+ justify-items: center;
+}
+
+.categories button::before {
+ content: "";
+ background: linear-gradient(#FFF4, #0000 75%) var(--color);
+ width: 24px;
+ height: 24px;
+ border: 2px solid #0006;
+ border-color: oklab(from var(--color) calc(l - 0.25) a b);
+ border-radius: 256px;
+ margin: 0 auto;
+}
+
+.library {
+ padding: 16px;
+ overflow: hidden auto;
+ touch-action: pan-y;
+ display: grid;
+ gap: 32px;
+ align-content: start;
+ background: #DEF;
+ position: relative;
+ width: 0;
+ min-width: 100%;
+}
+
+.library-category {
+ display: grid;
+ gap: 24px;
+}
+
+.category-name {
+ font-weight: bold;
+ margin: 0 0 16px;
+}
+
+.sprite-control {
+ display: grid;
+ grid-auto-flow: column;
+ gap: 8px;
+ justify-content: start;
+}
+
+.buttons {
+ display: grid;
+ grid-auto-flow: column;
+ gap: 8px;
+ justify-content: start;
+}
+
+.logo {
+ display: grid;
+ align-items: center;
+ transition: 0.25s ease-in-out filter;
+}
+
+.logo:hover {
+ filter: drop-shadow(0 0 2px #0001);
+}
+
+.logo > svg {
+ height: 32px;
+}
+
+button, .button {
+ color: #444;
+ background: linear-gradient(#FFF8, #0000, #48F1) #FAFAFA;
+ border: 1px solid #68A;
+ font: inherit;
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 8px;
+ text-align: center;
+ transition: 0.125s ease-in-out background;
+ display: inline-grid;
+ align-items: center;
+ justify-items: center;
+}
+
+.button > .icon:only-child, button > .icon:only-child {
+ margin: 0 -4px;
+}
+
+button:hover, .button:hover {
+ background-color: #DEF;
+}
+
+.start-button:hover {
+ background-color: #DFE;
+}
+
+.stop-button:hover {
+ background-color: #FDE;
+}
+
+button:active, .button:active {
+ background-image: linear-gradient(#0000, #FFF4);
+}
+
+button:disabled {
+ background: linear-gradient(#0000, #FFF4) #CCC;
+ cursor: auto;
+}
+
+.library-category > button {
+ margin-inline-end: auto;
+}
+
+.paint-container {
+ overflow: hidden;
+ display: flex;
+ align-content: center;
+ justify-content: center;
+ align-items: center;
+ justify-items: center;
+}
+
+.costume-list {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+}
+
+.costume-list button {
+ gap: 16px;
+ grid-template-columns: auto 1fr;
+ padding: 8px;
+ justify-items: start;
+}
+
+.costume-list img {
+ width: 48px;
+ height: 48px;
+ object-fit: contain;
+ background: #FFF;
+ border-radius: 4px;
+ padding: 8px;
+ border: 1px solid #68A;
+}
+
+.toolbox {
+ display: flex;
+ gap: 0.5em;
+}
+
+.scrollbar {
+ position: absolute;
+ touch-action: none;
+}
+
+.scrollbar-vertical {
+ top: 0;
+ bottom: 0;
+ right: -8px;
+ width: 8px;
+}
+
+.scrollbar-vertical::before {
+ width: 50%;
+ height: 25%;
+ left: 25%;
+ top: calc(var(--x) * 75%);
+}
+
+.scrollbar-horizontal {
+ left: 0;
+ right: 0;
+ bottom: -8px;
+ height: 8px;
+}
+
+.scrollbar-horizontal::before {
+ width: 25%;
+ height: 50%;
+ top: 25%;
+ left: calc(var(--x) * 75%);
+}
+
+.scrollbar-horizontal:dir(rtl)::before {
+ left: auto;
+ right: calc(var(--x) * -75%);
+}
+
+.scrollbar::before {
+ content: "";
+ position: absolute;
+ background: #68A;
+ opacity: 0;
+ transition: 0.5s ease opacity;
+ transition-delay: 1s;
+ border-radius: 2048px;
+}
+
+.scrollbar:hover::before, .scrollbar:active::before {
+ opacity: 1;
+ transition-delay: 0s;
+}
+
+.categories button, .category-name,
+.missing-message, .add-extension,
+.ok, .cancel, .make,
+.add-input, .atomic-message,
+.choose-scope {
+ text-transform: lowercase;
+}
+
+@media (max-width: 512px) {
+
+ body {
+ grid-template:
+ "buttons buttons buttons" auto
+ "stage bottom bottom" auto
+ "top bottom bottom" auto
+ "sprites sprites control" auto
+ "main main main" 1fr
+ / 180px 1fr;
+ }
+
+ .categories {
+ grid-template: 1fr 1fr 1fr 1fr 1fr / 1fr 1fr;
+ grid-auto-flow: column;
+ gap: 0.5em;
+ }
+
+ .categories button {
+ grid-auto-flow: column;
+ align-items: center;
+ justify-content: start;
+ text-align: left;
+ gap: 0.5em;
+ padding: 0.5em;
+ }
+
+ .categories button::before {
+ width: 12px;
+ height: 12px;
+ border-width: 1px;
+ }
+
+ .library {
+ height: 0;
+ min-height: 100%;
+ }
+}
+`
+
+let style = document.createElement("style")
+style.textContent = css
+document.head.append(style)
+
+export let stage = await main()
+document.title = "Scrax / " + stage.name
+registerSprite(stage, StageAreas, getMessage("gui.stageSelector.stage") ?? "Stage")
+for (let sprite of stage.sprites) registerSprite(sprite)
+show(stage.sprites[0] ?? stage)
+document.body.append(buttonsElement, spriteListScroll, controlElement, stage.element)
--- /dev/null
+++ b/md5.js
@@ -1,0 +1,138 @@
+// adapted from <https://stackoverflow.com/a/14733423>
+
+function md5cycle(x, k)
+{
+ let a = x[0]
+ let b = x[1]
+ let c = x[2]
+ let d = x[3]
+
+ a = ff(a, b, c, d, k[0], 7, -680876936)
+ d = ff(d, a, b, c, k[1], 12, -389564586)
+ c = ff(c, d, a, b, k[2], 17, 606105819)
+ b = ff(b, c, d, a, k[3], 22, -1044525330)
+ a = ff(a, b, c, d, k[4], 7, -176418897)
+ d = ff(d, a, b, c, k[5], 12, 1200080426)
+ c = ff(c, d, a, b, k[6], 17, -1473231341)
+ b = ff(b, c, d, a, k[7], 22, -45705983)
+ a = ff(a, b, c, d, k[8], 7, 1770035416)
+ d = ff(d, a, b, c, k[9], 12, -1958414417)
+ c = ff(c, d, a, b, k[10], 17, -42063)
+ b = ff(b, c, d, a, k[11], 22, -1990404162)
+ a = ff(a, b, c, d, k[12], 7, 1804603682)
+ d = ff(d, a, b, c, k[13], 12, -40341101)
+ c = ff(c, d, a, b, k[14], 17, -1502002290)
+ b = ff(b, c, d, a, k[15], 22, 1236535329)
+
+ a = gg(a, b, c, d, k[1], 5, -165796510)
+ d = gg(d, a, b, c, k[6], 9, -1069501632)
+ c = gg(c, d, a, b, k[11], 14, 643717713)
+ b = gg(b, c, d, a, k[0], 20, -373897302)
+ a = gg(a, b, c, d, k[5], 5, -701558691)
+ d = gg(d, a, b, c, k[10], 9, 38016083)
+ c = gg(c, d, a, b, k[15], 14, -660478335)
+ b = gg(b, c, d, a, k[4], 20, -405537848)
+ a = gg(a, b, c, d, k[9], 5, 568446438)
+ d = gg(d, a, b, c, k[14], 9, -1019803690)
+ c = gg(c, d, a, b, k[3], 14, -187363961)
+ b = gg(b, c, d, a, k[8], 20, 1163531501)
+ a = gg(a, b, c, d, k[13], 5, -1444681467)
+ d = gg(d, a, b, c, k[2], 9, -51403784)
+ c = gg(c, d, a, b, k[7], 14, 1735328473)
+ b = gg(b, c, d, a, k[12], 20, -1926607734)
+
+ a = hh(a, b, c, d, k[5], 4, -378558)
+ d = hh(d, a, b, c, k[8], 11, -2022574463)
+ c = hh(c, d, a, b, k[11], 16, 1839030562)
+ b = hh(b, c, d, a, k[14], 23, -35309556)
+ a = hh(a, b, c, d, k[1], 4, -1530992060)
+ d = hh(d, a, b, c, k[4], 11, 1272893353)
+ c = hh(c, d, a, b, k[7], 16, -155497632)
+ b = hh(b, c, d, a, k[10], 23, -1094730640)
+ a = hh(a, b, c, d, k[13], 4, 681279174)
+ d = hh(d, a, b, c, k[0], 11, -358537222)
+ c = hh(c, d, a, b, k[3], 16, -722521979)
+ b = hh(b, c, d, a, k[6], 23, 76029189)
+ a = hh(a, b, c, d, k[9], 4, -640364487)
+ d = hh(d, a, b, c, k[12], 11, -421815835)
+ c = hh(c, d, a, b, k[15], 16, 530742520)
+ b = hh(b, c, d, a, k[2], 23, -995338651)
+
+ a = ii(a, b, c, d, k[0], 6, -198630844)
+ d = ii(d, a, b, c, k[7], 10, 1126891415)
+ c = ii(c, d, a, b, k[14], 15, -1416354905)
+ b = ii(b, c, d, a, k[5], 21, -57434055)
+ a = ii(a, b, c, d, k[12], 6, 1700485571)
+ d = ii(d, a, b, c, k[3], 10, -1894986606)
+ c = ii(c, d, a, b, k[10], 15, -1051523)
+ b = ii(b, c, d, a, k[1], 21, -2054922799)
+ a = ii(a, b, c, d, k[8], 6, 1873313359)
+ d = ii(d, a, b, c, k[15], 10, -30611744)
+ c = ii(c, d, a, b, k[6], 15, -1560198380)
+ b = ii(b, c, d, a, k[13], 21, 1309151649)
+ a = ii(a, b, c, d, k[4], 6, -145523070)
+ d = ii(d, a, b, c, k[11], 10, -1120210379)
+ c = ii(c, d, a, b, k[2], 15, 718787259)
+ b = ii(b, c, d, a, k[9], 21, -343485551)
+
+ x[0] += a
+ x[1] += b
+ x[2] += c
+ x[3] += d
+}
+
+function cmn(q, a, b, x, s, t)
+{
+ let c = add32(add32(a, q), add32(x, t))
+ return add32((c << s) | (c >>> (32 - s)), b)
+}
+
+function ff(a, b, c, d, x, s, t)
+{
+ return cmn((b & c) | ((~b) & d), a, b, x, s, t)
+}
+
+function gg(a, b, c, d, x, s, t)
+{
+ return cmn((b & d) | (c & (~d)), a, b, x, s, t)
+}
+
+function hh(a, b, c, d, x, s, t)
+{
+ return cmn(b ^ c ^ d, a, b, x, s, t)
+}
+
+function ii(a, b, c, d, x, s, t)
+{
+ return cmn(c ^ (b | (~d)), a, b, x, s, t)
+}
+
+function md51(s)
+{
+ let n = s.length
+ let state = new Uint32Array([1732584193, -271733879, -1732584194, 271733878])
+ let i
+ for (i = 64 ; i <= s.length ; i += 64) md5cycle(state, new Uint32Array(s.buffer, s.byteOffset + i - 64, 16))
+ s = s.subarray(i - 64)
+ let tail = new Uint32Array(16)
+ for (i = 0 ; i < s.length ; i++) tail[i >> 2] |= s[i] << ((i % 4) << 3)
+ tail[i >> 2] |= 0x80 << ((i % 4) << 3)
+ if (i > 55) {
+ md5cycle(state, tail)
+ for (i = 0 ; i < 16 ; i++) tail[i] = 0
+ }
+ tail[14] = n * 8
+ md5cycle(state, tail)
+ return state
+}
+
+function add32(a, b)
+{
+ return (a + b) & 0xFFFFFFFF
+}
+
+export function MD5(buffer)
+{
+ let view = new DataView(md51(buffer).buffer)
+ return Array(4).fill().map((n, i) => view.getUint32(i * 4).toString(16).padStart(8, "0")).join("")
+}
--- /dev/null
+++ b/model.js
@@ -1,0 +1,254 @@
+export function remove(child)
+{
+ let parent = child.parent
+ if (!parent) return
+ let i = parent.stack.indexOf(child)
+ parent.stack.splice(i, 1)
+ child.parent = undefined
+}
+
+export function removeInput(child)
+{
+ if (!child.parent) return
+ let i = child.parent.inputs.indexOf(child)
+ let other = child.parent.type.slots[i]()
+ child.parent.inputs[i] = other
+ child.parent.inputs[i].parent = child.parent
+ child.parent = undefined
+ return other
+}
+
+export function setInput(block, i, child)
+{
+ removeInput(block.inputs[i])
+ removeInput(child)
+ block.inputs[i] = child
+ child.parent = block
+}
+
+export function splice(child, n = Infinity)
+{
+ if (!child.parent) return [child]
+ let children = child.parent.stack.splice(child.parent.stack.indexOf(child), n)
+ for (let child of children) child.parent = undefined
+ return children
+}
+
+export function append(parent, ...children)
+{
+ for (let child of children) {
+ if (child.type.shape !== undefined) continue
+ if (parent.stack.length > 0 && parent.stack.at(-1).type.cap) break
+ remove(child)
+ parent.stack.push(child)
+ child.parent = parent
+ }
+}
+
+export function prepend(parent, ...children)
+{
+ if (!parent.stack[0]) append(parent, ...children)
+ else append(parent, ...children, ...splice(parent.stack[0]))
+}
+
+export function after(before, ...after)
+{
+ append(before.parent, before, ...after, ...splice(before).slice(1))
+}
+
+export function before(after, ...before)
+{
+ append(after.parent, ...before, ...splice(after))
+}
+
+export function replace(block0, block1)
+{
+ let parent = block0.parent
+ if (!parent) return
+ if (block0.type.shape !== undefined) {
+ setInput(parent, parent.inputs.indexOf(block0), block1)
+ return
+ }
+ let before = parent.stack[parent.stack.indexOf(block0) - 1]
+ remove(block0)
+ if (before) after(before, block1)
+ else prepend(parent, block1)
+}
+
+export function flatten(block)
+{
+ return [block, ...block.inputs.flatMap(flatten), ...block.stack?.flatMap(flatten) ?? [], ...block.complement ? flatten(block.complement) : []]
+}
+
+export function complement(block, complement)
+{
+ if (block.type.complement === complement.type)
+ block.complement = complement
+ complement.parent = block
+}
+
+let id = 1
+
+class Block {
+
+ id = id++
+ parent = undefined
+ inputs = []
+
+ constructor(type, inputs = [], stack, names)
+ {
+ this.type = type
+ if (names?.length) this.names = names
+
+ for (let input of inputs) {
+ removeInput(input)
+ this.inputs.push(input)
+ input.parent = this
+ }
+
+ if (stack) {
+ this.stack = []
+ for (let child of stack) append(this, child)
+ }
+ }
+}
+
+export function duplicate(block)
+{
+ let other = new Block(block.type, block.inputs.map(duplicate), block.stack?.map(duplicate), structuredClone(block.names))
+ if (block.atomic) other.atomic = true
+ if (block.complement) complement(other, duplicate(block.complement))
+ if (block.value !== undefined) other.value = block.value
+ return other
+}
+
+export function map(block, fn)
+{
+ let inputs = block.inputs.map(block => map(block, fn))
+ let stack = block.stack?.map(block => map(block, fn))
+ let other = fn(block, inputs, stack, block.complement ? map(block.complement, fn) : undefined)
+ if (!other) {
+ other = new Block(block.type, inputs, stack, structuredClone(block.names))
+ if (block.atomic) other.atomic = true
+ if (block.complement) complement(other, map(block.complement, fn))
+ if (block.value !== undefined) other.value = block.value
+ }
+ return other
+}
+
+export function MakeBlock(type)
+{
+ let length = type.slots?.length ?? 0
+ let inputs0 = type.defaults?.slice(0, length) ?? []
+ while (inputs0.length < length) inputs0.push("")
+ make.type = type
+ return make
+
+ function make(...inputs1)
+ {
+ let names
+ if (type.references?.length) {
+ names = inputs1.splice(0, type.references.length)
+ while (names.length < type.references.length) names.push("")
+ }
+ let inputs2 = inputs1.splice(0, length).map((n, i) => typeof n === "object" ? n : type.slots[i](String(n)))
+ let inputs = [...inputs2, ...inputs0.slice(inputs2.length).map((n, i) => type.slots[inputs2.length + i](n))]
+ let block = new Block(type, inputs, type.hat || type.stack ? inputs1 : undefined, names)
+ type.complete?.(block)
+ return block
+ }
+}
+
+export function MakeSlot(type = {})
+{
+ type.shape = "slot"
+ type.normalise ??= String
+ let make0 = MakeBlock(type)
+ make.type = type
+ return make
+
+ function make(value = "")
+ {
+ let block = make0()
+ block.value = type.normalise(value)
+ return block
+ }
+}
+
+function compareValue(a, b)
+{
+ if (typeof a !== typeof b) return false
+ if (typeof a !== "object" && typeof a !== "function") return Object.is(a, b)
+ if (a === null && b === null) return true
+ if (a === null || b === null) return false
+ if ((a instanceof Array) !== (b instanceof Array)) return false
+ if (a instanceof Array) return a.length === b.length && a.every((a, i) => compareValue(a, b[i]))
+ let ak = Object.keys(a)
+ let bk = Object.keys(b)
+ ak.sort()
+ bk.sort()
+ if (!compareValue(ak, bk)) return false
+ return ak.every(key => compareValue(a[key], b[key]))
+}
+
+export function compare(a, b)
+{
+ if (a.length !== b.length) return false
+
+ for (let [i, ax] of a.entries()) {
+
+ let bx = b[i]
+
+ if (ax.block.id !== bx.block.id) return false
+ if (ax.value !== bx.value) return false
+
+ for (let [i, input] of ax.inputs.entries()) {
+ if (input.id !== bx.inputs[i].id) return false
+ }
+
+ if (!compareValue(ax.names, bx.names)) return false
+
+ if ((!ax.stack) !== (!bx.stack)) return false
+ if (!ax.stack) continue
+
+ if (ax.stack.length !== bx.stack.length) return false
+ for (let [i, child] of ax.stack.entries()) {
+ if (child.id !== bx.stack[i].id) return false
+ }
+ }
+
+ return true
+}
+
+export function save(scripts)
+{
+ let structure = []
+ for (let block of scripts.flatMap(flatten)) {
+ structure.push({
+ block,
+ inputs: block.inputs.slice(),
+ stack: block.stack?.slice(),
+ value: block.value,
+ complement: block.complement,
+ names: structuredClone(block.names),
+ })
+ }
+ return structure
+}
+
+export function load(structure)
+{
+ for (let {block, inputs, stack, value, complement: complement1, names} of structure) {
+ for (let [i, input] of inputs.entries()) setInput(block, i, input)
+ if (value !== undefined) block.value = value
+ if (!stack) continue
+ while (block.stack.length !== 0) remove(block.stack[0])
+ for (let child of stack) append(block, child)
+ if (complement1) complement(block, complement1)
+ else block.complement = undefined
+ if (names) {
+ block.names.length = 0
+ block.names.push(...names)
+ }
+ }
+}
--- /dev/null
+++ b/music.js
@@ -1,0 +1,214 @@
+import {toNumber, toInteger} from "./compile.js"
+import {MakeBlock} from "./model.js"
+import {audioContext as ctx} from "./stage.js"
+import {PositiveSlot, EnumeratedSlot} from "./core.js"
+
+function playInstrument(instrument, note, seconds, destination)
+{
+ let sample = instrument.samples.findLast(sample => sample.note <= note) ?? instrument.samples[0]
+ if (!sample.buffer) return
+ let node = ctx.createBufferSource()
+ node.buffer = sample.buffer
+ node.playbackRate.value = 2 ** ((note - sample.note) / 12)
+
+ let gainNode = ctx.createGain()
+ let releaseTime = ctx.currentTime + seconds
+ gainNode.gain.setValueAtTime(1, releaseTime)
+ gainNode.gain.linearRampToValueAtTime(0, releaseTime + (instrument.release ?? 0))
+
+ node.connect(gainNode)
+ gainNode.connect(destination)
+ node.start()
+}
+
+function playDrum(drum, destination)
+{
+ if (!drum.buffer) return
+ let node = ctx.createBufferSource()
+ node.buffer = drum.buffer
+ node.connect(destination)
+ node.start()
+}
+
+export function extendSprite(sprite, name)
+{
+ return extend(sprite, sprite.stage, name)
+}
+
+export function extendStage(stage, name)
+{
+ return extend(stage, stage, name)
+}
+
+function extend(sprite, stage, name)
+{
+ let music = {}
+ let stageMusic = stage.extensions[name] ?? music
+
+ let blockFns = {
+ playDrum: (drum, beats) =>
+ {
+ playDrum(drums[toInteger(drum) - 1] ?? drums[0], sprite.audioDestination)
+ return blockFns.rest(beats)
+ },
+ playNote: (note, beats) =>
+ {
+ if (toNumber(beats) <= 0) return
+ playInstrument(music.instrument, toNumber(note), toNumber(beats) / stageMusic.tempo * 60, sprite.audioDestination)
+ return blockFns.rest(beats)
+ },
+ rest: beats => sleep(toNumber(beats) / stageMusic.tempo * 60000),
+ setInstrument: instrument => music.instrument = instruments[toInteger(instrument) - 1] ?? music.instrument,
+ setTempo: tempo => stageMusic.tempo = toNumber(tempo),
+ changeTempo: change => stageMusic.tempo += toNumber(change),
+ tempo: () => stageMusic.tempo,
+ }
+
+ let blocks = {}
+ for (let name of Object.keys(blockFns)) {
+ let upperCaseName = name[0].toUpperCase() + name.slice(1)
+ blocks[upperCaseName] = MakeBlock({
+ run: blockFns[name],
+ async: sync[name] === "async",
+ slots: slots[name] ?? [],
+ name: nameFns[name],
+ shape: name === "tempo" ? "reporter" : undefined,
+ defaults: defaults[name],
+ })
+ }
+
+ music.blocks = blocks
+ music.instrument = sprite.original?.extensions[name].instrument ?? instruments[0]
+ if (sprite === stage) music.tempo = 60
+ return music
+}
+
+function sleep(duration)
+{
+ if (typeof Animation === "undefined") {
+ return new Promise(resolve => setTimeout(resolve, duration))
+ }
+
+ let animation = new Animation(new KeyframeEffect(null, null, {duration}), document.timeline)
+ animation.play()
+ return animation.finished
+}
+
+let icon = `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M12 5v8.55c-.94-.54-2.1-.75-3.33-.32-1.34.48-2.37 1.67-2.61 3.07-.46 2.74 1.86 5.08 4.59 4.65 1.96-.31 3.35-2.11 3.35-4.1V7h2c1.1 0 2-.9 2-2s-.9-2-2-2h-2c-1.1 0-2 .9-2 2z"/></svg>`
+
+function getIcon()
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icon)
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+let sync = {
+ playDrum: "async",
+ playNote: "async",
+ rest: "async",
+}
+
+let nameFns = {
+ playDrum: (drum, beats) => [getIcon(), "play drum", drum, "for", beats, "beats"],
+ playNote: (note, beats) => [getIcon(), "play note", note, "for", beats, "beats"],
+ rest: beats => [getIcon(), "rest for", beats, "beats"],
+ setInstrument: instrument => [getIcon(), "set instrument to", instrument],
+ setTempo: tempo => [getIcon(), "set tempo to", tempo],
+ changeTempo: change => [getIcon(), "change tempo by", change],
+ tempo: () => [getIcon(), "tempo"],
+}
+
+let slots = {
+ playDrum: [undefined, PositiveSlot],
+ playNote: [PositiveSlot, PositiveSlot],
+ rest: [PositiveSlot],
+ setInstrument: [undefined],
+ setTempo: [PositiveSlot],
+ changeTempo: [PositiveSlot],
+}
+
+let defaults = {
+ playDrum: [1, 0.25],
+ playNote: [60, 0.25],
+ rest: [0.25],
+ setInstrument: [1],
+ setTempo: [60],
+ changeTempo: [20],
+}
+
+let instruments = [
+ {name: "Piano", release: 0.5, notes: [24, 36, 48, 60, 72, 84, 96, 108]},
+ {name: "Electric Piano", release: 0.5, notes: [60]},
+ {name: "Organ", release: 0.5, notes: [60]},
+ {name: "Guitar", release: 0.5, notes: [60]},
+ {name: "Electric Guitar", release: 0.5, notes: [60]},
+ {name: "Bass", release: 0.25, notes: [36, 48]},
+ {name: "Pizzicato", release: 0.25, notes: [60]},
+ {name: "Cello", release: 0.1, notes: [36, 48, 60]},
+ {name: "Trombone", notes: [36, 48, 60]},
+ {name: "Clarinet", notes: [48, 60]},
+ {name: "Saxophone", notes: [36, 60, 84]},
+ {name: "Flute", notes: [60, 72]},
+ {name: "Wooden Flute", notes: [60, 72]},
+ {name: "Bassoon", notes: [36, 48, 60]},
+ {name: "Choir", release: 0.25, notes: [48, 60, 72]},
+ {name: "Vibraphone", release: 0.5, notes: [60, 72]},
+ {name: "Music Box", release: 0.25, notes: [60]},
+ {name: "Steel Drum", release: 0.5, notes: [60]},
+ {name: "Marimba", notes: [60]},
+ {name: "Synth Lead", release: 0.1, notes: [60]},
+ {name: "Synth Pad", release: 0.25, notes: [60]},
+]
+
+let drums = [
+ {name: "Snare Drum"},
+ {name: "Bass Drum"},
+ {name: "Side Stick"},
+ {name: "Crash Cymbal"},
+ {name: "Open Hi-Hat"},
+ {name: "Closed Hi-Hat"},
+ {name: "Tambourine"},
+ {name: "Hand Clap"},
+ {name: "Claves"},
+ {name: "Wood Block"},
+ {name: "Cowbell"},
+ {name: "Triangle"},
+ {name: "Bongo"},
+ {name: "Conga"},
+ {name: "Cabasa"},
+ {name: "Guiro"},
+ {name: "Vibraslap"},
+ {name: "Cuica"},
+]
+
+let instrumentOptions = []
+let drumOptions = []
+
+for (let [i, instrument] of instruments.entries()) {
+ instrumentOptions.push(i + 1)
+ let name = instrument.name.replaceAll(" ", "-").toLowerCase()
+ let samples = instrument.notes.map(note => ({note}))
+ instrument.samples = samples
+ for (let [i, note] of instrument.notes.entries()) {
+ fetch(new URL(`assets/${name}-${note}.mp3`, import.meta.url))
+ .then(response => response.arrayBuffer())
+ .then(buffer => new Promise((resolve, reject) => ctx.decodeAudioData(buffer, resolve, reject)))
+ .then(buffer => samples[i].buffer = buffer)
+ }
+}
+
+for (let [i, drum] of drums.entries()) {
+ drumOptions.push(i + 1)
+ let name = drum.name.replaceAll(" ", "-").toLowerCase()
+ fetch(new URL(`assets/${name}.mp3`, import.meta.url))
+ .then(response => response.arrayBuffer())
+ .then(buffer => new Promise((resolve, reject) => ctx.decodeAudioData(buffer, resolve, reject)))
+ .then(buffer => drum.buffer = buffer)
+}
+
+slots.setInstrument[0] = EnumeratedSlot(instrumentOptions, {canFit: undefined, name: i => `(${i}) ${instruments[i - 1].name}`})
+slots.playDrum[0] = EnumeratedSlot(drumOptions, {canFit: undefined, name: i => `(${i}) ${drums[i - 1].name}`})
--- /dev/null
+++ b/paint.js
@@ -1,0 +1,769 @@
+import {Stoppable} from "./areas.js"
+import "path-data-polyfill/path-data-polyfill.js"
+
+let svgns = "http://www.w3.org/2000/svg"
+
+class PaintArea {
+
+ element = document.createElement("div")
+ #element
+ #changeListeners = []
+ #zoom = 1
+ #x = 0
+ #y = 0
+
+ constructor(element)
+ {
+ this.#element = element
+ this.element.classList.add("area", "paint")
+ this.element.append(this.#element)
+
+ let x = 0
+ let y = 0
+
+ this.element.addEventListener("pointermove", event =>
+ {
+ if (!event.isPrimary) return
+ x = event.x
+ y = event.y
+ })
+
+ this.element.addEventListener("wheel", event =>
+ {
+ event.preventDefault()
+ if (event.ctrlKey || event.metaKey) {
+ this.zoom(1 / Math.exp((event.deltaX + event.deltaY) / 256), x, y)
+ }
+ else {
+ if (event.shiftKey) {
+ this.pan(-event.deltaY, -event.deltaX)
+ }
+ else {
+ this.pan(-event.deltaX, -event.deltaY)
+ }
+ }
+ })
+
+ this.element.addEventListener("contextmenu", event => event.preventDefault())
+
+ this.element.addEventListener("pointerdown", event =>
+ {
+ if (!(event.buttons & ~1)) return
+ event.preventDefault()
+ })
+
+ this.element.addEventListener("pointermove", event =>
+ {
+ if (!(event.buttons & ~1)) return
+ event.preventDefault()
+ this.pan(event.movementX, event.movementY)
+ })
+
+ this.#updateTransform()
+ }
+
+ show()
+ {
+ this.#x = 0
+ this.#y = 0
+ this.#zoom = 1
+ this.#updateTransform()
+ }
+
+ save()
+ {
+ for (let fn of this.#changeListeners) fn()
+ }
+
+ onChanged(fn)
+ {
+ this.#changeListeners.push(fn)
+ }
+
+ zoom(zoom = 1, x, y)
+ {
+ let rect = this.element.getBoundingClientRect()
+ x ??= rect.x + rect.width / 2
+ y ??= rect.y + rect.height / 2
+ let [x0, y0] = this.fromClient(x, y)
+ this.#zoom *= zoom
+ this.#updateTransform()
+ let [x1, y1] = this.fromClient(x, y)
+ this.pan((x1 - x0) * this.#zoom, (y1 - y0) * this.#zoom)
+ }
+
+ pan(x = 0, y = 0)
+ {
+ this.#x += x
+ this.#y += y
+ this.#updateTransform()
+ }
+
+ #updateTransform()
+ {
+ let minZoom = 16 ** 2 / Math.min(this.#element.offsetWidth, this.#element.offsetHeight)
+ let maxZoom = 16 ** 4 / Math.max(this.#element.offsetWidth, this.#element.offsetHeight)
+ this.#zoom = Math.min(Math.max(this.#zoom, minZoom), maxZoom)
+ let width = this.#element.offsetWidth * this.#zoom
+ let height = this.#element.offsetHeight * this.#zoom
+ this.#x = Math.min(Math.max(this.#x, -width), width)
+ this.#y = Math.min(Math.max(this.#y, -height), height)
+ this.#element.style.setProperty("transform", `translate(${this.#x}px, ${this.#y}px) scale(${this.#zoom})`)
+ }
+}
+
+class RasterPaintArea extends PaintArea {
+
+ #costume
+ #canvas
+
+ constructor(costume)
+ {
+ let canvas = document.createElement("canvas")
+ super(canvas)
+ this.#canvas = canvas
+ this.#canvas.width = 960
+ this.#canvas.height = 720
+ this.ctx = canvas.getContext("2d")
+ this.ctx.lineCap = "round"
+ this.ctx.lineJoin = "round"
+ this.ctx.lineWidth = 16
+ this.ctx.filter = "url('#alias')"
+ this.ctx.strokeStyle = "#A4E"
+ this.#costume = costume
+ }
+
+ fromClient(x, y)
+ {
+ let rect = this.#canvas.getBoundingClientRect()
+ return [(x - rect.x) * this.#canvas.width / rect.width, (y - rect.y) * this.#canvas.height / rect.height]
+ }
+
+ show()
+ {
+ let x = this.#canvas.width / 2 - this.#costume.x
+ let y = this.#canvas.height / 2 - this.#costume.y
+ this.ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
+ this.ctx.drawImage(this.#costume.image, x, y)
+ super.show()
+ this.zoom(0.5)
+ }
+
+ async save()
+ {
+ let x0 = 0
+ let y0 = 0
+ let x1 = this.#canvas.width
+ let y1 = this.#canvas.height
+ let w = this.#canvas.width
+
+ let image = this.ctx.getImageData(0, 0, x1, y1)
+
+ outer:
+ while (x0 < x1) {
+ for (let y = y0 ; y < y1 ; y++) {
+ if (image.data[(y * w + x0) * 4 + 3]) break outer
+ }
+ x0++
+ }
+
+ outer:
+ while (x0 < x1) {
+ for (let y = y0 ; y < y1 ; y++) {
+ if (image.data[(y * w + x1 - 1) * 4 + 3]) break outer
+ }
+ x1--
+ }
+
+ outer:
+ while (y0 < y1) {
+ for (let x = x0 ; x < x1 ; x++) {
+ if (image.data[(y0 * w + x) * 4 + 3]) break outer
+ }
+ y0++
+ }
+
+ outer:
+ while (y0 < y1) {
+ for (let x = x0 ; x < x1 ; x++) {
+ if (image.data[((y1 - 1) * w + x) * 4 + 3]) break outer
+ }
+ y1--
+ }
+
+ if (x0 === x1 || y0 === y1) {
+ x0 = 0
+ y0 = 0
+ x1 = 1
+ y1 = 1
+ }
+
+ let canvas = new OffscreenCanvas(x1 - x0, y1 - y0)
+ let ctx = canvas.getContext("2d")
+ ctx.putImageData(this.ctx.getImageData(x0, y0, x1 - x0, y1 - y0), 0, 0)
+
+ let blob = await canvas.convertToBlob()
+ this.#costume.blob = blob
+ this.#costume.x = this.#canvas.width / 2 - x0
+ this.#costume.y = this.#canvas.height / 2 - y0
+
+ super.save()
+ }
+}
+
+class VectorPaintArea extends PaintArea {
+
+ #costume
+ #element
+
+ constructor(costume)
+ {
+ let element = document.createElement("div")
+ super(element)
+ this.#costume = costume
+ this.#element = document.createElementNS(svgns, "svg")
+ element.append(this.#element)
+ this.#element.setAttribute("width", 480)
+ this.#element.setAttribute("height", 360)
+ }
+
+ fromClient(x, y)
+ {
+ let rect = this.#element.getBoundingClientRect()
+ return [(x - rect.x) * this.#element.width.baseVal.value / rect.width, (y - rect.y) * this.#element.height.baseVal.value / rect.height]
+ }
+
+ async show()
+ {
+ this.#element.textContent = ""
+ super.show()
+
+ let iframe = document.createElement("iframe")
+ document.body.append(iframe)
+ iframe.style.setProperty("opacity", "0")
+ iframe.style.setProperty("position", "fixed")
+ iframe.style.setProperty("pointer-events", "none")
+ iframe.sandbox.add("allow-same-origin")
+ iframe.src = this.#costume.url
+ await new Promise(resolve => iframe.addEventListener("load", resolve))
+ let doc = iframe.contentDocument
+
+ for (let element0 of doc.querySelectorAll("*")) {
+
+ let data = element0.getPathData?.({normalize: true})
+ data ??= window[element0.constructor.name]?.prototype.getPathData?.call(element0, {normalize: true})
+ if (!data) continue
+ if (data.length === 0) continue
+
+ let matrix = element0.getScreenCTM()
+ for (let {values} of data) {
+ for (let i = 0 ; i < values.length ; i += 2) {
+ let point = new DOMPoint(values[i], values[i + 1]).matrixTransform(matrix)
+ values[i] = point.x + this.#element.width.baseVal.value / 2 - this.#costume.x
+ values[i + 1] = point.y + this.#element.height.baseVal.value / 2 - this.#costume.y
+ }
+ }
+
+ let data0
+ let data1 = []
+ for (let segment of data) {
+ if (segment.type === "M") {
+ data0 = []
+ data1.push(data0)
+ }
+ if (segment.type === "L") {
+ let [x0, y0] = data0.at(-1).values.slice(-2)
+ let [x3, y3] = segment.values
+ let [x1, y1] = [2 * x0 / 3 + x3 / 3, 2 * y0 / 3 + y3 / 3]
+ let [x2, y2] = [x0 / 3 + 2 * x3 / 3, y0 / 3 + 2 * y3 / 3]
+ segment = {type: "C", values: [x1, y1, x2, y2, x3, y3]}
+ }
+ data0.push(segment)
+ }
+
+ for (let data of data1) {
+ let element1 = document.createElementNS(svgns, "path")
+ this.#element.append(element1)
+ element1.setPathData(data)
+ let style = getComputedStyle(element0)
+ for (let name of ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin"]) {
+ let value = style.getPropertyValue(name)
+ if (value.match(/\burl\(/)) continue
+ element1.style.setProperty(name, value)
+ }
+ }
+ }
+
+ iframe.remove()
+ }
+
+ save()
+ {
+ let x0 = Infinity
+ let y0 = Infinity
+ let x1 = -Infinity
+ let y1 = -Infinity
+
+ for (let path of this.#element.children) {
+ let rect = path.getBBox()
+ let style = getComputedStyle(path)
+ let stroke = 0
+ if (style.getPropertyValue("stroke") !== "none") stroke = Number(style.getPropertyValue("stroke-width").slice(0, -2)) / 2
+ x0 = Math.min(x0, rect.x - stroke)
+ y0 = Math.min(y0, rect.y - stroke)
+ x1 = Math.max(x1, rect.x + rect.width + stroke)
+ y1 = Math.max(y1, rect.y + rect.height + stroke)
+ }
+
+ if (x0 >= x1 || y0 >= y1) {
+ x0 = 0
+ y0 = 0
+ x1 = 1
+ y1 = 1
+ }
+
+ let element = this.#element.cloneNode(true)
+ element.setAttribute("xmlns", svgns)
+
+ for (let path of element.children) {
+ let data = path.getPathData()
+ for (let {values} of data) {
+ for (let i = 0 ; i < values.length ; i += 2) {
+ values[i] -= x0
+ values[i + 1] -= y0
+ }
+ }
+ path.setPathData(data)
+ }
+
+ element.setAttribute("width", x1 - x0)
+ element.setAttribute("height", y1 - y0)
+
+ this.#costume.blob = new Blob([element.outerHTML], {type: "image/svg+xml"})
+ this.#costume.x = this.#element.width.baseVal.value / 2 - x0
+ this.#costume.y = this.#element.height.baseVal.value / 2 - y0
+ super.save()
+ }
+}
+
+class Tool {
+
+ enabled = false
+ changed = false
+
+ constructor(area, stoppable)
+ {
+ this.area = area
+ area.element.addEventListener("pointermove", event =>
+ {
+ if (!this.enabled) return
+ let x1 = event.x
+ let y1 = event.y
+ let x0 = x1 - event.movementX
+ let y0 = y1 - event.movementY
+ this.move?.(event, ...area.fromClient(x1, y1), ...area.fromClient(x0, y0))
+ })
+
+ area.element.addEventListener("pointerdown", event =>
+ {
+ if (!this.enabled) return
+ let [x, y] = area.fromClient(event.x, event.y)
+ if (event.buttons & 1) this.click?.(event, x, y)
+ })
+
+ stoppable.addEventListener("pointerup", event =>
+ {
+ if (!this.enabled) return
+ this.unclick?.(event)
+ })
+ }
+
+ async save()
+ {
+ if (!this.changed) return
+ this.changed = false
+ await this.area.save()
+ }
+}
+
+class RasterBrush extends Tool {
+
+ name = "brush"
+
+ move(event, x1, y1, x0, y0)
+ {
+ if (!(event.buttons & 1)) return
+ this.changed = true
+ this.area.ctx.beginPath()
+ this.area.ctx.moveTo(x0, y0)
+ this.area.ctx.lineTo(x1, y1)
+ this.area.ctx.stroke()
+ }
+
+ click(event, x, y)
+ {
+ this.move(event, x, y, x, y)
+ }
+
+ unclick()
+ {
+ this.save()
+ }
+}
+
+class RasterEraser extends RasterBrush {
+ name = "eraser"
+ move(...args)
+ {
+ this.area.ctx.save()
+ this.area.ctx.globalCompositeOperation = "destination-out"
+ super.move(...args)
+ this.area.ctx.restore()
+ }
+}
+
+class VectorReshape extends Tool {
+
+ name = "reshape"
+ #element = document.createElementNS(svgns, "svg")
+ #path = document.createElementNS(svgns, "g")
+ #point = document.createElementNS(svgns, "g")
+ #enabled = false
+ #highlightedPath
+ #highlightedPoint
+ #lockedPath = false
+ #lockedPoint = false
+
+ constructor(...args)
+ {
+ super(...args)
+ delete this.enabled
+ this.#element.style.setProperty("pointer-events", "none")
+ this.#element.append(this.#path, this.#point)
+ }
+
+ get enabled()
+ {
+ return this.#enabled
+ }
+
+ set enabled(enabled)
+ {
+ this.#enabled = enabled
+ this.#path.textContent = ""
+ this.#point.textContent = ""
+ if (enabled) {
+ let div = this.area.element.querySelector("div")
+ let svg = div.querySelector("svg")
+ div.append(this.#element)
+ this.#element.setAttribute("width", svg.getAttribute("width"))
+ this.#element.setAttribute("height", svg.getAttribute("height"))
+ this.#element.setAttribute("fill", "#00F")
+ this.#element.setAttribute("stroke", "#00F")
+ this.#element.setAttribute("stroke-width", "0")
+ }
+ else {
+ this.#element.remove()
+ this.#highlightedPath = undefined
+ this.#highlightedPoint = undefined
+ }
+ }
+
+ move(event, x0, y0, x1, y1)
+ {
+ if (!this.#lockedPath) this.#highlightPath(event)
+ if (!this.#lockedPoint) this.#highlightPoint(x0, y0)
+ if (this.#lockedPoint && (event.buttons & 1)) this.#movePoint(x1, y1)
+ }
+
+ click(event, x, y)
+ {
+ this.#highlightPoint(x, y)
+ this.#lockedPoint = Boolean(this.#highlightedPoint)
+ this.#lockedPath = this.#lockedPoint
+ if (this.#lockedPoint) return
+ this.#highlightPath(event)
+ this.#lockedPath = Boolean(this.#highlightedPath)
+ }
+
+ unclick()
+ {
+ this.save()
+ }
+
+ #highlightPath(event)
+ {
+ let path = event.target.closest("path") ?? undefined
+ if (this.#highlightedPath === path) return
+ this.#path.textContent = ""
+ this.#highlightedPath = path
+ if (!path) return
+
+ let div = this.area.element.querySelector("div")
+ let svg = div.querySelector("svg")
+ if (!svg.contains(path)) return
+
+ let data = path.getPathData()
+ let path1 = document.createElementNS(svgns, "path")
+ path1.setPathData(data)
+ path1.setAttribute("fill", "none")
+ path1.setAttribute("stroke-width", "0.5")
+ this.#path.append(path1)
+
+ for (let [i, {type, values}] of data.entries()) {
+ if (type === "Z") continue
+ let [x, y] = values.slice(-2)
+ let circle = document.createElementNS(svgns, "circle")
+ circle.setAttribute("r", "1")
+ circle.setAttribute("cx", String(x))
+ circle.setAttribute("cy", String(y))
+ circle.dataset.index = String(i)
+ this.#path.append(circle)
+ }
+ }
+
+ #highlightPoint(x, y)
+ {
+ let point
+ let distance = Infinity
+ for (let point1 of this.#path.querySelectorAll("circle")) {
+ let cx = Number(point1.getAttribute("cx"))
+ let cy = Number(point1.getAttribute("cy"))
+ let d = (x - cx) ** 2 + (y - cy) ** 2
+ if (d < distance) {
+ point = point1
+ distance = d
+ }
+ }
+
+ if (distance > 2) point = undefined
+ if (this.#highlightedPoint === point) return
+ this.#point.textContent = ""
+ this.#highlightedPoint = point
+ if (!point) return
+
+ let data = this.#highlightedPath.getPathData()
+
+ let index = Number(point.dataset.index)
+ let index1 = index + 1
+
+ if (data[index].type === "C") {
+ let circle1 = document.createElementNS(svgns, "circle")
+ circle1.setAttribute("r", "1")
+ circle1.dataset.index = String(index)
+ circle1.dataset.offset = "2"
+ this.#point.append(circle1)
+ }
+
+ if (data[index1]?.type === "C") {
+ let circle1 = document.createElementNS(svgns, "circle")
+ circle1.setAttribute("r", "1")
+ circle1.dataset.index = String(index1)
+ circle1.dataset.offset = "0"
+ this.#point.append(circle1)
+ }
+
+ let [x1, y1] = data[index].values.slice(-2)
+
+ for (let circle of [...this.#point.children]) {
+
+ let index = Number(circle.dataset.index)
+ let offset = Number(circle.dataset.offset)
+ let [x2, y2] = data[index].values.slice(offset)
+
+ circle.setAttribute("cx", String(x2))
+ circle.setAttribute("cy", String(y2))
+ circle.dataset.dx = String(x2 - x1)
+ circle.dataset.dy = String(y2 - y1)
+
+ let line = document.createElementNS(svgns, "line")
+ line.setAttribute("x1", String(x1))
+ line.setAttribute("y1", String(y1))
+ line.setAttribute("x2", String(x2))
+ line.setAttribute("y2", String(y2))
+ line.setAttribute("stroke-width", "0.5")
+ line.dataset.index = circle.dataset.index
+ line.dataset.offset = circle.dataset.offset
+
+ this.#point.append(line)
+ }
+ }
+
+ #movePoint(x, y)
+ {
+ this.changed = true
+
+ let index = Number(this.#highlightedPoint.dataset.index)
+ let data = this.#highlightedPath.getPathData()
+
+ let values = data[index].values
+ values[values.length - 2] = x
+ values[values.length - 1] = y
+
+ for (let line of this.#point.querySelectorAll("line")) {
+
+ let index = Number(line.dataset.index)
+ let offset = Number(line.dataset.offset)
+ let circle = this.#point.querySelector(`circle[data-index="${index}"][data-offset="${offset}"]`)
+
+ let x2 = x + Number(circle.dataset.dx)
+ let y2 = y + Number(circle.dataset.dy)
+
+ line.setAttribute("x1", String(x))
+ line.setAttribute("y1", String(y))
+ line.setAttribute("x2", String(x2))
+ line.setAttribute("y2", String(y2))
+
+ circle.setAttribute("cx", String(x2))
+ circle.setAttribute("cy", String(y2))
+
+ let {values} = data[index]
+ values[offset] = x2
+ values[offset + 1] = y2
+ }
+
+ this.#highlightedPath.setPathData(data)
+ this.#path.querySelector("path").setPathData(data)
+ this.#highlightedPoint.setAttribute("cx", String(x))
+ this.#highlightedPoint.setAttribute("cy", String(y))
+ }
+}
+
+class ToolboxArea {
+
+ element = document.createElement("div")
+ tools = []
+
+ constructor(area, stoppable)
+ {
+ this.element.classList.add("area", "toolbox")
+
+ let tools = area instanceof VectorPaintArea ? [VectorReshape] : [RasterBrush, RasterEraser]
+
+ for (let Tool of tools) this.tools.push(new Tool(area, stoppable))
+
+ for (let tool of this.tools) {
+ let button = document.createElement("button")
+ button.append(getIcon(tool.name))
+ this.element.append(button)
+ button.addEventListener("click", () =>
+ {
+ for (let tool of this.tools) tool.enabled = false
+ for (let button of this.element.querySelectorAll("button")) button.disabled =false
+ tool.enabled = true
+ button.disabled = true
+ })
+ }
+
+ this.element.append("note: costume editor is WIP")
+ }
+
+ show()
+ {
+ this.element.querySelector("button")?.click()
+ }
+}
+
+export class CostumeAreas {
+
+ #options
+ #areas = new Map()
+ #selecting = false
+ costumeListElement = document.createElement("div")
+ paintElement = document.createElement("div")
+ toolboxElement = document.createElement("div")
+
+ constructor(costumes, options = {})
+ {
+ this.#options = options
+ this.costumeListElement.classList.add("costume-list")
+ this.paintElement.classList.add("paint-container")
+ this.toolboxElement.classList.add("toolbox-container")
+ for (let costume of costumes) this.#register(costume)
+ }
+
+ #register(costume)
+ {
+ let button = document.createElement("button")
+ button.addEventListener("click", () => this.#select(costume))
+ this.costumeListElement.append(button)
+ let image = document.createElement("img")
+ image.src = costume.url
+ button.append(image, costume.name)
+ let stoppable = new Stoppable()
+ let paint = costume.blob.type === "image/svg+xml" ? new VectorPaintArea(costume) : new RasterPaintArea(costume)
+ let toolbox = new ToolboxArea(paint, stoppable)
+ this.#areas.set(costume, {button, paint, toolbox, stoppable})
+ paint.onChanged(() => image.src = costume.url)
+ }
+
+ async #select(costume)
+ {
+ if (this.#selecting) return
+ this.#selecting = true
+ let areas = this.#areas.get(costume)
+ for (let {button} of this.#areas.values()) button.disabled = false
+ areas.button.disabled = true
+ this.paintElement.textContent = ""
+ this.toolboxElement.textContent = ""
+ this.paintElement.append(areas.paint.element)
+ this.toolboxElement.append(areas.toolbox.element)
+ await areas.paint.show()
+ areas.toolbox.show()
+ this.#selecting = false
+ this.#options.select?.(costume)
+ }
+}
+
+function getIcon(name)
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icons[name])
+ span.classList.add("icon")
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+let icons = {
+ brush: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`,
+ eraser: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`,
+ reshape: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`,
+}
+
+document.body.insertAdjacentHTML("beforeend", `
+<svg width="0" height="0" style="position: fixed; pointer-events: none;">
+ <filter id="alias">
+ <feComponentTransfer>
+ <feFuncA type="discrete" tableValues="0 1" />
+ </feComponentTransfer>
+ </filter>
+</svg>`)
+
+let css = `
+.paint > * {
+ display: grid;
+ border-radius: 8px;
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2" stroke="none"><rect width="2" height="2" fill="%23FFF" /><path fill="%23EEF" d="M 0 0 0 1 2 1 2 2 1 2 1 0" /></svg>') center / 16px;
+}
+
+.paint > canvas {
+ border-radius: 16px;
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+ background-size: 32px;
+}
+
+.paint > div > * {
+ grid-area: 1 / 1;
+}
+
+.paint {
+ filter: drop-shadow(0 0 6px #0002);
+}
+`
+
+let style = document.createElement("style")
+style.textContent = css
+document.head.append(style)
--- /dev/null
+++ b/pen.js
@@ -1,0 +1,127 @@
+import {toNumber, toString} from "./compile.js"
+import {MakeBlock} from "./model.js"
+import {TextSlot, NumberSlot} from "./core.js"
+import {ColorSlot} from "./stage.js"
+
+let svgns = "http://www.w3.org/2000/svg"
+
+export function extendStage(stage)
+{
+ let canvas = document.createElement("canvas")
+ canvas.width = 480
+ canvas.height = 360
+
+ let ctx = canvas.getContext("2d")
+ ctx.translate(240, 180)
+ ctx.imageSmoothingEnabled = false
+ ctx.lineCap = "round"
+ ctx.lineJoin = "round"
+
+ let penElement = document.createElementNS(svgns, "foreignObject")
+ penElement.setAttribute("x", "-240")
+ penElement.setAttribute("y", "-180")
+ penElement.setAttribute("width", "480")
+ penElement.setAttribute("height", "360")
+ penElement.append(canvas)
+
+ stage.element.querySelector("g").children[1].after(penElement)
+
+ return {canvas, ctx}
+}
+
+export function extendSprite(sprite, name)
+{
+ let {canvas, ctx} = sprite.stage.extensions[name]
+ let down = sprite.original?.extensions[name].down ?? false
+ let color = sprite.original?.extensions[name].color ?? "#000000"
+ let size = sprite.original?.extensions[name].size ?? 1
+
+ let blockFns = {
+ clear: name => ctx.clearRect(-canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height),
+ penDown: () =>
+ {
+ down = true
+ ctx.fillStyle = color
+ ctx.beginPath()
+ ctx.arc(sprite.x, -sprite.y, size / 2, 0, 8)
+ ctx.fill()
+ },
+ penUp: () => down = false,
+ setPenColor: color1 =>
+ {
+ // todo: improve this
+ let number = Math.round(Number(color1))
+ if (Number.isNaN(number)) color = toString(color1)
+ else color = `rgb(${(number >> 16) & 0xFF}, ${(number >> 8) & 0xFF}, ${number & 0xFF})`
+ },
+ setPenSize: size1 => size = Math.max(1, toNumber(size1)),
+ changePenSize: size1 => blockFns.setPenSize(toNumber(size1) + size),
+ stamp: () =>
+ {
+ ctx.save()
+ ctx.translate(sprite.x, -sprite.y)
+ ctx.scale(sprite.size / 100, sprite.size / 100)
+ if (sprite.rotationStyle === "full") ctx.rotate((sprite.direction - 90) * (Math.PI / 180))
+ if (sprite.rotationStyle === "left-right" && (sprite.direction < 0 || sprite.direction >= 180)) ctx.scale(-1, 1)
+ if (sprite.costume.blob?.type !== "image/svg+xml") ctx.scale(0.5, 0.5)
+ ctx.filter = sprite.getFilter()
+ ctx.drawImage(sprite.costume.canvas, -sprite.costume.x, -sprite.costume.y)
+ ctx.restore()
+ },
+ }
+
+ let before = () =>
+ {
+ if (!down) return
+ ctx.beginPath()
+ ctx.moveTo(sprite.x, -sprite.y)
+ }
+
+ let after = () =>
+ {
+ if (!down) return
+ ctx.strokeStyle = color
+ ctx.lineWidth = size
+ ctx.lineTo(sprite.x, -sprite.y)
+ ctx.stroke()
+ }
+
+ sprite.onMove({before, after})
+
+ let blocks = {}
+ for (let name of Object.keys(blockFns)) {
+ let upperCaseName = name[0].toUpperCase() + name.slice(1)
+ let slots = defaults[name]?.map(n => typeof n === "number" ? NumberSlot : n.startsWith("#") ? ColorSlot : TextSlot) ?? []
+ blocks[upperCaseName] = MakeBlock({run: blockFns[name], slots, name: nameFns[name], defaults: defaults[name]})
+ }
+
+ return {blocks}
+}
+
+let icon = `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`
+
+function getIcon()
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icon)
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+let nameFns = {
+ clear: () => [getIcon(), "erase all"],
+ penDown: () => [getIcon(), "pen down"],
+ penUp: () => [getIcon(), "pen up"],
+ setPenColor: color => [getIcon(), "set pen color to", color],
+ setPenSize: size => [getIcon(), "set pen size to", size],
+ changePenSize: size => [getIcon(), "change pen size by", size],
+ stamp: () => [getIcon(), "stamp"],
+}
+
+let defaults = {
+ setPenColor: ["#BBDDFF"],
+ setPenSize: [5],
+ changePenSize: [5],
+}
--- /dev/null
+++ b/readme.md
@@ -1,0 +1,18 @@
+Scrax
+===
+
+[Scrax] is an implementation of [Scratch]. It is meant to be cutesy and fun! 🐱
+
+[Scrax]: <https://scrax.org/about>
+[Scratch]: <https://scratch.mit.edu/about>
+
+license
+---
+
+Scrax is released under [0BSD]:
+
+> Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
+>
+> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+[0BSD]: <https://landley.net/toybox/license.html>
--- /dev/null
+++ b/scratch.js
@@ -1,0 +1,138 @@
+import {toNumber, toString, Dispatcher} from "./compile.js"
+import {MakeBlock, setInput} from "./model.js"
+import {Stop, library as library0, NumberSlot, PositiveSlot} from "./core.js"
+
+let operatorOptions = []
+
+function Operator(label, fn)
+{
+ let make = MakeBlock({run: value => fn(toNumber(value)), name: value => [{options: operatorOptions}, "of", value], category: "operators", shape: "reporter", slots: [NumberSlot], output: "number"})
+ operatorOptions.push({label, morph, type: make.type})
+ return make
+ function morph(block)
+ {
+ let block1 = make()
+ setInput(block1, 0, block.inputs[0])
+ return block1
+ }
+}
+
+function toRadians(n)
+{
+ return Math.PI / 180 * n
+}
+
+function fromRadians(n)
+{
+ return 180 / Math.PI * n
+}
+
+export let Round = MakeBlock({run: value => Math.round(toNumber(value)), name: value => ["round", value], category: "operators", shape: "reporter", slots: [NumberSlot], output: "number"})
+export let Abs = Operator("abs", Math.abs)
+export let Ceiling = Operator("ceiling", Math.ceil)
+export let Floor = Operator("floor", Math.floor)
+export let Sqrt = Operator("sqrt", Math.sqrt)
+export let Sin = Operator("sin", n => Math.sin(toRadians(n)))
+export let Cos = Operator("cos", n => Math.cos(toRadians(n)))
+export let Tan = Operator("tan", n => Math.tan(toRadians(n)))
+export let ArcSin = Operator("asin", n => fromRadians(Math.asin(n)))
+export let ArcCos = Operator("acos", n => fromRadians(Math.acos(n)))
+export let ArcTan = Operator("atan", n => fromRadians(Math.atan(n)))
+export let Ln = Operator("ln", Math.log)
+export let Log = Operator("log", Math.log10)
+export let Exp = Operator("e ^", Math.exp)
+export let Exp10 = Operator("10 ^", n => 10 ** n)
+
+export let PickRandom = MakeBlock({run: random, name: (a, b) => ["pick random", a, "to", b], category: "operators", shape: "reporter", slots: [NumberSlot, NumberSlot], defaults: [1, 10]})
+export let Wait = MakeBlock({async: true, run: value => new Promise(resolve => setTimeout(resolve, toNumber(value) * 1000)), name: seconds => ["wait", seconds, "seconds"], category: "control", slots: [PositiveSlot], defaults: [1]})
+export let DaysSince2000 = MakeBlock({run: () => Date.now() / 86400 / 1000 - 10957, name: () => ["days since 2000"], category: "sensing", shape: "reporter"})
+
+function random(a, b)
+{
+ let an = toNumber(a)
+ let bn = toNumber(b)
+ if (isFractional(toString(a)) || isFractional(toString(b))) {
+ return Math.random() * (bn - an) + an
+ }
+ if (an > bn) [an, bn] = [bn, an]
+ return Math.floor(Math.random() * (bn - an + 1) + an)
+}
+
+function isFractional(v)
+{
+ return v.includes(".") || v.includes("e-")
+}
+
+export function Scratch(options = {})
+{
+ let dispatcher = options.dispatcher ?? new Dispatcher(options.tick)
+
+ let date = new Date()
+ let time0 = performance.now()
+ let time1 = time0
+
+ options.onBeforeTick(() =>
+ {
+ time1 = performance.now()
+ date = new Date()
+ })
+
+ let dateOptions = []
+
+ function DatePart(label, fn)
+ {
+ let make = MakeBlock({run: () => fn(), name: () => ["current", {options: dateOptions}], category: "sensing", shape: "reporter", output: "number"})
+ dateOptions.push({label, morph: () => make(), type: make.type})
+ return make
+ }
+
+ let Timer = MakeBlock({run: () => (time1 - time0) / 1000, name: () => ["timer"], category: "sensing", shape: "reporter", output: "number"})
+ let ResetTimer = MakeBlock({run: () => time1 = time0 = performance.now(), name: () => ["reset timer"], category: "sensing"})
+ let Username = MakeBlock({run: () => options.username ?? "zamfofex", name: () => ["username"], category: "sensing", shape: "reporter", output: "text"})
+
+ let Year = DatePart("year", () => date.getFullYear())
+ let Month = DatePart("month", () => date.getMonth() + 1)
+ let Day = DatePart("date", () => date.getDate())
+ let DayOfWeek = DatePart("day of week", () => date.getDay() + 1)
+ let Hour = DatePart("hour", () => date.getHours())
+ let Minute = DatePart("minute", () => date.getMinutes())
+ let Second = DatePart("second", () => date.getSeconds())
+
+ let other = options.other ?? (id => dispatcher.all().filter(id1 => id1 !== id))
+
+ let StopAll = MakeBlock({run: () => dispatcher.stop(), name: (...args) => getName(StopAll.type, ...args), category: "control", cap: true})
+ let StopOther = MakeBlock({run: ({id}) => dispatcher.stop(other(id)), name: (...args) => getName(StopOther.type, ...args), category: "control"})
+ let getName = NameFunction({StopAll, StopOther})
+
+ let blocks = {Wait, Round, PickRandom, Timer, ResetTimer, Username, DaysSince2000, Abs, Year}
+ let library = [...library0, ...Object.values(blocks)]
+
+ return {
+ ...blocks,
+ Abs, Ceiling, Floor, Sqrt, Sin, Cos, Tan, ArcSin, ArcCos, ArcTan, Ln, Log, Exp, Exp10,
+ Year, Month, Day, DayOfWeek, Hour, Minute, Second,
+ StopAll, StopOther,
+ library,
+ dispatcher,
+ getName,
+ }
+}
+
+let NameFunction = ({StopOther, StopAll}) =>
+{
+ let name = ["stop", {
+ options: [
+ {label: "this script", morph: () => Stop(), type: Stop.type},
+ {label: "other scripts in sprite", morph: () => StopOther(), type: StopOther.type},
+ {label: "all", morph: () => StopAll(), type: StopAll.type},
+ ]
+ }]
+
+ function getName(type)
+ {
+ if (type !== Stop.type && type !== StopAll.type && type !== StopOther.type) return
+ return name
+ }
+
+ return getName
+}
--- /dev/null
+++ b/stage.js
@@ -1,0 +1,1827 @@
+import {MakeBlock, map, flatten, duplicate, append, remove, removeInput, MakeSlot} from "./model.js"
+import {Variable, List, VariableLibrary, ListLibrary, NumberSlot, TextSlot, Define, compareName, Custom, EnumeratedSlot} from "./core.js"
+import {toNumber, toString, compileForDispatcher, Dispatcher, ticker} from "./compile.js"
+import {categories as categories0, closestBlockID, query} from "./view.js"
+import {addVariable, addList, addBlock, ProgramArea, LibraryArea, Scrollable, History, addValue} from "./areas.js"
+import {Scratch} from "./scratch.js"
+import {fromSyntax} from "./syntax.js"
+
+export let audioContext = new AudioContext()
+
+let svgns = "http://www.w3.org/2000/svg"
+let extensionNames = ["pen", "music"]
+
+let canvas = new OffscreenCanvas(1, 1)
+canvas.getContext("2d")
+let blankBlob = await canvas.convertToBlob()
+
+let KeySlot = EnumeratedSlot(["any", "space", "up arrow", "down arrow", "left arrow", "right arrow", ..."abcdefghijklmnopqrstuvwxyz0123456789"])
+let keyMap = new Map([
+ [" ", "space"],
+ ["Spacebar", "space"],
+ ["Up", "Down", "Left", "Right"].map(key => [key, key.toLowerCase() + " arrow"]),
+ ..."abcdefghijklmnopqrstuvwxyz0123456789".split("").flatMap(key => [[key, key], [key.toUpperCase(), key]]),
+])
+
+export class Stage {
+
+ element = document.createElementNS(svgns, "svg")
+ spritesElement = document.createElementNS(svgns, "g")
+ bubblesElement = document.createElementNS(svgns, "foreignObject")
+ sprites = []
+ messages = []
+ ClosedMessageSlot = EnumeratedSlot(this.messages, {dynamic: true})
+ OpenMessageSlot = EnumeratedSlot(this.messages, {dynamic: true, canFit: undefined})
+ WhenClicked = MakeBlock({hat: true, run: event => event === "click", name: () => ["when stage clicked"], category: "events", stageOnly: true})
+ WhenReceived = MakeBlock({hat: true, run: (event, message) => event === `message: ${toString(message)}`, name: message => ["when I receive", message], category: "events", slots: [this.ClosedMessageSlot], defaults: ["message1"]})
+ WhenKeyPressed = MakeBlock({hat: true, run: (event, key) => event === `key: ${key}`, name: key => ["when", key, "key pressed"], category: "events", slots: [KeySlot], defaults: ["space"]})
+ variables = new Map()
+ lists = new Map()
+ backdrops = []
+ scripts = []
+ extensionMap = new Map()
+ volume = 100
+ audioDestination = audioContext.createGain()
+ library = []
+ pointerX = 0
+ pointerY = 0
+ mouseX = 0
+ mouseY = 0
+ extensions = {}
+ #background = document.createElementNS(svgns, "rect")
+ #Whenever = MakeBlock({hat: true, run: event => event === "run", name: () => ["whenever"]})
+ #backdrop
+ #mouseX = 0
+ #mouseY = 0
+ #mouseDown = false
+ #startListeners = []
+ #loadListeners = []
+ #backdropElement
+ #keys = new Set()
+
+ constructor(options = {})
+ {
+ this.name = options.name ?? "New Project"
+
+ this.tick = options.tick ? (() => options.tick()) : (() => ticker.tick())
+ let tickListeners = []
+ this.dispatcher = new Dispatcher(() =>
+ {
+ for (let fn of tickListeners) fn()
+ return this.tick()
+ })
+
+ this.scratch = Scratch({
+ dispatcher: this.dispatcher,
+ onBeforeTick: fn => tickListeners.push(fn),
+ username: options.username,
+ other: id => this.#other(id),
+ })
+
+ this.element.classList.add("stage")
+ this.element.setAttribute("viewBox", "-240 -180 480 360")
+
+ let g = document.createElementNS(svgns, "g")
+ let clipPath = document.createElementNS(svgns, "clipPath")
+ clipPath.id = "clip-path"
+ this.element.append(clipPath, g)
+ g.setAttribute("clip-path", "url('#clip-path')")
+
+ let rect = document.createElementNS(svgns, "rect")
+ rect.setAttribute("x", "-240")
+ rect.setAttribute("y", "-180")
+ rect.setAttribute("width", "480")
+ rect.setAttribute("height", "360")
+ clipPath.append(rect)
+
+ this.#background.setAttribute("x", "-240")
+ this.#background.setAttribute("y", "-180")
+ this.#background.setAttribute("width", "480")
+ this.#background.setAttribute("height", "360")
+ this.#background.setAttribute("fill", "#FFF")
+
+ this.bubblesElement.setAttribute("x", "-240")
+ this.bubblesElement.setAttribute("y", "-180")
+ this.bubblesElement.setAttribute("width", "480")
+ this.bubblesElement.setAttribute("height", "360")
+
+ this.#backdropElement = document.createElementNS(svgns, "g")
+ this.bubblesElement.classList.add("bubbles")
+ g.append(this.#background, this.#backdropElement, this.spritesElement, this.bubblesElement)
+
+ this.backdrop = this.makeBackdrop()
+
+ this.element.addEventListener("pointerdown", event =>
+ {
+ this.#updateMouse(event)
+ if (event.button === 0) this.#mouseDown = true
+ if (!event.target.closest(".sprite")) this.dispatcher.dispatch("click", this.scripts.map(({id}) => id))
+ })
+
+ addEventListener("pointerup", event =>
+ {
+ if (event.button === 0) {
+ this.#mouseDown = false
+ this.dragged = undefined
+ }
+ })
+
+ addEventListener("pointermove", event => this.#updateMouse(event))
+ this.element.addEventListener("contextmenu", event => event.preventDefault())
+
+ addEventListener("keyup", event => this.#keys.delete(keyMap.get(event.key)))
+ addEventListener("keydown", event =>
+ {
+ this.dispatcher.dispatch("key: any")
+ let key = keyMap.get(event.key)
+ if (!key) return
+ this.#keys.add(key)
+ this.dispatcher.dispatch("key: " + key)
+ })
+
+ this.audioDestination.connect(audioContext.destination)
+
+ this.library.push(
+ this.WhenClicked, this.WhenReceived, this.WhenKeyPressed,
+ ...makeLibrary(this),
+ )
+
+ this.updateMessages()
+ }
+
+ get backdrop()
+ {
+ return this.#backdrop
+ }
+
+ set backdrop(backdrop)
+ {
+ this.#backdrop = backdrop
+ this.#backdropElement.textContent = ""
+ this.#backdropElement.append(backdrop.element)
+ }
+
+ makeSprite(options = {})
+ {
+ return new Sprite(this, undefined, options)
+ }
+
+ getSprite(name)
+ {
+ return this.sprites.find(sprite => sprite.name === name)
+ }
+
+ makeBackdrop(options = {})
+ {
+ let getBackdrop = () => this.backdrop
+ let setBackdrop = backdrop => this.backdrop = backdrop
+ let backdrop = new Costume(options, {
+ name: "backdrop", x: 0, y: 0,
+ costumes: this.backdrops,
+ get costume() { return getBackdrop() },
+ set costume(costume) { setBackdrop(costume) },
+ })
+ this.backdrops.push(backdrop)
+ return backdrop
+ }
+
+ onStart(fn)
+ {
+ this.#startListeners.push(fn)
+ }
+
+ async start(ids)
+ {
+ for (let fn of this.#startListeners) fn()
+ for (let sprite of this.sprites) {
+ while (sprite.clones.length > 0) sprite.clones[0].delete()
+ }
+ for (let element of this.bubblesElement.children) element.textContent = ""
+ await this.#compile(this.scripts, {variables: this.variables, lists: this.lists, dispatcher: this.dispatcher})
+ for (let sprite of this.sprites) await this.#compile(sprite.scripts, {variables: sprite.variables, lists: sprite.lists, dispatcher: this.dispatcher}, sprite)
+ return this.dispatcher.start(ids)
+ }
+
+ stop(ids)
+ {
+ this.dispatcher.stop(ids)
+ this.dispatcher.remove(ids)
+ }
+
+ async run(block, sprite = this)
+ {
+ let index = block.parent?.stack?.indexOf(block) ?? -1
+ let blocks = (index < 0 ? [block] : block.parent.stack.slice(index)).map(duplicate)
+ let result
+ if (block.type.shape === "reporter") {
+ let block = blocks[0]
+ result = new Promise(resolve =>
+ {
+ let Report = MakeBlock({run: value => resolve(toString(value)), name: value => ["report", value], slots: [TextSlot]})
+ blocks = [Report(block)]
+ })
+ result = result.then(value => { removeInput(block) ; return value })
+ }
+ if (block.type.hat) blocks = blocks[0].stack
+ let whenever = this.#Whenever()
+ append(whenever, ...blocks)
+ let [other] = await this.#compile([whenever, ...sprite.scripts], {variables: sprite.variables, lists: sprite.lists, dispatcher: this.dispatcher}, sprite)
+ return {
+ stop: () => this.stop([other.id]),
+ result: this.dispatcher.dispatch("run", [other.id])
+ .then(() => this.dispatcher.remove([other.id]))
+ .then(() => result),
+ }
+ }
+
+ onExtensionLoaded(fn)
+ {
+ this.#loadListeners.push(fn)
+ }
+
+ async addExtension(name)
+ {
+ if (!extensionNames.includes(name)) return
+ if (this.extensionMap.has(name)) return
+ let {extendStage = () => { }, extendSprite = () => { }, extendClone = extendSprite} = await import(`./${name}.js`)
+ this.extensionMap.set(name, {extendStage, extendSprite, extendClone})
+ this.extensions[name] = extendStage(this, name)
+ for (let sprite of this.sprites) {
+ sprite.extensions[name] = extendSprite(sprite, name)
+ for (let clone of sprite.clones) clone.extensions[name] = extendClone(clone, name)
+ }
+ for (let fn of this.#loadListeners) fn(name)
+ return true
+ }
+
+ updateMessages(defaultMessage = "message1")
+ {
+ this.messages.length = 0
+ this.messages.push(defaultMessage)
+ for (let block of [this, ...this.sprites].flatMap(sprite => sprite.scripts).flatMap(flatten)) {
+ for (let [i, slot] of block.type.slots?.entries() ?? []) {
+ if (slot.type !== this.ClosedMessageSlot.type && slot.type !== this.OpenMessageSlot.type) {
+ continue
+ }
+ let input = block.inputs[i]
+ if (input.type.shape !== "slot") continue
+ if (this.messages.includes(input.value)) continue
+ this.messages.push(input.value)
+ }
+ }
+ }
+
+ #updateMouse(event)
+ {
+ this.pointerX = event.x
+ this.pointerY = event.y
+ let rect = this.#background.getBoundingClientRect()
+ this.#mouseX = (event.x - rect.x) * (480 / rect.width) - 240
+ this.#mouseX = Math.max(-240, this.#mouseX)
+ this.#mouseX = Math.min(240, this.#mouseX)
+ this.#mouseY = 180 - (event.y - rect.y) * (360 / rect.height)
+ this.#mouseY = Math.max(-180, this.#mouseY)
+ this.#mouseY = Math.min(180, this.#mouseY)
+ if (this.dragged) {
+ this.dragged.x = this.#mouseX
+ this.dragged.y = this.#mouseY
+ }
+ }
+
+ #other(id)
+ {
+ for (let {scripts} of [this, ...this.sprites, ...this.sprites.flatMap(sprite => sprite.clones)]) {
+ if (!scripts.some(block => block.id === id)) continue
+ return scripts.filter(block => block.id !== id).map(block => block.id)
+ }
+ return []
+ }
+
+ async #compile(blocks, options, sprite = this)
+ {
+ for (let {transform} of Object.values(this.extensions)) {
+ if (!transform) continue
+ blocks = blocks.map(duplicate)
+ await transform(blocks, sprite)
+ }
+ compileForDispatcher(blocks, options)
+ return blocks
+ }
+
+ ////// BLOCKS /////
+
+ broadcast(message)
+ {
+ this.broadcastAndWait(message)
+ }
+
+ async broadcastAndWait(message)
+ {
+ await this.dispatcher.dispatch("message: " + toString(message))
+ }
+
+ // todo: allow switching to next/previous/random backdrop
+ setBackdrop(which)
+ {
+ let name = toString(which)
+ let index = Math.round(toNumber(which))
+ this.backdrop = this.backdrops.find(backdrop => backdrop.name === name) ?? this.backdrops[index - 1] ?? this.backdrop
+ }
+
+ nextBackdrop()
+ {
+ this.backdrop = this.backdrops[(this.backdrops.indexOf(backdrop) + 1) % this.backdrops.length] ?? this.backdrop
+ }
+
+ backdropName()
+ {
+ return this.backdrop.name
+ }
+
+ backdropNumber()
+ {
+ return this.backdrops.indexOf(this.backdrop) + 1
+ }
+
+ mouseDown()
+ {
+ return this.#mouseDown
+ }
+
+ getMouseX()
+ {
+ return this.#mouseX
+ }
+
+ getMouseY()
+ {
+ return this.#mouseY
+ }
+
+ spriteX(name)
+ {
+ return this.getSprite(name)?.x ?? 0
+ }
+
+ spriteY(name)
+ {
+ return this.getSprite(name)?.y ?? 0
+ }
+
+ spriteDirection(name)
+ {
+ return this.getSprite(name)?.direction ?? 0
+ }
+
+ spriteSize(name)
+ {
+ return this.getSprite(name)?.size ?? 0
+ }
+
+ spriteCostumeName(name)
+ {
+ return this.getSprite(name)?.costume.name ?? 0
+ }
+
+ spriteCostumeNumber(name)
+ {
+ let sprite = this.getSprite(name)
+ if (sprite) return sprite.costumes.indexOf(sprite.costume) + 1
+ return 0
+ }
+
+ // todo: make this a dropdown
+ // todo: figure out a way to avoid duplicating the blocks and recompile
+ async cloneSprite(name)
+ {
+ name = toString(name)
+ let sprite = this.sprites.find(sprite => sprite.name === name)
+ if (!sprite) return
+ let clone = new Sprite(this, sprite)
+ await this.#compile(clone.scripts, {variables: clone.variables, lists: clone.lists, dispatcher: this.dispatcher}, clone)
+ this.dispatcher.dispatch("clone", clone.scripts.map(({id}) => id))
+ return clone
+ }
+
+ keyPressed(key)
+ {
+ return this.#keys.has(toString(key))
+ }
+
+ // todo: add more blocks
+}
+
+class Sprite {
+
+ WhenClicked = MakeBlock({hat: true, run: event => event === "click", name: () => ["when this sprite clicked"], category: "events"})
+ WhenCloned = MakeBlock({hat: true, run: event => event === "clone", name: () => ["when I start as a clone"], category: "control"})
+ element = document.createElementNS(svgns, "g")
+ costumes = []
+ selfVariables = new Map()
+ selfLists = new Map()
+ variables = {
+ get: (name) => this.selfVariables.get(name) ?? this.stage.variables.get(name),
+ set: (name, value) =>
+ {
+ if (this.stage.variables.has(name)) this.stage.variables.set(name, {value})
+ else this.selfVariables.set(name, {value})
+ },
+ keys: () => [...this.selfVariables.keys(), ...this.stage.variables.keys()],
+ has: (name) => this.selfVariables.has(name) || this.stage.variables.has(name),
+ }
+ lists = {
+ get: (name) => this.selfLists.get(name) ?? this.stage.lists.get(name),
+ set: (name, value) => this.selfLists.set(name, value),
+ keys: () => [...this.selfLists.keys(), ...this.stage.lists.keys()],
+ has: (name) => this.selfLists.has(name) || this.stage.lists.has(name),
+ }
+ scripts = []
+ library = []
+ extensions = {}
+ #bubble = document.createElement("div")
+ #rotationStyle = "full"
+ #direction
+ #x = 0
+ #y = 0
+ #size = 100
+ #volume = 100
+ #effects = {
+ brightness: 0,
+ ghost: 0,
+ color: 0,
+ }
+ #moveListeners = []
+ #costume
+ #updatePending = false
+ #original
+
+ constructor(stage, original, options = {})
+ {
+ this.name = original?.name ?? options.name ?? "Sprite" + (stage.sprites.length + 1)
+ this.stage = stage
+ this.#original = original
+
+ this.audioDestination = audioContext.createGain()
+ this.direction = original?.direction ?? 90
+ this.x = original?.x ?? 0
+ this.y = original?.y ?? 0
+ this.size = original?.size ?? 100
+ this.volume = original?.volume ?? 100
+
+ this.element.classList.add("sprite")
+ if (original) original.element.after(this.element)
+ else this.stage.spritesElement.append(this.element)
+
+ this.#bubble.classList.add("bubble")
+ this.stage.bubblesElement.append(this.#bubble)
+
+ this.element.addEventListener("pointerdown", event =>
+ {
+ if (event.button !== 0) return
+ stage.dispatcher.dispatch("click", this.scripts.map(({id}) => id))
+ if (this.draggable) this.stage.dragged = this
+ })
+
+ if (original) {
+ for (let costume of original.costumes) this.makeCostume({name: costume.name, x: costume.x, y: costume.y, url: costume.url, blob: costume.blob})
+ this.costume = this.costumes[original.costumes.indexOf(original.costume)]
+ }
+ else {
+ this.costume = this.makeCostume()
+ }
+
+ this.element.append(this.costume.element)
+
+ for (let [name, {value}] of original?.selfVariables ?? []) this.selfVariables.set(name, {value})
+ for (let [name, list] of original?.selfLists ?? []) this.selfLists.set(name, list.slice())
+
+ this.audioDestination.connect(audioContext.destination)
+
+ this.library.push(
+ this.WhenClicked, this.WhenCloned,
+ ...makeLibrary(this),
+ )
+
+ if (original) {
+ this.original = original.original ?? original
+ this.clones = this.original.clones
+ }
+ else {
+ this.clones = []
+ }
+
+ if (!original) {
+ this.stage.sprites.push(this)
+ for (let [name, {extendSprite}] of this.stage.extensionMap) this.extensions[name] = extendSprite(this, name)
+ }
+ else {
+ original.clones.push(this)
+ for (let [name, {extendClone}] of this.stage.extensionMap) this.extensions[name] = extendClone(this, name)
+ if (!original.visible) this.hide()
+ this.scripts.push(...original.scripts.map(block => map(block, (block, inputs, stack) =>
+ {
+ if (typeof block.type === "string") return
+ let found = Object.entries(original).find(([_name, fn]) => typeof fn === "function" && fn.type === block.type)
+ for (let extension of Object.values(this.extensions)) {
+ if (found) break
+ if (!extension?.blocks) continue
+ found = Object.entries(extension.blocks).find(([_name, fn]) => typeof fn === "function" && fn.type === block.type)
+ }
+ if (!found) return
+ let other = this[found[0]](...inputs)
+ append(other, ...stack ?? [])
+ return other
+ })))
+ }
+
+ this.update()
+ }
+
+ get rotationStyle()
+ {
+ return this.#rotationStyle
+ }
+
+ get direction()
+ {
+ return this.#direction
+ }
+
+ set direction(direction)
+ {
+ this.#direction = toNumber(direction) % 360
+ if (this.#direction <= -180) this.#direction = 360 - this.#direction
+ if (this.#direction > 180) this.#direction -= 360
+ this.update()
+ }
+
+ get x()
+ {
+ return this.#x
+ }
+
+ set x(x)
+ {
+ this.#x = x
+ this.update()
+ }
+
+ get y()
+ {
+ return this.#y
+ }
+
+ set y(y)
+ {
+ this.#y = y
+ this.update()
+ }
+
+ get size()
+ {
+ return this.#size
+ }
+
+ set size(size)
+ {
+ this.#size = size
+ this.update()
+ }
+
+ get volume()
+ {
+ return this.#volume
+ }
+
+ set volume(volume)
+ {
+ this.#volume = volume
+ this.audioDestination.gain.value = volume / 100
+ }
+
+ get costume()
+ {
+ return this.#costume
+ }
+
+ set costume(costume)
+ {
+ this.#costume = costume
+ this.element.textContent = ""
+ if (costume) this.element.append(costume.element)
+ }
+
+ makeCostume(options = {})
+ {
+ let getCostume = () => this.costume
+ let setCostume = costume => this.costume = costume
+ let costume = new Costume(options, {
+ name: "costume", x: 0, y: 0,
+ costumes: this.costumes,
+ get costume() { return getCostume() },
+ set costume(costume) { setCostume(costume) },
+ })
+ this.costumes.push(costume)
+ return costume
+ }
+
+ remove()
+ {
+ if (this.original) return
+ for (let clone of this.clones) clone.delete()
+ this.stage.sprites.splice(this.stage.sprites.indexOf(this), 1)
+ this.element.remove()
+ this.#bubble.remove()
+ }
+
+ getFilter()
+ {
+ let filter = []
+
+ // todo: saturate slightly (like in Scratch)
+ if (this.#effects.color !== 0) filter.push(`hue-rotate(${this.#effects.color / 200}turn)`)
+
+ if (this.#effects.brightness !== 0) {
+ if (this.#effects.brightness > 99) filter.push("brightness(0) invert()")
+ else filter.push(`brightness(${1 / (1 - this.#effects.brightness / 100)})`)
+ }
+
+ if (this.#effects.ghost !== 0) filter.push(`opacity(${1 - this.#effects.ghost / 100})`)
+
+ return filter.join(" ")
+ }
+
+ update()
+ {
+ if (this.#updatePending) return
+ this.#updatePending = true
+ requestAnimationFrame(() => this.#update())
+ }
+
+ onMove(fns)
+ {
+ this.#moveListeners.push(fns)
+ }
+
+ get visible()
+ {
+ return !this.element.hasAttribute("opacity")
+ }
+
+ #getRotation()
+ {
+ if (this.rotationStyle === "full") return `rotate(${this.direction - 90})`
+ if (this.rotationStyle === "left-right") return this.direction < 0 || this.direction >= 180 ? "scale(-1, 1)" : ""
+ if (this.rotationStyle === "none") return ""
+ }
+
+ #move(fn)
+ {
+ for (let {before} of this.#moveListeners) before()
+ fn()
+ for (let {after} of this.#moveListeners) after()
+ }
+
+ #update()
+ {
+ this.#updatePending = false
+ this.element.style.setProperty("filter", this.getFilter())
+ this.element.setAttribute("transform", `translate(${this.x}, ${-this.y}) scale(${this.size / 100}) ${this.#getRotation()}`)
+ if (this.#effects.ghost >= 50) this.element.style.setProperty("pointer-events", "none")
+ else this.element.style.removeProperty("pointer-events")
+ if (!this.#bubble.textContent) return
+ let rect0 = this.stage.bubblesElement.getBoundingClientRect()
+ let rect = this.costume.image.getBoundingClientRect()
+ this.#bubble.style.setProperty("left", `${(rect.right - rect0.left) / (rect0.width / 480) - 8}px`)
+ this.#bubble.style.setProperty("bottom", `${(rect0.bottom - rect.top) / (rect0.height / 360) - 8}px`)
+ }
+
+ ///// BLOCKS /////
+
+ move(n)
+ {
+ this.#move(() =>
+ {
+ n = toNumber(n)
+
+ if (this.rotationStyle !== "full") {
+ if (this.rotationStyle === "left-right" && (this.direction < 0 || this.direction >= 180)) {
+ this.x -= n
+ }
+ else {
+ this.x += n
+ }
+ return
+ }
+
+ let radians = this.direction * (Math.PI / 180)
+ this.x += Math.sin(radians) * n
+ this.y += Math.cos(radians) * n
+ })
+ }
+
+ rotateRight(degrees)
+ {
+ this.direction += toNumber(degrees)
+ }
+
+ rotateLeft(degrees)
+ {
+ this.direction -= toNumber(degrees)
+ }
+
+ say(message)
+ {
+ this.#bubble.textContent = toString(message).trim()
+ this.update()
+ }
+
+ async sayFor(message, duration)
+ {
+ this.say(message)
+ await new Promise(resolve => setTimeout(resolve, toNumber(duration) * 1000))
+ this.say("")
+ }
+
+ think(thought)
+ {
+ this.say(thought)
+ }
+
+ async thinkFor(thought, duration)
+ {
+ await this.sayFor(thought, duration)
+ }
+
+ gotoXY(x, y)
+ {
+ this.#move(() =>
+ {
+ this.x = toNumber(x)
+ this.y = toNumber(y)
+ })
+ }
+
+ setDirection(n)
+ {
+ this.direction = toNumber(n)
+ }
+
+ setX(n)
+ {
+ this.#move(() => this.x = toNumber(n))
+ }
+
+ setY(n)
+ {
+ this.#move(() => this.y = toNumber(n))
+ }
+
+ setSize(n)
+ {
+ this.size = toNumber(n)
+ }
+
+ changeX(n)
+ {
+ this.#move(() => this.x += toNumber(n))
+ }
+
+ changeY(n)
+ {
+ this.#move(() => this.y += toNumber(n))
+ }
+
+ changeSize(n)
+ {
+ this.size += toNumber(n)
+ }
+
+ async glideToXY(x1, y1, time)
+ {
+ let x0 = this.x
+ let y0 = this.y
+ x1 = toNumber(x1)
+ y1 = toNumber(y1)
+ time *= 1000
+ let later = performance.now() + time
+ while (true) {
+ let now = performance.now()
+ if (now > later) break
+ let t0 = (later - now) / time
+ let t1 = 1 - t0
+ this.gotoXY(x0 * t0 + x1 * t1, y0 * t0 + y1 * t1)
+ await this.stage.tick()
+ }
+ this.gotoXY(x1, y1)
+ }
+
+ getX()
+ {
+ return this.x
+ }
+
+ getY()
+ {
+ return this.y
+ }
+
+ getDirection()
+ {
+ return this.direction
+ }
+
+ getSize()
+ {
+ return this.size
+ }
+
+ clone()
+ {
+ return this.stage.cloneSprite(this.name)
+ }
+
+ delete()
+ {
+ if (!this.#original) return
+ this.clones.splice(this.clones.indexOf(this), 1)
+ let ids = this.scripts.map(({id}) => id)
+ this.stage.stop(ids)
+ this.element.remove()
+ this.#bubble.remove()
+ }
+
+ show()
+ {
+ this.element.removeAttribute("opacity")
+ }
+
+ hide()
+ {
+ this.element.setAttribute("opacity", "0")
+ }
+
+ setCostume(which)
+ {
+ let name = toString(which)
+ let index = Math.round(toNumber(which))
+ this.costume = this.costumes.find(costume => costume.name === name) ?? this.costumes[index - 1] ?? this.costume
+ }
+
+ nextCostume()
+ {
+ this.costume = this.costumes[(this.costumes.indexOf(this.costume) + 1) % this.costumes.length]
+ }
+
+ costumeName()
+ {
+ return this.costume.name
+ }
+
+ costumeNumber()
+ {
+ return this.costumes.indexOf(this.costume) + 1
+ }
+
+ gotoFront()
+ {
+ this.stage.spritesElement.append(this.element)
+ }
+
+ gotoBack()
+ {
+ this.stage.spritesElement.prepend(this.element)
+ }
+
+ goForward(count)
+ {
+ count = Math.round(toNumber(count))
+ if (count < 0) this.goBackward(count)
+ for (let i = 0 ; i < count ; i++) {
+ if (!this.element.nextElementSibling) break
+ this.element.nextElementSibling.after(this.element)
+ }
+ }
+
+ goBackward(count)
+ {
+ count = Math.round(toNumber(count))
+ if (count < 0) this.goForward(count)
+ for (let i = 0 ; i < count ; i++) {
+ if (!this.element.previousElementSibling) break
+ this.element.previousElementSibling.before(this.element)
+ }
+ }
+
+ touchingMouse()
+ {
+ return document.elementsFromPoint(this.stage.pointerX, this.stage.pointerY).some(other => this.element.contains(other))
+ }
+
+ clearEffects()
+ {
+ for (let name in this.#effects) this.#effects[name] = 0
+ this.update()
+ }
+
+ setEffect(name, value)
+ {
+ this.#effects[name] = toNumber(value)
+ this.update()
+ }
+
+ changeEffect(name, value)
+ {
+ this.#effects[name] += toNumber(value)
+ this.update()
+ }
+
+ disableRotation()
+ {
+ this.#rotationStyle = "none"
+ this.update()
+ }
+
+ enableRotation()
+ {
+ this.#rotationStyle = "full"
+ this.update()
+ }
+
+ enableFlipping()
+ {
+ this.#rotationStyle = "left-right"
+ this.update()
+ }
+
+ distanceToSprite(name)
+ {
+ name = toString(name)
+ let other = this.stage.sprites.find(sprite => sprite.name === name)
+ if (!other) return 10000
+ return Math.sqrt((other.x - this.x) ** 2 + (other.y - this.y0) ** 2)
+ }
+
+ // todo: add more blocks
+}
+
+class Costume {
+
+ element = document.createElementNS(svgns, "foreignObject")
+ image = document.createElement("img")
+ #options
+ #blob
+ #x = 0
+ #y = 0
+
+ constructor(options, options1)
+ {
+ this.#options = options1
+ this.element.classList.add("costume")
+ this.element.setAttribute("x", "-240")
+ this.element.setAttribute("y", "-180")
+ this.element.setAttribute("width", "480")
+ this.element.setAttribute("height", "360")
+ this.name = options.name ?? this.#options.name + (this.#options.costumes.length + 1)
+ this.image.draggable = false
+ this.element.append(this.image)
+ this.blob = options.blob ?? blankBlob
+ this.x = options.x ?? 0
+ this.y = options.y ?? 0
+ }
+
+ remove()
+ {
+ this.#revokeURL()
+ this.#options.costumes.splice(this.#options.costumes.indexOf(this), 1)
+ if (this.#options.costume === this) this.#options.costume = this.#options.costumes[0]
+ }
+
+ get blob()
+ {
+ return this.#blob
+ }
+
+ set blob(blob)
+ {
+ this.#revokeURL()
+ this.url = URL.createObjectURL(blob)
+ this.#blob = blob
+ this.image = document.createElement("img")
+ this.image.draggable = false
+ this.image.src = this.url
+ this.image.style.setProperty("opacity", "0")
+ this.image.style.setProperty("pointer-events", "none")
+ this.image.style.setProperty("position", "fixed")
+ this.image.addEventListener("load", async event =>
+ {
+ let target = event.target
+ await new Promise(resolve => setTimeout(resolve, 25))
+ if (target !== this.image) {
+ target.remove()
+ return
+ }
+ this.image.removeAttribute("style")
+ this.element.querySelector("img").replaceWith(this.image)
+ this.#updateOffset()
+ })
+ document.body.append(this.image)
+ }
+
+ get x()
+ {
+ return this.#x
+ }
+
+ get y()
+ {
+ return this.#y
+ }
+
+ set x(x)
+ {
+ this.#x = x
+ this.#updateOffset()
+ }
+
+ set y(y)
+ {
+ this.#y = y
+ this.#updateOffset()
+ }
+
+ #updateOffset()
+ {
+ if (!this.element.contains(this.image)) return
+ let transform = "translate(240px, 180px)"
+ if (this.blob?.type !== "image/svg+xml") transform += " scale(0.5, 0.5)"
+ transform += ` translate(${-this.x}px, ${-this.y}px)`
+ this.image.style.setProperty("transform", transform)
+ }
+
+ #revokeURL()
+ {
+ if (!this.image.complete) {
+ let outer = this
+ this.image.addEventListener("load", revoke)
+ this.image.addEventListener("error", revoke)
+ return
+ function revoke()
+ {
+ outer.#revokeURL()
+ outer.image.removeEventListener("load", revoke)
+ outer.image.removeEventListener("error", revoke)
+ }
+ }
+ if (this.url) URL.revokeObjectURL(this.url)
+ }
+}
+
+class Areas {
+
+ #variables = []
+ #lists = []
+ #sprite
+ #blocks = []
+ #running = new Map()
+ #options
+
+ constructor(stage, sprite, options)
+ {
+ this.stage = stage
+ this.#sprite = sprite
+ this.#options = options
+
+ if (this.stage !== this.#sprite) this.sprite = this.#sprite
+
+ let addGlobalVariable
+ let addGlobalList
+
+ let getLocalVariables = () => [...this.#sprite.variables.keys()]
+ let getLocalLists = () => [...this.#sprite.lists.keys()]
+
+ let getGlobalVariables = () => [...this.stage.variables.keys(), ...this.stage.sprites.flatMap(sprite => [...sprite.selfVariables.keys()])]
+ let getGlobalLists = () => [...this.stage.lists.keys(), ...this.stage.sprites.flatMap(sprite => [...sprite.selfLists.keys()])]
+
+ if (this.#sprite !== this.stage) {
+ addGlobalVariable = name => this.stage.variables.set(name, {value: 0})
+ addGlobalList = name => this.stage.lists.set(name, [])
+ }
+ else {
+ getLocalVariables = getGlobalVariables
+ getLocalLists = getGlobalLists
+ }
+
+ let addVariableButton = document.createElement("button")
+ addVariableButton.append(this.#options.messages?.getVariable?.() ?? "make a variable")
+ addVariableButton.classList.add("make", "make-variable")
+ addVariableButton.addEventListener("click", async () =>
+ {
+ await addVariable({
+ addLocal: name => this.#sprite.variables.set(name, {value: 0}),
+ addGlobal: addGlobalVariable,
+ locals: getLocalVariables(),
+ globals: getGlobalVariables(),
+ messages: this.#options.messages,
+ })
+
+ this.update()
+ })
+
+ let addListButton = document.createElement("button")
+ addListButton.append(this.#options.messages?.getList?.() ?? "make a list")
+ addListButton.classList.add("make", "make-list")
+ addListButton.addEventListener("click", async () =>
+ {
+ await addList({
+ addLocal: name => this.#sprite.lists.set(name, []),
+ addGlobal: addGlobalList,
+ locals: getLocalLists(),
+ globals: getGlobalVariables(),
+ messages: this.#options.messages,
+ })
+
+ this.update()
+ })
+
+ let addBlockButton = document.createElement("button")
+ addBlockButton.append(this.#options.messages?.getBlock?.() ?? "make a block")
+ addBlockButton.classList.add("make", "make-block")
+ addBlockButton.addEventListener("click", async () =>
+ {
+ let block = await addBlock({scripts:
+ this.#sprite.scripts,
+ messages: this.#options.messages,
+ getName: this.#options.getName,
+ })
+ if (!block) return
+ this.#sprite.scripts.push(block)
+ this.update()
+ })
+
+ let addExtensionButtons = []
+ let loadedExtensions = Object.keys(this.stage.extensions)
+ for (let name of extensionNames) {
+ let button = document.createElement("button")
+ addExtensionButtons.push(button)
+ button.disabled = loadedExtensions.includes(name)
+ button.append(this.#options.getExtensionName?.(name) ?? name)
+ button.classList.add("add-extension", `add-${name}-extension`)
+ button.addEventListener("click", () =>
+ {
+ button.disabled = true
+ this.stage.addExtension(name)
+ })
+ this.stage.onExtensionLoaded(name0 =>
+ {
+ if (name0 === name) button.disabled = true
+ })
+ }
+
+ let extra = new Map()
+ extra.set("variables", [addVariableButton])
+ extra.set("lists", [addListButton])
+ extra.set("custom", [addBlockButton])
+ extra.set("extensions", addExtensionButtons)
+
+ for (let category of ["motion", "sound"]) {
+ if (category === "motion" && this.#sprite !== this.stage) continue
+ if (this.#blocks .some(block => block.type.category === category)) continue
+ let div = document.createElement("div")
+ div.classList.add("missing-message")
+ div.append(this.#options.messages?.getMissing?.(category) ?? `no ${category} blocks implemented yet`)
+ extra.set(category, [div])
+ }
+
+ this.programArea = new ProgramArea(sprite.scripts, {
+ variables: this.#variables,
+ lists: this.#lists,
+ onDettached: block => this.#onDettached(block),
+ getName: (...args) => this.#getName(...args),
+ fromSyntax: syntax => this.#fromSyntax(syntax),
+ getItems: block => this.#getItems(block),
+ messages: this.#options.messages,
+ addValue: block => this.#addValue(block),
+ getAddMessage: type => type.options === this.stage.messages ? this.#options.messages?.getAddMessage?.() : undefined,
+ })
+
+ this.libraryArea = new LibraryArea(this.#blocks, block => categories0.includes(block.type.category) ? block.type.category : "extensions", {
+ order: categories0,
+ toName: name => this.#options.toCategoryName?.(name) ?? (name === "custom" ? "my blocks" : name),
+ extra,
+ getName: (...args) => this.#getName(...args),
+ getItems: block => this.#getItems(block),
+ variables: this.#variables,
+ lists: this.#lists,
+ messages: this.#options.messages,
+ getAddMessage: type => type.options === this.stage.messages ? this.#options.messages?.getAddMessage?.() : undefined,
+ })
+
+ new History([this.programArea])
+ new Scrollable({element: this.programArea.element})
+ new Scrollable({element: this.libraryArea.element, x: false})
+ this.programArea.onPaste(() => this.#updateLibrary())
+
+ let toRun
+
+ this.programArea.element.addEventListener("pointerdown", event =>
+ {
+ if (event.button !== 0) return
+ let id = closestBlockID(event.target)
+ if (!id) return
+ let block = this.#sprite.scripts.find(script => flatten(script.parent ?? script).some(block => block.id === id))
+ let element = query(id, this.programArea.element)
+ if (!element) return
+ toRun = {block, element}
+ })
+
+ this.libraryArea.element.addEventListener("pointerdown", event =>
+ {
+ if (event.button !== 0) return
+ let id = closestBlockID(event.target)
+ if (!id) return
+ let block = this.libraryArea.getBlock(id)
+ let element = query(id, this.libraryArea.element)
+ if (!element) return
+ toRun = {block, element}
+ })
+
+ addEventListener("pointerup", event =>
+ {
+ if (event.button !== 0) return
+ if (toRun?.element.contains(event.target)) this.run(toRun.block, toRun.element)
+ toRun = undefined
+ })
+
+ this.scripts = sprite.scripts
+ this.variables = sprite.variables
+ this.lists = sprite.lists
+
+ this.stage.onExtensionLoaded(() => this.#updateLibrary())
+ this.update()
+ }
+
+ async run(block, element)
+ {
+ let stop0 = this.#running.get(block.id)
+ if (stop0) {
+ if (typeof stop0 === "function") stop0()
+ return
+ }
+
+ let scriptElement = element?.closest(".script") ?? element
+ scriptElement?.classList.add("running")
+ this.#running.set(block.id, true)
+
+ let {result, stop} = await this.stage.run(block, this.#sprite)
+ this.#running.set(block.id, stop)
+ result = await result
+
+ this.#running.delete(block.id)
+ scriptElement?.classList.remove("running")
+
+ if (block.type.shape !== "reporter" || !element) return
+
+ let rect = element.getBoundingClientRect()
+ let div = document.createElement("div")
+
+ div.append(result)
+ div.style.setProperty("top", `${rect.bottom}px`)
+ div.style.setProperty("left", `${rect.left + rect.width / 2}px`)
+ div.classList.add("report")
+ addEventListener("pointerdown", () => div.remove(), {once: true})
+ document.body.append(div)
+ }
+
+ update()
+ {
+ this.#variables.length = 0
+ this.#lists.length = 0
+ this.#variables.push(...this.#sprite.variables.keys())
+ this.#lists.push(...this.#sprite.lists.keys())
+ this.#updateLibrary()
+ this.programArea.update()
+ }
+
+ onUpdate(fn)
+ {
+ this.programArea.onUpdate(fn)
+ }
+
+ #updateLibrary()
+ {
+ this.stage.updateMessages(this.#options.messages?.getMessage?.() ?? "message1")
+ let library = []
+ library.push(
+ ...this.stage.scratch.library
+ .filter(fn => !fn.type?.references?.includes("variable"))
+ .filter(fn => !fn.type?.references?.includes("list")),
+ ...this.#sprite.library,
+ ...this.#sprite.scripts.filter(block => block.type === Define.type).map(block => () => Custom(block.names[0])),
+ ...makeExtensionLibrary(this.#sprite),
+ ...VariableLibrary([...this.#sprite.variables.keys()]),
+ ...ListLibrary([...this.#sprite.lists.keys()]),
+ )
+ if (this.sprite) {
+ library.push(
+ ...this.stage.library.filter(make => !make.type || !make.type.stageOnly),
+ ...makeExtensionLibrary(this.stage, this.#sprite),
+ )
+ }
+ this.#blocks.length = 0
+ this.#blocks.push(...library.map(make => this.#options.Make?.(this, make) ?? make).map(make => make()))
+ this.libraryArea.update()
+ }
+
+ #fromSyntax(syntax)
+ {
+ let variables = [...this.#sprite.variables.keys()]
+ let lists = [...this.#sprite.lists.keys()]
+ let custom = this.#sprite.scripts.filter(block => block.type === Define.type).map(block => block.names[0])
+
+ let variablesLength = variables.length
+ let listsLength = lists.length
+ let customLength = custom.length
+
+ let blocks = fromSyntax(syntax, {
+ mutate: true,
+ variables, lists, custom,
+ types: this.#blocks.map(block => block.type),
+ getName: (...args) => this.#getName(...args),
+ })
+
+ variables = variables.slice(variablesLength)
+ lists = lists.slice(listsLength)
+ custom = custom.slice(customLength).filter(name => !blocks.some(block => block.type === Define.type && compareName(name, block.names[0])))
+
+ for (let name of variables) this.#sprite.variables.set(name, {value: 0})
+ for (let name of lists) this.#sprite.lists.set(name, [])
+ for (let name of custom) this.#sprite.scripts.push(Define(name))
+
+ return blocks.filter(block => block.type !== Define.type || !this.#sprite.scripts.some(block1 => block1.type === Define.type && compareName(block.names[0], block1.names[0])))
+ }
+
+ #getItems(block)
+ {
+ if (block?.type !== Variable.type && block?.type !== List.type) return
+ return [{
+ label: "delete",
+ run: () =>
+ {
+ let locals = block.type === Variable.type ? this.#sprite.selfVariables : this.#sprite.selfLists
+ let globals = block.type === Variable.type ? this.stage.variables : this.stage.lists
+ let removed = block.type === Variable.type ? "variable" : "list"
+ let isLocal = [...locals?.keys() ?? []].includes(block.value)
+ let sprites = isLocal ? [this.#sprite] : [this.stage, ...this.stage.sprites]
+ for (let {scripts} of sprites) {
+ for (let block1 of scripts.flatMap(flatten)) {
+ if (!block1.type.references?.includes(removed)) continue
+ let index = scripts.indexOf(block1)
+ if (index < 0) remove(block1)
+ else scripts.splice(index, 1)
+ }
+ }
+ if (isLocal) locals.delete(block.names[0])
+ else globals.delete(block.names[0])
+ this.update()
+ },
+ }]
+ }
+
+ #onDettached(block)
+ {
+ this.#running.get(block.id)?.()
+ if (block.type === Define.type) this.update()
+ }
+
+ #getName(...args)
+ {
+ return this.#options.getName?.(...args) ?? this.stage.scratch.getName(...args)
+ }
+
+ async #addValue(block)
+ {
+ if (block.type.options !== this.stage.messages) return
+ let value = await addValue({messages: this.#options.messages})
+ if (!value) return
+ block.type.options.push(value)
+ block.value = value
+ this.update()
+ }
+}
+
+export class StageAreas extends Areas {
+
+ constructor(stage, options = {})
+ {
+ super(stage, stage, options)
+ }
+}
+
+export class SpriteAreas extends Areas {
+
+ constructor(sprite, options = {})
+ {
+ super(sprite.stage, sprite, options)
+ }
+}
+
+function makeLibrary(sprite)
+{
+ let combos = new Map()
+ let library = []
+ for (let name of Object.keys(nameFns)) {
+ if (!sprite[name]) continue
+ let upperCaseName = name[0].toUpperCase() + name.slice(1)
+ let slots1 = slots[name]
+ if (typeof slots1 === "function") slots1 = slots1(sprite)
+ let make = MakeBlock({
+ async: sync[name] === "async",
+ run: (...args) => sprite[name](...args),
+ slots: slots1 ?? defaults[name]?.map(n => typeof n === "number" ? NumberSlot : TextSlot),
+ category: categories[name] ?? "motion",
+ name: (...inputs) => nameFn(...inputs),
+ shape: shapes[name],
+ defaults: defaults[name],
+ stageOnly: stageOnly[name],
+ output: outputs[name] ?? (shapes[name] === "reporter" ? "text" : undefined),
+ cap: isCap[name],
+ })
+ sprite[upperCaseName] = make
+ let nameFn = nameFns[name]
+ if (typeof nameFn !== "function" && nameFn !== undefined) {
+ let {label, name} = nameFn
+ let combo = combos.get(name) ?? {options: []}
+ combos.set(name, combo)
+ combo.options.push({morph: block => make(...block.inputs), label, type: make.type})
+ nameFn = (...inputs) => nameFns[name](...inputs, combo)
+ if (combo.options.length > 1) continue
+ }
+ library.push(make)
+ }
+ return library
+}
+
+function makeExtensionLibrary(sprite, sprite0 = sprite)
+{
+ let extensions = sprite.extensions
+ let [variable] = sprite0.variables.keys()
+ let [list] = sprite0.lists.keys()
+ let library = []
+ for (let extension of Object.values(extensions)) {
+ for (let make of Object.values(extension?.blocks ?? {})) {
+ if (make.type.references?.includes("variable") && !variable) continue
+ if (make.type.references?.includes("list") && !list) continue
+ if (make.type.references?.length) {
+ library.push(() => make(...make.type.references.map(reference => reference === "list" ? list : variable)))
+ continue
+ }
+ library.push(make)
+ }
+ }
+ return library
+}
+
+let nameFns = {
+
+ // motion
+ move: count => ["move", count, "steps"],
+ rotateRight: degrees => ["turn", [getIcon("turn-right"), "right", "cw"], degrees, "degrees"],
+ rotateLeft: degrees => ["turn", [getIcon("turn-left"), "right", "ccw"], degrees, "degrees"],
+ // todo: goto [ v]
+ gotoXY: (x, y) => ["go to x:", x, "y:", y],
+ // todo: glide to [ v]
+ glideToXY: (x, y, seconds) => ["glide", seconds, "secs to x:", x, "y:", y],
+ setDirection: degrees => ["point in direction", degrees],
+ // todo: point towards
+ changeX: x => ["change x by", x],
+ setX: x => ["set x to", x],
+ changeY: y => ["change y by", y],
+ setY: y => ["set y to", y],
+ // todo: bounce
+ enableRotation: {name: "setRotationStyle", label: "all around"},
+ enableFlipping: {name: "setRotationStyle", label: "left-right"},
+ disableRotation: {name: "setRotationStyle", label: "don't rotate"},
+ setRotationStyle: style => ["set rotation style", style],
+ getX: () => ["x position"],
+ getY: () => ["y position"],
+ getDirection: () => ["direction"],
+
+ // looks
+ sayFor: (message, duration) => ["say", message, "for", duration, "seconds"],
+ say: message => ["say", message],
+ thinkFor: (thought, duration) => ["think", thought, "for", duration, "seconds"],
+ think: thought => ["think", thought],
+ setCostume: costume => ["switch costume to", costume],
+ setBackdrop: which => ["switch backdrop to", which],
+ nextCostume: () => ["next costume"],
+ nextBackdrop: () => ["next backdrop"],
+ // todo: switch backdrop and wait
+ changeSize: size => ["change size by", size],
+ setSize: size => ["set size to", size, "%"],
+ changeEffect: (effect, value) => ["change", effect, "effect by", value],
+ setEffect: (effect, value) => ["set", effect, "effect to", value],
+ clearEffects: () => ["clear graphic effects"],
+ show: () => ["show"],
+ hide: () => ["hide"],
+ gotoLayer: which => ["go to", which, "layer"],
+ gotoBack: {name: "gotoLayer", label: "back"},
+ gotoFront: {name: "gotoLayer", label: "front"},
+ changeLayer: (count, which) => ["go", which, count, "layers"],
+ goForward: {name: "changeLayer", label: "forward"},
+ goBackward: {name: "changeLayer", label: "backward"},
+ costumeNumberOrName: which => ["costume", which],
+ costumeName: {name: "costumeNumberOrName", label: "name"},
+ costumeNumber: {name: "costumeNumberOrName", label: "number"},
+ backdropNameOrNumber: which => ["backdrop", which],
+ backdropName: {name: "backdropNameOrNumber", label: "name"},
+ backdropNumber: {name: "backdropNameOrNumber", label: "number"},
+ getSize: () => ["size"],
+
+ // sound
+ // (empty for now)
+
+ // events
+ broadcast: message => ["broadcast", message],
+ broadcastAndWait: message => ["broadcast", message, "and wait"],
+
+ // control
+ cloneThing: thing => ["create clone of", thing],
+ clone: {name: "cloneThing", label: "myself"},
+ cloneSprite: name => ["create clone of", name],
+ delete: () => ["delete this clone"],
+ mouseDown: () => ["mouse down?"],
+
+ // sensing
+ touching: thing => ["touching", thing, "?"],
+ touchingMouse: {name: "touching", label: "mouse-pointer"},
+ // todo: touching color
+ distanceToSprite: name => ["distance to", name],
+ // todo: ask and wait
+ // todo: answer
+ keyPressed: key => ["key", key, "pressed?"],
+ getMouseX: () => ["mouse x"],
+ getMouseY: () => ["mouse y"],
+ // todo: set drag mode
+ // todo: loudness
+ of: (name, property) => [property, "of", name],
+ spriteX: {name: "of", label: "x position"},
+ spriteY: {name: "of", label: "y position"},
+ spriteDirection: {name: "of", label: "direction"},
+ spriteSize: {name: "of", label: "size"},
+ spriteCostumeName: {name: "of", label: "costume name"},
+ spriteCostumeNumber: {name: "of", label: "costume #"},
+}
+
+let defaults = {
+ move: [10],
+ rotateRight: [15],
+ rotateLeft: [15],
+ gotoXY: [0, 0],
+ setDirection: [90],
+ setX: [0],
+ setY: [0],
+ changeX: [0],
+ changeY: [0],
+ setSize: [100],
+ changeSize: [10],
+ glideToXY: [0, 0, 1],
+ broadcast: ["message1"],
+ broadcastAndWait: ["message1"],
+ setBackdrop: [""],
+ goForward: [1],
+ goBackward: [1],
+ setCostume: [""],
+ spriteX: [""],
+ spriteY: [""],
+ spriteDirection: [""],
+ spriteSize: [""],
+ spriteCostumeName: [""],
+ spriteCostumeNumber: [""],
+ say: ["Hello!"],
+ sayFor: ["Hello!", 2],
+ think: ["Hmm..."],
+ thinkFor: ["Hmm...", 2],
+ cloneSprite: [""],
+ distanceToSprite: [""],
+ changeEffect: ["color", 25],
+ setEffect: ["color", 0],
+ keyPressed: ["space"],
+}
+
+let categories = {
+ changeSize: "looks",
+ setSize: "looks",
+ getSize: "looks",
+ clone: "control",
+ delete: "control",
+ show: "looks",
+ hide: "looks",
+ broadcast: "events",
+ broadcastAndWait: "events",
+ setBackdrop: "looks",
+ nextBackdrop: "looks",
+ backdropName: "looks",
+ backdropNumber: "looks",
+ setCostume: "looks",
+ nextCostume: "looks",
+ costumeName: "looks",
+ costumeNumber: "looks",
+ mouseDown: "sensing",
+ getMouseX: "sensing",
+ getMouseY: "sensing",
+ gotoFront: "looks",
+ gotoBack: "looks",
+ goForward: "looks",
+ goBackward: "looks",
+ spriteX: "sensing",
+ spriteY: "sensing",
+ spriteDirection: "sensing",
+ spriteSize: "sensing",
+ spriteCostumeName: "sensing",
+ spriteCostumeNumber: "sensing",
+ touchingMouse: "sensing",
+ setEffect: "looks",
+ changeEffect: "looks",
+ clearEffects: "looks",
+ say: "looks",
+ sayFor: "looks",
+ think: "looks",
+ thinkFor: "looks",
+ cloneSprite: "control",
+ distanceToSprite: "sensing",
+ keyPressed: "sensing",
+}
+
+let shapes = {
+ getX: "reporter",
+ getY: "reporter",
+ getDirection: "reporter",
+ getSize: "reporter",
+ backdropName: "reporter",
+ backdropNumber: "reporter",
+ costumeName: "reporter",
+ costumeNumber: "reporter",
+ mouseDown: "reporter",
+ getMouseX: "reporter",
+ getMouseY: "reporter",
+ spriteX: "reporter",
+ spriteY: "reporter",
+ spriteDirection: "reporter",
+ spriteSize: "reporter",
+ spriteCostumeName: "reporter",
+ spriteCostumeNumber: "reporter",
+ touchingMouse: "reporter",
+ distanceToSprite: "reporter",
+ keyPressed: "reporter",
+}
+
+let outputs = {
+ getX: "number",
+ getY: "number",
+ getDirection: "number",
+ getSize: "positive",
+ backdropNumber: "natural",
+ costumeNumber: "natural",
+ mouseDown: "boolean",
+ getMouseX: "number",
+ getMouseY: "number",
+ spriteX: "number",
+ spriteY: "number",
+ spriteDirection: "number",
+ spriteSize: "positive",
+ spriteCostumeNumber: "natural",
+ touchingMouse: "boolean",
+ distanceToSprite: "positive",
+ keyPressed: "boolean",
+}
+
+let sync = {
+ broadcastAndWait: "async",
+ glideToXY: "async",
+ sayFor: "async",
+ thinkFor: "async",
+}
+
+let slots = {
+ setEffect: [EnumeratedSlot(["color", "brightness", "ghost"]), NumberSlot],
+ changeEffect: [EnumeratedSlot(["color", "brightness", "ghost"]), NumberSlot],
+ broadcast: stage => [stage.OpenMessageSlot],
+ broadcastAndWait: stage => [stage.OpenMessageSlot],
+ keyPressed: [KeySlot],
+}
+
+let stageOnly = {
+ // empty (for now)
+}
+
+let isCap = {
+ delete: true,
+}
+
+function getIcon(name)
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icons[name])
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+let icons = {
+ "turn-right": `<svg viewbox="0 0 24 24" fill="#444" stroke="#444"><path d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z"/></svg>`,
+ "turn-left": `<svg viewbox="0 0 24 24" fill="#444" stroke="#444"><path transform-origin="12 12" transform="scale(-1, 1)" d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z"/></svg>`,
+ "loop-arrow": `<svg viewbox="0 0 24 24" fill="#EEE" stroke="#444"><path transform-origin="12 12" transform="rotate(90)" d="M10 9V7.41c0-.89-1.08-1.34-1.71-.71L3.7 11.29c-.39.39-.39 1.02 0 1.41l4.59 4.59c.63.63 1.71.19 1.71-.7V14.9c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>`,
+ "stop-sign": `<svg viewbox="0 0 24 24" fill="#E24" stroke="#444"><path d="M14.9,3H9.1C8.57,3,8.06,3.21,7.68,3.59l-4.1,4.1C3.21,8.06,3,8.57,3,9.1v5.8c0,0.53,0.21,1.04,0.59,1.41l4.1,4.1 C8.06,20.79,8.57,21,9.1,21h5.8c0.53,0,1.04-0.21,1.41-0.59l4.1-4.1C20.79,15.94,21,15.43,21,14.9V9.1c0-0.53-0.21-1.04-0.59-1.41 l-4.1-4.1C15.94,3.21,15.43,3,14.9,3z"/></svg>`,
+ bolt: `<svg viewbox="6 0 12 24" fill="#EEE" stroke="#444"><path d="M10.67,21L10.67,21c-0.35,0-0.62-0.31-0.57-0.66L11,14H7.5c-0.88,0-0.33-0.75-0.31-0.78c1.26-2.23,3.15-5.53,5.65-9.93 c0.1-0.18,0.3-0.29,0.5-0.29h0c0.35,0,0.62,0.31,0.57,0.66L13.01,10h3.51c0.4,0,0.62,0.19,0.4,0.66c-3.29,5.74-5.2,9.09-5.75,10.05 C11.07,20.89,10.88,21,10.67,21z"/></svg>`,
+}
+
+function toColorElement(block, options)
+{
+ let input = document.createElement("input")
+ input.value = block.value
+ input.type = "color"
+ if (options.frozen) input.disabled = true
+ input.addEventListener("change", () => block.value = value)
+ input.style.setProperty("display", "block")
+ input.style.setProperty("height", "256px")
+ input.style.setProperty("width", "256px")
+ input.style.setProperty("margin", "-128px")
+ input.style.setProperty("border", "none")
+ input.style.setProperty("padding", "0")
+
+ let span = document.createElement("span")
+ span.classList.add("input", "color-input")
+ span.append(input)
+ return span
+}
+
+export let ColorSlot = MakeSlot({toElement: toColorElement, type: "text", normalise: text => text.match(/^#[0-9A-Fa-f]{6}$/) ? text.toUpperCase() : "#000000"})
+
+let css = `
+.stage, .stage *, .stage ::before, .stage ::after {
+ touch-action: none;
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+ user-select: none;
+}
+
+.bubbles {
+ pointer-events: none;
+}
+
+.bubble {
+ background: #FFF;
+ border: 3px solid #888;
+ width: max-content;
+ max-width: 16ch;
+ border-radius: 2em;
+ padding: 0.5em 1em;
+ position: absolute;
+ transform: translate(-8px, 8px);
+ border-bottom-left-radius: 0.5em;
+}
+
+.bubble:empty {
+ display: none;
+}
+
+.report {
+ position: fixed;
+ z-index: 5000;
+ background: #FFF;
+ border: 1px solid #888;
+ border-radius: 0.5em;
+ padding: 0.5em 1em;
+ box-shadow: 0 0 8px #0002;
+ transform: translate(-50%, 8px);
+ max-width: 24em;
+ text-align: center;
+}
+
+.running {
+ filter: drop-shadow(0 0 2px #FD6) drop-shadow(0 0 2px #FD6);
+}
+
+.costume {
+ pointer-events: none;
+}
+
+.costume > img {
+ pointer-events: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: top left;
+}
+
+.color-input::before {
+ content: "x";
+ color: #0000;
+}
+`
+
+let style = document.createElement("style")
+style.textContent = css
+document.head.append(style)
--- /dev/null
+++ b/syntax.js
@@ -1,0 +1,685 @@
+import {append, complement, MakeBlock, replace} from "./model.js"
+import {TextParameter, BooleanParameter, BooleanSlot, Variable, List, Forever, Define, Custom, compareName, TextSlot} from "./core.js"
+
+function escape(text)
+{
+ text = text.replace(/[^-a-zA-Z0-9 _#/]/gu, n => "\\" + n)
+ if (text.endsWith(" v")) text = text.slice(0, -1) + "\\v"
+ return text
+}
+
+function unescape(text)
+{
+ return text.replace(/\\./g, n => n.slice(1))
+}
+
+function toName(name)
+{
+ return name.normalize().replace(/\s+/ug, " ").trim()
+}
+
+let braces = {
+ "(": ")",
+ "[": "]",
+ "<": ">",
+ "{": "}",
+}
+
+function toBareSyntax(block, options)
+{
+ let inputs = []
+ for (let i of block.type.references?.keys() ?? []) inputs.push(`[${escape(block.names[i])} v]`)
+ inputs.push(...block.inputs.map(input => toSyntax0(input, options)))
+ let name = options.getName?.(block.type, ...inputs) ?? block.type.name(...inputs)
+ let array = name.map(part =>
+ {
+ if (part instanceof Array) part = part.find(v => typeof v === "string") ?? ""
+ if (typeof part === "string") return part
+ if (part instanceof Element) return
+ for (let option of part.options) {
+ let type = option.type ?? option.morph(block).type
+ if (block.type === type) return `[${escape(option.label)} v]`
+ }
+ })
+ return array.filter(Boolean)
+}
+
+function toReporterSyntax(block, options)
+{
+ if (block.type.shape === "slot") {
+ if (block.type.options) {
+ let name = options.getName?.(block.type, block.value) ?? block.type.name?.(block.value) ?? block.value
+ if (block.type.canFit) return `[${escape(name)} v]`
+ return `(${escape(name)} v)`
+ }
+ if (block.type === BooleanSlot.type) return "<>"
+ if (block.type.numeric) return `(${block.value})`
+ let value = block.value.replaceAll("]", "\\]")
+ return `[${escape(value)}]`
+ }
+ if (block.type.shape === "reporter" && block.type.output === "boolean") return `<${toBareSyntax(block, options).join(" ")}>`
+ return `(${toBareSyntax(block, options).join(" ")})`
+}
+
+function toCustomBlockSyntax(block, options)
+{
+ let name = []
+ let i = 0
+ for (let part of block.names[0]) {
+ if (typeof part === "string") {
+ name.push(part)
+ continue
+ }
+ if (block.type === Define.type) {
+ if (part.type === "boolean") name.push(`<${escape(part.name)}>`)
+ else name.push(`(${escape(part.name)})`)
+ continue
+ }
+ let input = block.inputs[i++]
+ let text = toSyntax0(input, options)
+ if (input.type.output === "boolean" && part.type === "text") text = `(${text})`
+ name.push(text)
+ }
+
+ return name
+}
+
+function toDefineSyntax(prototype, options)
+{
+ return (options.getName?.(Define.type, prototype) ?? Define.type.name(prototype)).map(part => typeof part === "string" ? toName(part) : part).filter(Boolean)
+}
+
+function toDefineRegex(options)
+{
+ let symbol = Symbol()
+ return new RegExp(`^\\s*${toDefineSyntax(symbol, options).flatMap(part => part === symbol ? ["(.+)"] : part.split(/\s+/g).filter(Boolean).map(part => RegExp.escape(part))).join("\\s+")}\\s*$`, "u")
+}
+
+function wrapPhonyDefine(parts, extra, options)
+{
+ if (parts.map(part => typeof part === "string" ? part : "(())").join(" ").match(toDefineRegex(options))) {
+ extra = [...extra, "stack"]
+ }
+ if (extra.length > 0) return parts.join(" ") + " :: " + extra.join(" ")
+ return parts.join(" ")
+}
+
+function toSyntax0(block, options)
+{
+ if (block.type === Variable.type || block.type === List.type || block.type === TextParameter.type) {
+ return `(${escape(block.names[0])} :: ${block.type.category})`
+ }
+ if (block.type === BooleanParameter.type) {
+ return `<${escape(block.names[0])} :: custom>`
+ }
+ if (block.type === Define.type) {
+ let syntax = toDefineSyntax(toCustomBlockSyntax(block, options).join(" "), options).join(" ")
+ if (block.atomic) return syntax + " // atomic"
+ return syntax
+ }
+ if (block.type.category === "custom") return wrapPhonyDefine(toCustomBlockSyntax(block, options), ["custom"], options)
+ if (block.type.shape === undefined) return wrapPhonyDefine(toBareSyntax(block, options), [], options)
+ return toReporterSyntax(block, options)
+}
+
+export function toSyntax(block, options = {})
+{
+ let syntax = toSyntax0(block, options)
+ if (block.type.hat) {
+ if (block.stack.length > 0) syntax += "\n" + toSyntax(block.stack[0], options)
+ return syntax
+ }
+ if (!block.parent || block.type.shape !== undefined) return syntax
+ if (block.stack) {
+ syntax += "\n"
+ if (block.stack.length > 0) {
+ syntax += toSyntax(block.stack[0], options).replace(/^/mg, "\t") + "\n"
+ }
+ }
+ let block0 = block
+ while (block0.complement) {
+ block0 = block0.complement
+ syntax += toSyntax0(block0, options) + "\n"
+ if (block0.stack.length > 0) {
+ syntax += toSyntax(block0.stack[0], options).replace(/^/mg, "\t") + "\n"
+ }
+ }
+ if (block0.stack) syntax += "end"
+ let index = block.parent.stack.indexOf(block)
+ let next = block.parent.stack[index + 1]
+ if (!next) return syntax
+ return syntax + "\n" + toSyntax(next, options)
+}
+
+export function fromSyntax(syntax, options = {})
+{
+ let blocks = []
+ let block0
+ let block1
+
+ function flush()
+ {
+ if (block0 && block0.stack.length > 0) blocks.push(block0.stack[0])
+ block0 = Forever()
+ block1 = block0
+ }
+
+ flush()
+
+ let parameters0 = {text: options.parameters?.text?.slice() ?? [], boolean: options.parameters?.boolean?.slice() ?? []}
+
+ let variables = options.variables ?? []
+ let lists = options.lists ?? []
+ let custom = options.custom ?? []
+ let parameters = structuredClone(parameters0)
+
+ let types0 = new Set(options.types)
+ let types = new Set(types0)
+
+ while (true) {
+ let found
+ for (let type of [...types]) {
+ if (!type.complement) continue
+ if (types.has(type.complement)) continue
+ types.add(type.complement)
+ found = true
+ }
+ if (!found) break
+ }
+
+ options = {...options, variables, lists, custom, parameters, types, parameters0}
+ if (!options.dry) {
+ if (!options.mutate) options = {...options, variables: variables.slice(), lists: lists.slice(), custom: custom.slice()}
+ fromSyntax(syntax, {...options, dry: true})
+ }
+
+ for (let line of syntax.normalize().split("\n")) {
+
+ let infos = [{type: "block", text: "{{", children: []}]
+
+ let tokens = [...line.matchAll(/\((\\.|[^\(\)\[<{])*\sv\)|\[(\\.|[^\]])*\]|::|[\(\)\[\]{}<>:]|[^\(\)\[\]{}<>:\s]+/ugd)]
+ let comment = ""
+
+ for (let i = 0 ; i < tokens.length ; i++) {
+
+ let [token] = tokens[i]
+ let {indices: [[index]]} = tokens[i]
+
+ if (token.match(/^\[.*\sv\]|\(.*\sv\)$/u)) {
+ let text = unescape(token.slice(1, -2).split(/\s+/ug).join(" "))
+ infos[0].children.push({type: "slot", text})
+ continue
+ }
+
+ if (token.match(/^\[.*\]$/u)) {
+ let text = unescape(token.slice(1, -1))
+ infos[0].children.push({type: "slot", text})
+ continue
+ }
+
+ if (token === "(") {
+ if ((tokens[i + 1]?.[0] ?? ")") === ")") {
+ infos[0].children.push({type: "slot", text: ""})
+ i++
+ continue
+ }
+ let text = unescape(tokens[i + 1]?.[0])
+ if ((tokens[i + 2]?.[0] ?? ")") === ")" && !Number.isNaN(Number(text))) {
+ infos[0].children.push({type: "slot", text})
+ i += 2
+ continue
+ }
+ }
+
+ if (token === "::") {
+ infos[0].children.push({type: "end"})
+ continue
+ }
+
+ if (infos[0].text === "{{" && token.includes("//")) {
+ let j = token.indexOf("//")
+ token = token.slice(0, j)
+ tokens.splice(i)
+ comment = line.slice(index + j)
+ if (!token) continue
+ }
+
+ if (token === "<" && (tokens[i + 1]?.[0] ?? ">") === ">") {
+ infos[0].children.push({type: "slot", text: "", boolean: true})
+ i++
+ continue
+ }
+
+ let ltgt = (token === "<" || token === ">") && infos[0].children.length === 1 && typeof infos[0].children[0] !== "string" && tokens[i + 1]?.[0][0] in braces
+
+ if (token in braces && !ltgt) {
+ let next = {type: "block", text: token, children: []}
+ infos[0].children.push(next)
+ infos.unshift(next)
+ continue
+ }
+
+ if (infos[0].text in braces && token === braces[infos[0].text] && !ltgt) {
+ infos.shift()
+ continue
+ }
+
+ let children = infos[0].children
+ if (typeof children[children.length - 1] !== "string") infos[0].children.push(unescape(token))
+ else children[children.length - 1] += " " + unescape(token)
+ }
+
+ let info = infos[infos.length - 1]
+ if (info.children.length === 0) {
+ if (block1 === block0 || block1.type.hat) flush()
+ continue
+ }
+
+ if (info.children.length === 1 && info.children[0] === "end" && block1.stack) {
+ while (block1.parent?.complement === block1) block1 = block1.parent
+ block1 = block1.parent ?? block1
+ continue
+ }
+
+ let block = match(info, options)
+ if (!block) continue
+ if (!types0.has(block.type) && block.type.category !== "custom") continue
+ if (block.type.shape === "slot") continue
+ if (options.dry) continue
+
+ if (block.type.shape !== undefined) {
+ flush()
+ blocks.push(block)
+ continue
+ }
+
+ if (block.type === Define.type && comment.match(/\batomic\b/)) {
+ block.atomic = true
+ }
+
+ if (block.type.hat) {
+ blocks.push(block)
+ block1 = block
+ continue
+ }
+
+ if (block.type === block1.type.complement) {
+ complement(block1, block)
+ block1 = block
+ continue
+ }
+
+ if (block1.stack[block1.stack.length - 1]?.type.cap) continue
+
+ append(block1, block)
+ if (block.stack) block1 = block
+ }
+
+ flush()
+ return blocks
+}
+
+function match(info, options)
+{
+ let extras = []
+
+ for (;;) {
+ while (info.children.length === 1 && typeof info.children[0] !== "string" && info.children[0].type === "block") {
+ info = info.children[0]
+ }
+ let index = info.children.findIndex(a => a.type === "end")
+ if (index < 0) break
+ extras.push(...info.children.splice(index).slice(1))
+ }
+
+ if (info.children.length === 1) {
+ let name0 = info.children[0]
+ if (typeof name0 === "string" && (info.text === "(" || info.text === "<")) {
+ let name = toName(name0)
+ if (extras.includes("variables") || options.variables.includes(name)) {
+ return options.dry || Variable(name)
+ }
+ if (extras.includes("lists") || options.lists.includes(name)) {
+ return options.dry || List(name)
+ }
+ let type = info.text === "<" ? "boolean" : "text"
+ if (extras.includes("custom") && info.text !== "{" || options.parameters[type].includes(name)) {
+ if (options.dry) return
+ return type === "boolean" ? BooleanParameter(name) : TextParameter(name)
+ }
+ }
+ }
+
+ let match
+ if (info.text === "{{" && !extras.includes("stack")) match = info.children.map((part, i) => typeof part === "string" ? toName(part) : `((${i}))`).filter(Boolean).join(" ").match(toDefineRegex(options))
+
+ if (match) {
+
+ info.children = match[1].split(/(\(\([0-9]+\)\))/g).map(part => info.children[part.match(/^\(\(([0-9]+)\)\)$/)?.[1] ?? -1] ?? toName(part))
+
+ let name = []
+ for (let child of info.children) {
+ if (typeof child === "string") {
+ name.push(child)
+ continue
+ }
+ if (child.type !== "block") continue
+ if (child.children.length !== 1) continue
+ if (typeof child.children[0] !== "string") continue
+ let parameter = {type: child.text === "<" ? "boolean" : "text", name: child.children[0]}
+ name.push(parameter)
+ options.parameters[parameter.type].push(parameter.name)
+ }
+
+ if (name.length !== 0) {
+ if (!options.custom.some(name0 => compareName(name0, name))) options.custom.push(name)
+ if (!options.dry) return Define(name)
+ }
+
+ return
+ }
+
+ if (extras.includes("custom")) return matchCustom(info, options, true)
+
+ let matches = []
+
+ for (let type of options.types) {
+
+ let inputs = [...type.references ?? [], ...type.slots ?? []].map((_a, i) => i)
+ let name = options.getName?.(type, ...inputs) ?? type.name?.(...inputs)
+ if (!name) continue
+
+ let array = name.map(parts0 =>
+ {
+ let parts1 = []
+ if (!(parts0 instanceof Array)) parts0 = [parts0]
+ for (let part of parts0) {
+ if (part instanceof Element) continue
+ if (typeof part === "string") {
+ parts1.push(part)
+ continue
+ }
+ if (typeof part === "number") {
+ parts1.push(part)
+ continue
+ }
+ if (part.options) {
+ parts1.push({options: part.options})
+ continue
+ }
+ parts1.push("")
+ }
+ if (parts1.length === 0) parts1.push("")
+ return parts1
+ })
+
+ let names = [[]]
+ for (let parts of array) {
+ for (let name of names.splice(0)) {
+ for (let part of parts) {
+ names.push([...name, part])
+ }
+ }
+ }
+
+ for (let name of names) {
+
+ let matched = true
+ let perfectMatch = true
+
+ for (let part of name.splice(0)) {
+ if (part === "") continue
+ if (typeof part === "string" && typeof name[name.length - 1] === "string") {
+ name[name.length - 1] += " " + part
+ continue
+ }
+ name.push(part)
+ }
+
+ if (info.children.length !== name.length) continue
+
+ let inputs = []
+ let names = type.references?.map(() => "") ?? []
+
+ for (let [i, part0] of info.children.entries()) {
+
+ if (typeof part0.type !== "string") {
+ while (part0.type === "block" && part0.children.length === 1) {
+ if (typeof part0.children[0] === "string") break
+ part0 = part0.children[0]
+ }
+ }
+
+ let part1 = name[i]
+
+ if ((typeof part0 === "string") !== (typeof part1 === "string")) {
+ matched = false
+ break
+ }
+
+ if (typeof part0 === "string") {
+ if (part0 === part1) continue
+ matched = false
+ break
+ }
+
+ if (typeof part1 === "string") {
+ matched = false
+ break
+ }
+
+ if (typeof part1 !== "number") {
+
+ if (part0.type === "slot" && part1.options) {
+ let name = toName(part0.text)
+ let found
+ for (let option of part1.options) {
+ if (option.label === name) {
+ type = option.type
+ found = true
+ break
+ }
+ }
+ if (found) continue
+ }
+
+ matched = false
+ break
+ }
+
+ if (type.references && part1 < type.references.length) {
+
+ let reference = type.references?.[part1]
+ if (reference !== "variable" && reference !== "list") {
+ matched = false
+ break
+ }
+
+ if (part0.type !== "slot") {
+ matched = false
+ break
+ }
+
+ let name0 = toName(part0.text)
+ if (!options[reference + "s"].includes(name0)) options[reference].push(name0)
+ names[part1] = name0
+ continue
+ }
+
+ part1 -= type.references?.length ?? 0
+
+ let Slot = type.slots[part1]
+ if (!Slot) {
+ matched = false
+ break
+ }
+
+ if (part0.type !== "slot") {
+ inputs[part1] = () => match(part0, options)
+ continue
+ }
+
+ let value = toName(part0.text)
+
+ for (let option of Slot.type.options ?? []) {
+ let name = options.getName?.(Slot.type, option) ?? Slot.type.name?.(option) ?? option
+ if (name === value) {
+ value = option
+ break
+ }
+ }
+
+ if (Slot.type.dynamic && !Slot.type.options.includes(value)) Slot.type.options.push(value)
+ inputs[part1] = () => Slot(value)
+ }
+
+ if (!matched) continue
+
+ if (perfectMatch) {
+ matches = [{type, inputs, names}]
+ break
+ }
+
+ matches.push({type, inputs, names})
+ }
+ }
+
+ if (!matches[0]) {
+ if (info.children.length === 1) {
+ let name0 = info.children[0]
+ if (typeof name0 === "string" && info.text === "(") {
+ let name = toName(name0)
+ if (!options.variables.includes(name)) options.variables.push(name)
+ return options.dry || Variable(name)
+ }
+ }
+ return matchCustom(info, options)
+ }
+
+ if (options.dry) return
+
+ let {type, inputs, names} = matches[0]
+
+ let block = MakeBlock(type)()
+
+ if (block.type.hat) Object.assign(options.parameters, structuredClone(options.parameters0))
+
+ for (let [i, make] of inputs.entries()) {
+ let input = make() ?? block.type.slots[i]()
+ if (input.type.shape !== "slot") {
+ if (input.type.shape !== "reporter") {
+ input = block.type.slots[i]()
+ }
+ if (block.type.slots[i].type.canFit && !block.type.slots[i].type.canFit(input)) {
+ input = block.type.slots[i]()
+ }
+ }
+ replace(block.inputs[i], input)
+ }
+
+ for (let i of names.keys()) block.names[i] = names[i]
+
+ return block
+}
+
+function matchCustom(info, options, force)
+{
+ let matches = []
+
+ for (let name of options.custom) {
+
+ let perfectMatch = true
+ let matched = true
+
+ for (let [i, part] of name.entries()) {
+
+ let part1 = info.children[i]
+ if ((typeof part === "string") !== (typeof part1 === "string")) {
+ matched = false
+ break
+ }
+ if (typeof part === "string") {
+ if (part !== part1) {
+ matched = false
+ break
+ }
+ continue
+ }
+
+ if (part.type !== "boolean") {
+ if (part1.type === "block" && part1.text === "<") perfectMatch = false
+ if (part1.type === "slot" && part1.boolean) perfectMatch = false
+ continue
+ }
+ if (part1.type === "block" && part1.text !== "<") {
+ matched = false
+ break
+ }
+ if (part1.type === "slot" && !part1.boolean) {
+ matched = false
+ break
+ }
+ }
+
+ if (!matched) continue
+ if (perfectMatch) {
+ matches = [name]
+ break
+ }
+ matches.push(name)
+ }
+
+ if (!matches[0]) {
+
+ if (!force) return
+
+ let name = []
+ matches = [name]
+ options.custom.push(name)
+
+ let n = 1
+ for (let part of info.children) {
+ if (typeof part === "string") {
+ name.push(part)
+ continue
+ }
+ let parameter = {name: `input ${n++}`, type: "text"}
+ name.push(parameter)
+ if (part.type !== "slot" ? part.text === "<" : part.boolean) {
+ parameter.type = "boolean"
+ }
+ }
+ }
+
+ if (options.dry) return
+
+ let inputs = []
+
+ for (let [i, part] of matches[0].entries()) {
+
+ if (typeof part === "string") continue
+
+ let part0 = info.children[i]
+ while (part0.type === "block" && part0.children.length === 1) {
+ if (typeof part0.children[0] === "string") break
+ part0 = part0.children[0]
+ }
+
+ if (part0.type === "slot") {
+ if (part.type === "boolean") inputs.push(BooleanSlot())
+ else inputs.push(TextSlot(part0.text))
+ continue
+ }
+
+ let input = match(part0, options)
+ if (input.type.shape !== "reporter") {
+ inputs.push(input)
+ continue
+ }
+
+ if (part.type === "boolean") inputs.push(BooleanSlot())
+ else inputs.push(TextSlot())
+ }
+
+ return Custom(matches[0], ...inputs)
+}
--- /dev/null
+++ b/translation.js
@@ -1,0 +1,113 @@
+import messages1 from "scratch-l10n/locales/blocks-msgs.js"
+import messages2 from "scratch-l10n/locales/editor-msgs.js"
+import {isRtl} from "scratch-l10n/src/supported-locales.js"
+
+export let locale
+
+for (let name of navigator.languages) {
+ if (locale) break
+ name = name.toLowerCase()
+ for (let name1 in messages1) {
+ if (locale) break
+ let parts = name1.split("-")
+ while (parts.length > 0) {
+ if (parts.join("-") === name) {
+ locale = name1
+ break
+ }
+ parts.pop()
+ }
+ }
+}
+
+locale ??= "en"
+document.documentElement.lang = locale
+if (isRtl(locale)) document.dir = "rtl"
+else document.dir = "ltr"
+
+let messages = {...messages1[locale], ...messages2[locale]}
+let messages0 = [...Object.entries(messages1.en), ...Object.entries(messages2.en)]
+
+export function getName(stage, type, ...args)
+{
+ if (locale === "en") return
+ if (type.shape === "slot" && type.dynamic) return
+ if (type.category === "custom" && !type.hat) return
+
+ let symbol = Symbol()
+
+ let name0 = args
+ if (type.shape !== "slot") {
+ args = args.map(arg => ({[symbol]: arg}))
+ name0 = stage.scratch.getName(type, ...args) ?? type.name?.(...args)
+ if (!name0) return
+ }
+
+ let category = type.category ?? "extensions"
+ if (type.category === "events") category = "event"
+ if (type.category === "variables") category = "data"
+ if (type.category === "lists") category = "data"
+
+ name0 = name0.map(part =>
+ {
+ if (typeof part === "string") return part
+ if (part instanceof Node) return part
+ if (part[symbol]) return part
+ if (!part.options) return part
+ let options = part.options.map(option =>
+ {
+ let label = option.label
+
+ for (let [key, name] of messages0) {
+ if (!["extensions", "custom"].includes(category)) {
+ if (!key.startsWith(category.toUpperCase() + "_")) continue
+ }
+ if (name === label) {
+ label = messages[key]
+ break
+ }
+ }
+
+ return {...option, label}
+ })
+
+ return {...part, options}
+ })
+
+ let icon
+ if (name0[0] instanceof Element && name0[0].querySelector("svg")) icon = name0.shift()
+
+ let regex = new RegExp("^" + name0.map((n, i) => typeof n !== "string" ? `(?<i${i}>%[0-9]+|\\[[a-zA-Z_]+\\]+)`: RegExp.escape(n)).join("\\s*") + "$", "u")
+
+ for (let [key, name] of messages0) {
+
+ if (!["extensions", "custom"].includes(category)) {
+ if (!key.startsWith(category.toUpperCase() + "_")) continue
+ }
+
+ if (key === "CONTROL_STOP" && !name.includes("%1")) name += " %1"
+ let match = name.match(regex)
+ if (!match) continue
+
+ let message = messages[key]
+ if (key === "CONTROL_STOP" && !message.includes("%1")) message += " %1"
+ let parts = message.split(/(%[0-9]+|\[[a-zA-Z_]+\]+)/g)
+
+ return [...icon ? [icon] : [], ...parts.map(part =>
+ {
+ if (!part.match(/^%[0-9]+|\[[a-zA-Z_]+\]+$/)) return part.replace(/\s+/, " ").trim()
+ for (let [i, part1] of Object.entries(match.groups)) {
+ if (part === part1) {
+ let part2 = name0[i.slice(1)]
+ return part2[symbol] ?? part2
+ }
+ }
+ })]
+ }
+}
+
+export function getMessage(name, fn = m => m)
+{
+ let message = messages[name] ?? messages1.en[name] ?? messages2.en[name]
+ if (message) return fn(message)
+}
--- /dev/null
+++ b/translations.js
@@ -1,0 +1,2 @@
+export default {en: {}}
+export function isRtl() { return false }
--- /dev/null
+++ b/view.js
@@ -1,0 +1,804 @@
+import {BooleanSlot, Variable, List, TextParameter, BooleanParameter, Define} from "./core.js"
+
+export let categories = ["motion", "looks", "sound", "events", "control", "sensing", "operators", "variables", "lists", "custom", "extensions"]
+
+function toChildElement(block, child, options)
+{
+ if (child instanceof Array) child = child[0]
+ if (typeof child === "string") child = document.createTextNode(child)
+ if (child instanceof Node) return child
+ return toSelectElement(block, child.options, options)
+}
+
+function toChildrenElement(block, options)
+{
+ let inputs = []
+
+ for (let [i, reference] of block.type.references?.entries() ?? []) {
+ inputs.push(toReferenceElement(block, options[reference + "s"] ?? [block.names[0]], i, options))
+ }
+
+ for (let child of block.inputs) {
+ inputs.push(toReporterElement(child, options))
+ }
+
+ let name = options.getName?.(block.type, ...inputs) ?? block.type.name(...inputs)
+ return name.map(child => toChildElement(block, child, options))
+}
+
+function resizeSelect(element)
+{
+ let div = document.createElement("div")
+ div.classList.add("block", "stack")
+ div.style.setProperty("white-space", "pre")
+ div.style.setProperty("position", "fixed")
+ div.style.setProperty("width", "max-content")
+ let select = document.createElement("select")
+ let option = document.createElement("option")
+ option.append(element.selectedOptions[0]?.textContent ?? "")
+ select.append(option)
+ div.append(select)
+ document.body.append(div)
+ let width = select.offsetWidth
+ div.remove()
+ element.style.setProperty("width", `${width}px`)
+}
+
+function resizeInput(input)
+{
+ let span = document.createElement("span")
+ span.classList.add("input")
+ span.style.setProperty("white-space", "pre")
+ span.append(input.value)
+ span.style.setProperty("position", "fixed")
+ span.style.setProperty("width", "max-content")
+ document.body.append(span)
+ let width = span.offsetWidth
+ span.remove()
+ input.style.setProperty("width", `${width}px`)
+}
+
+function toReferenceElement(block, names, index, options)
+{
+ let element = document.createElement("select")
+
+ for (let name of names) {
+ let option = document.createElement("option")
+ element.append(option)
+ option.append(name)
+ if (name === block.names[index]) option.selected = true
+ }
+
+ if (options.frozen) element.disabled = true
+
+ element.addEventListener("change", () =>
+ {
+ resizeSelect(element)
+ block.names[index] = element.value
+ })
+
+ resizeSelect(element)
+
+ return element
+}
+
+function toReporterElement(block, options)
+{
+ if (block.type.shape === "slot") return toSlotElement(block, options)
+
+ let element = document.createElement("span")
+ element.classList.add("block", "reporter", categories.includes(block.type.category) ? block.type.category : "extensions")
+ if (block.type.shape === "reporter" && block.type.output === "boolean") {
+ element.classList.add("boolean")
+ }
+ if ([Variable, List, TextParameter, BooleanParameter].some(fn => fn.type === block.type)) {
+ element.append(block.names[0])
+ }
+ else {
+ let children = toChildrenElement(block, options)
+ element.append(...children)
+ }
+
+ element.dataset.id = block.id
+ return element
+}
+
+function toSlotElement(block, options)
+{
+ if (block.type === BooleanSlot.type) {
+ let span = document.createElement("span")
+ span.classList.add("slot")
+ span.dataset.id = block.id
+ return span
+ }
+
+ if (block.type.toElement) return block.type.toElement(block, {frozen: options.frozen})
+ if (block.type.options) return toEnumeratedElement(block, options)
+
+ let input = document.createElement("input")
+ input.classList.add("input")
+ input.value = block.value
+ if (options.frozen) input.disabled = true
+
+ resizeInput(input)
+
+ input.addEventListener("input", () => resizeInput(input))
+ input.addEventListener("blur", () =>
+ {
+ let value = block.type.normalise(input.value)
+ input.value = value
+ block.value = value
+ resizeInput(input)
+ })
+
+ let span = document.createElement("span")
+ span.append(input)
+ span.dataset.id = block.id
+ return span
+}
+
+function toEnumeratedElement(block, options)
+{
+ let element = document.createElement("select")
+ element.dataset.id = block.id
+ if (!block.type.canFit) element.classList.add("allow-block")
+
+ if (block.type.dynamic) {
+ let optionElement = document.createElement("option")
+ element.append(optionElement)
+ optionElement.append(options.getAddMessage?.(block.type) ?? "add another")
+ if (!options.addValue) optionElement.disabled = true
+ }
+
+ for (let option of block.type.options) {
+ let optionElement = document.createElement("option")
+ element.append(optionElement)
+ optionElement.append(options.getName?.(block.type, option) ?? block.type.name?.(option) ?? option)
+ optionElement.value = option
+ }
+
+ if (options.frozen) element.disabled = true
+ if (element.querySelectorAll("option:enabled").length <= 1) element.disabled = true
+
+ element.addEventListener("change", () =>
+ {
+ if (block.type.dynamic && element.selectedIndex === 0) {
+ options.addValue?.(block)
+ element.value = block.value
+ return
+ }
+ block.value = element.value
+ resizeSelect(element)
+ })
+
+ element.value = block.value
+ resizeSelect(element)
+ return element
+}
+
+function toSelectElement(block, options, options0)
+{
+ let element = document.createElement("select")
+ let actions = []
+
+ for (let option of options) {
+
+ let optionElement = document.createElement("option")
+ element.append(optionElement)
+
+ optionElement.append(option.label)
+
+ actions.push(() =>
+ {
+ let other = option.morph(block)
+ if (other.type.cap && block.parent && block.parent.stack.indexOf(block) !== block.parent.stack.length - 1) {
+ for (let [i, option] of options.entries()) {
+ if (option.type === block.type) {
+ element.children[i].selected = true
+ break
+ }
+ }
+ return
+ }
+ let element0 = element.closest(`[data-id="${block.id}"]`)
+ element0.dataset.id = other.id
+ element0.classList.toggle("cap", Boolean(other.type.cap))
+ options0.replace(block, other, Boolean(option.update))
+ block = other
+ })
+
+ if (block.type === option.type) optionElement.selected = true
+ }
+
+ if (!options0.replace || options0.frozen) element.disabled = true
+ if (element.querySelectorAll("option:enabled").length <= 1) element.disabled = true
+
+ element.addEventListener("change", () =>
+ {
+ actions[element.selectedIndex]()
+ resizeSelect(element)
+ })
+
+ resizeSelect(element)
+ return element
+}
+
+function toSingleElement(block, options)
+{
+ if (block.type.shape === "reporter") return toReporterElement(block, options)
+ if (block.type === Define.type) return toDefineElement(block, options)
+ if (block.type.category === "custom") return toCustomBlockElement(block, options)
+
+ let div = document.createElement("div")
+ div.dataset.id = block.id
+ div.classList.add("block", "stack", categories.includes(block.type.category) ? block.type.category : "extensions")
+
+ let line = document.createElement("div")
+ line.classList.add("line")
+ line.append(...toChildrenElement(block, options))
+
+ div.append(line)
+
+ if (block.type.hat) {
+ let div2 = document.createElement("div")
+ div2.append(div)
+ div.classList.add("hat")
+ for (let child of block.stack) div2.append(toSingleElement(child, options))
+ return div2
+ }
+ else {
+ if (block.stack) {
+ div.classList.add("c")
+ let mouth = document.createElement("div")
+ mouth.classList.add("mouth")
+ div.append(mouth)
+ for (let child of block.stack) mouth.append(toSingleElement(child, options))
+ }
+ }
+
+ while (block.complement) {
+ block = block.complement
+ let line = document.createElement("div")
+ line.classList.add("line")
+ line.dataset.id = block.id
+ div.append(line)
+ line.append(...toChildrenElement(block, options))
+ if (block.stack) {
+ let mouth = document.createElement("div")
+ mouth.classList.add("mouth")
+ div.append(mouth)
+ for (let child of block.stack) mouth.append(toSingleElement(child, options))
+ }
+ }
+
+ if (block.stack) {
+ let line = document.createElement("div")
+ line.classList.add("line")
+ div.append(line)
+ if (block.type.loop) line.append(getIcon("loop-arrow"))
+ }
+
+ if (block.type.cap) div.classList.add("cap")
+
+ return div
+}
+
+function toDefineElement(block, options)
+{
+ let div = document.createElement("div")
+ div.dataset.id = block.id
+ div.classList.add("block", "stack", "define", "custom")
+
+ let line = document.createElement("div")
+ line.classList.add("line")
+ div.append(line)
+
+ let div1 = document.createElement("div")
+ div1.append(div)
+ for (let child of block.stack) div1.append(toSingleElement(child, options))
+
+ let div2 = document.createElement("div")
+ div2.classList.add("block", "stack", "custom", "prototype")
+ if (block.atomic) div2.append(getIcon("bolt"))
+
+ let line2 = document.createElement("div")
+ line2.classList.add("line")
+ div2.append(line2)
+
+ line.append(...options.getName?.(block.type, div2) ?? block.type.name(div2))
+
+ for (let part of block.names[0]) {
+
+ if (typeof part === "string") {
+ let span = document.createElement("span")
+ span.append(part)
+ line2.append(span)
+ continue
+ }
+
+ let element = document.createElement("span")
+ element.classList.add("block", "reporter", "custom", "parameter")
+ if (part.type === "boolean") element.classList.add("boolean")
+ element.append(part.name)
+ line2.append(element)
+ }
+
+ return div1
+}
+
+function toCustomBlockElement(block, options)
+{
+ let div = document.createElement("div")
+ div.classList.add("block", "stack", "custom")
+
+ let line = document.createElement("div")
+ line.classList.add("line")
+ div.append(line)
+
+ div.dataset.id = block.id
+ let i = 0
+ for (let part of block.names[0]) {
+ if (typeof part === "string") {
+ line.append(part)
+ continue
+ }
+ let element = toReporterElement(block.inputs[i], options)
+ element.dataset.index = i++
+ line.append(element)
+ }
+
+ return div
+}
+
+export function toElement(block, options = {})
+{
+ let stack
+ if (!block.parent || block.type.shape !== undefined) stack = [block]
+ else stack = block.parent.stack.slice(block.parent.stack.indexOf(block))
+
+ let div = document.createElement("div")
+ div.classList.add("script")
+ div.append(...stack.map(block => toSingleElement(block, options)))
+ return div
+}
+
+export function query(id, node = document)
+{
+ return node.querySelector(`[data-id="${id}"]`)
+}
+
+export function closestScriptID(element0)
+{
+ if (element0.closest(":enabled")) return
+ let element = element0.closest(".script")
+ if (!element) return
+ element = element.querySelector(".hat") ?? element.querySelector(".define")
+ if (!element) return
+ return Number(element.dataset.id)
+}
+
+export function closestBlockID(element0)
+{
+ if (element0.closest(":enabled")) return
+ let element = element0.closest(".block[data-id]")
+ if (!element) return
+ return Number(element.dataset.id)
+}
+
+export function closestParameterIndex(element0)
+{
+ let element = element0.closest(".parameter:not([data-id])")
+ if (!element) return
+ let define = element.closest(".define")
+ let parameters = [...define.querySelectorAll(".parameter")]
+ return {index: parameters.indexOf(element), id: Number(define.dataset.id), element}
+}
+
+export function closestSlotID(element0)
+{
+ let element = element0.closest(".slot")
+ if (!element) return
+ return Number(element.dataset.id)
+}
+
+export function closestNamePartIndex(element0)
+{
+ let element = element0.closest(".define > .line > .custom > .line > span")
+ if (!element) return
+ let block = element.closest(".define")
+ return {index: [...element.parentNode.childNodes].indexOf(element), id: Number(block.dataset.id)}
+}
+
+function getIcon(name)
+{
+ let span = document.createElement("span")
+ span.insertAdjacentHTML("beforeend", icons[name])
+ let svg = span.querySelector("svg")
+ svg.style.setProperty("height", "1.5em")
+ svg.style.setProperty("vertical-align", "middle")
+ return span
+}
+
+let icons = {
+ "loop-arrow": `<svg viewbox="3 0 16 24" fill="#EEE" stroke="#444" class="loop-arrow"><path transform-origin="12 12" transform="rotate(90)" d="M10 9V7.41c0-.89-1.08-1.34-1.71-.71L3.7 11.29c-.39.39-.39 1.02 0 1.41l4.59 4.59c.63.63 1.71.19 1.71-.7V14.9c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>`,
+ bolt: `<svg viewbox="6 0 12 24" fill="#EEE" stroke="#444"><path d="M10.67,21L10.67,21c-0.35,0-0.62-0.31-0.57-0.66L11,14H7.5c-0.88,0-0.33-0.75-0.31-0.78c1.26-2.23,3.15-5.53,5.65-9.93 c0.1-0.18,0.3-0.29,0.5-0.29h0c0.35,0,0.62,0.31,0.57,0.66L13.01,10h3.51c0.4,0,0.62,0.19,0.4,0.66c-3.29,5.74-5.2,9.09-5.75,10.05 C11.07,20.89,10.88,21,10.67,21z"/></svg>`,
+}
+
+let css = `
+.block {
+ color: #444;
+ pointer-events: none;
+ white-space: nowrap;
+}
+
+.line, .mouth::before, .reporter {
+ background: var(--color);
+ border: 2px solid #0006;
+ border-color: oklab(from var(--color) calc(l - 0.25) a b);
+ background-origin: border-box;
+ pointer-events: auto;
+ touch-action: none;
+ cursor: move;
+}
+
+.stack {
+ width: max-content;
+ position: relative;
+}
+
+.mouth {
+ width: 0;
+ margin-inline-start: 1.125em;
+ position: relative;
+ min-height: 2em;
+}
+
+.line {
+ padding: 0.5em;
+ border-radius: 0.5em;
+ margin-bottom: -2px;
+ position: relative;
+ min-height: 2em;
+ display: grid;
+ grid-auto-flow: column;
+ align-items: baseline;
+ gap: 0.5em;
+ justify-content: start;
+ min-width: 4em;
+}
+
+:not(.prototype) > .line:not(:first-child):last-child {
+ padding: 0 0.5em;
+ justify-content: end;
+ align-content: center;
+}
+
+.c > .line:first-child, :not(.c) > .line, .reporter {
+ background-image: linear-gradient(#FFF4, #0000 75%);
+}
+
+.c > .line:not(first-child):last-child {
+ background-image: linear-gradient(#0000 25%, #0001);
+}
+
+.c > .line:not(first-child):last-child::after {
+ background-image: linear-gradient(#0001, #0001);
+}
+
+.c > .line:not(:first-child):not(:dir(rtl)) {
+ border-top-left-radius: 0;
+}
+
+.c > .line:not(:last-child):not(:dir(rtl)) {
+ border-bottom-left-radius: 0;
+}
+
+.c > .line:not(:first-child):dir(rtl) {
+ border-top-right-radius: 0;
+}
+
+.c > .line:not(:last-child):dir(rtl) {
+ border-bottom-right-radius: 0;
+}
+
+.c > .line {
+ min-width: 10em;
+}
+
+.block select {
+ background: linear-gradient(#FFF4, #0000 75%) var(--color);
+ font: inherit;
+ color: inherit;
+ padding: 0.25em 0.5em;
+ border: 1px solid #0006;
+ border-radius: 0.5em;
+ cursor: pointer;
+}
+
+.block select.allow-block {
+ border-radius: 128em;
+ background: linear-gradient(#6662, #FFF4);
+ border-width: 2px;
+}
+
+.block select:disabled, .input:disabled {
+ pointer-events: none;
+}
+
+.input {
+ background: linear-gradient(#EEE, #FFF);
+ font: inherit;
+ color: #666;
+ padding: 0.25em 0.5em;
+ border: 2px solid #888;
+ border-radius: 128em;
+ cursor: text;
+ min-width: 2.5em;
+ text-align: center;
+ overflow: hidden;
+}
+
+.input:empty::before {
+ content: " ";
+ white-space: pre;
+}
+
+.line::before, .line::after {
+ content: "";
+ width: 1.5em;
+ height: 0.5em;
+ position: absolute;
+ inset-inline-start: 1em;
+ border-radius: 0 0 calc(0.5em - 2px) calc(0.5em - 2px);
+}
+
+.line::before {
+ top: 0;
+ background: linear-gradient(#0006, #0008);
+ background: linear-gradient(#0000, #0004) oklab(from var(--color) calc(l - 0.25) a b);
+ background-clip: padding-box;
+ width: 1.5em;
+ height: calc(0.5em - 2px);
+}
+
+.line::after {
+ top: 100%;
+ background-color: inherit;
+ z-index: 1;
+ border: 2px solid #0006;
+ border-color: oklab(from var(--color) calc(l - 0.25) a b);
+ border-top: none;
+}
+
+.c > .line:not(:first-child)::before,
+.c > .line:not(:last-child)::after {
+ inset-inline-start: calc(2em + 2px);
+}
+
+.mouth::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ inset-inline-start: -1.125em;
+ width: 1.25em;
+ border-width: 0 2px;
+ z-index: 1;
+}
+
+.mouth:empty::before {
+ bottom: -2px;
+}
+
+.hat {
+ padding-top: 1em;
+}
+
+.hat > .line {
+ min-width: 8em;
+}
+
+.hat > .line:not(:dir(rtl)) {
+ border-top-left-radius: 0;
+}
+
+.hat > .line:dir(rtl) {
+ border-top-right-radius: 0;
+}
+
+.hat > .line:last-child::before {
+ content: "";
+ background: linear-gradient(#FFF4, #FFF4);
+ background-color: inherit;
+ border: 2px solid #0006;
+ border-color: oklab(from var(--color) calc(l - 0.25) a b);
+ border-bottom: none;
+ position: absolute;
+ top: auto;
+ inset-inline-start: -2px;
+ bottom: 100%;
+ width: 6em;
+ height: 1em;
+ border-radius: 50% 50% 0 0 / 100% 100% 0 0;
+}
+
+.define > .line:last-child::before {
+ content: none;
+}
+
+.define > .line {
+ border-top-left-radius: 1.5em;
+ border-top-right-radius: 1.5em;
+ padding: 1em;
+ padding-inline-start: 0.5em;
+}
+
+.prototype {
+ display: flex;
+ gap: 0.5em;
+ align-items: baseline;
+}
+
+.cap > .line:last-child::after {
+ content: none;
+}
+
+.reporter {
+ display: grid;
+ grid-auto-flow: column;
+ align-items: center;
+ padding: 0.25em 0.5em;
+ border-radius: 100em;
+ width: max-content;
+ grid-gap: 0.5em;
+}
+
+.boolean {
+ border-width: 2px 3px;
+ border-radius: 0;
+ padding: 0.25em 1em;
+ clip-path: polygon(0 50%, 8888em -8888em, calc(100% - 8888em) -8888em, 100% 50%, calc(100% - 8888em) 8888em, 8888em 8888em);
+ position: relative;
+ overflow: hidden;
+}
+
+.boolean::before, .boolean::after {
+ content: "";
+ border: 888em solid #0000;
+ border-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4" fill="%230006"><path d="M 0 2 2 0 4 2 2 4 M 0 0 0 4 4 4 4 0" /></svg>') 50%;
+ position: absolute;
+ top: 50%;
+ transform: translate(0, -50%);
+ pointer-events: none;
+ width: 64em;
+}
+
+.boolean::before {
+ left: 0;
+}
+
+.boolean::after {
+ right: 0;
+}
+
+.slot {
+ background: linear-gradient(#0008, #0004);
+ clip-path: polygon(0 50%, 88em -88em, calc(100% - 88em) -88em, 100% 50%, calc(100% - 88em) 88em, 88em 88em);
+ width: 3em;
+ height: 1.5em;
+ display: grid;
+}
+
+.slot::before {
+ content: "x";
+ align-self: center;
+ color: #0000;
+}
+
+.line > input, .parameter > input {
+ color: inherit;
+ font: inherit;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ min-width: 0.5em;
+}
+
+.loop-arrow:dir(rtl) {
+ transform: scale(-1, 1);
+}
+
+.control { --color: #FB5; }
+.events { --color: #FD6; }
+.looks { --color: #CBF; }
+.sound { --color: #E9E; }
+.variables { --color: #F94; }
+.operators { --color: #8C8; }
+.lists { --color: #F96; }
+.motion { --color: #8BF; }
+.sensing { --color: #8CE; }
+.custom { --color: #F9A; }
+.define, .parameter { --color: #FBC; }
+.extensions { --color: #1EA; }
+`
+
+let paintCSS = `
+
+.line {
+ background: none !important;
+ border: 2px solid #0000;
+ border-radius: 0;
+ pointer-events: visiblepainted;
+}
+
+.line::before {
+ content: none !important;
+}
+
+.line::after {
+ content: paint(stack-block) !important;
+ border: none;
+ border-radius: 0;
+ pointer-events: visiblepainted;
+ top: -2px !important;
+ bottom: -0.5em !important;
+ left: -2px !important;
+ right: -2px !important;
+ width: auto;
+ height: auto;
+ z-index: -1;
+ pointer-events: none;
+ background: none !important;
+}
+
+.line:dir(rtl)::after {
+ transform: scale(-1, 1);
+}
+
+.boolean {
+ clip-path: none;
+ background: paint(boolean-block) border-box !important;
+ border-color: #0000 !important;
+}
+
+.boolean::before, .boolean::after {
+ content: none;
+}
+
+.hat > .line { --hat: true; }
+.cap > .line:last-child { --cap: true; }
+.define > .line::after { --define: true; }
+.c > .line:not(:last-child) { --mouth-below: true; }
+.c > .line:not(:first-child) { --mouth-above: true; }
+
+.hat > .line::after {
+ top: calc(-2px - 1em) !important;
+}
+
+.c > .line:not(:first-child)::after {
+ top: -0.5em !important;
+}
+
+.mouth::before {
+ z-index: -2;
+}
+
+.c > .line:first-child {
+ z-index: 4100;
+}
+
+.script {
+ position: relative;
+}
+`
+
+if (CSS.paintWorklet) {
+ CSS.paintWorklet?.addModule(import.meta.resolve("./worklet.js"))
+ css += paintCSS
+ for (let i = 0 ; i <= 4000 ; i++) css += `.block.stack:nth-child(${i + 1}) { z-index: ${4000 - i}; }\n`
+}
+
+let style = document.createElement("style")
+style.textContent = css
+document.head.append(style)
--- /dev/null
+++ b/worklet.js
@@ -1,0 +1,161 @@
+class StackBlockPaint {
+
+ static inputProperties = ["--color", "font-size", "--hat", "--cap", "--mouth-above", "--mouth-below", "--define"]
+
+ paint(ctx, {width, height}, properties)
+ {
+ let isHat = properties.get("--hat")?.[0]
+ let isCap = properties.get("--cap")?.[0]
+ let isDefine = properties.get("--define")?.[0]
+ let hasMouthAbove = properties.get("--mouth-above")?.[0]
+ let hasMouthBelow = properties.get("--mouth-below")?.[0]
+ let color = String(properties.get("--color"))
+ let darker = `oklab(from ${color} calc(l - 0.25) a b)`
+ let size = Number(properties.get("font-size").to("px").value)
+
+ let x0 = 0
+ let y0 = 0
+ let x1 = width
+ let y1 = height + 2
+
+ y1 -= size / 2
+ if (isHat) y0 += size
+ if (hasMouthAbove) y0 += size / 2 - 2
+
+ function drawBlock1()
+ {
+ ctx.beginPath()
+ drawBlock(ctx, x0, y0, x1, y1, size, {isHat, isCap, hasMouthBelow, hasMouthAbove, isDefine})
+ }
+
+ drawBlock1()
+ ctx.clip()
+
+ ctx.rect(0, 0, width, height)
+ ctx.fillStyle = color
+ ctx.fill()
+
+ if (!hasMouthAbove) {
+ let gradient = ctx.createLinearGradient(x0, y0, x0, y1)
+ gradient.addColorStop(0, "#FFF4")
+ gradient.addColorStop(0.75, "#FFF0")
+ ctx.fillStyle = gradient
+ ctx.fill()
+ }
+
+ if (hasMouthAbove && !hasMouthBelow) {
+ let gradient = ctx.createLinearGradient(x0, y0, x0, y1)
+ gradient.addColorStop(0.25, "#0000")
+ gradient.addColorStop(1, "#0001")
+ ctx.fillStyle = gradient
+ ctx.fill()
+ }
+
+ drawBlock1()
+ ctx.strokeStyle = darker
+ ctx.lineWidth = 4
+ ctx.stroke()
+
+ if (isHat || isDefine) return
+
+ let x2 = 2
+ if (hasMouthAbove) x2 += size + 2
+
+ let gradient = ctx.createLinearGradient(x0, y0, x0, y0 + 100)
+ gradient.addColorStop(0.02, darker)
+ gradient.addColorStop(size / 100, "#222")
+
+ ctx.beginPath()
+ ctx.moveTo(size * 3 + x2, 0)
+ ctx.lineTo(size * 3 + x2, 2)
+ drawKnub(ctx, size + x2, y0 + 2, size * 2.5 + x2 + 2, y0 + size / 2)
+ ctx.lineTo(x0 + x2 + size, y0)
+ ctx.fillStyle = gradient
+ ctx.fill()
+ }
+}
+
+class BooleanBlockPaint {
+
+ static inputProperties = ["--color"]
+
+ paint(ctx, {width, height}, properties)
+ {
+ let color = String(properties.get("--color"))
+ let darker = `oklab(from ${color} calc(l - 0.25) a b)`
+
+ ctx.moveTo(0, height / 2)
+ ctx.lineTo(height / 2, 0)
+ ctx.lineTo(width - height / 2, 0)
+ ctx.lineTo(width, height / 2)
+ ctx.lineTo(width - height / 2, height)
+ ctx.lineTo(height / 2, height)
+ ctx.closePath()
+
+ ctx.clip()
+
+ ctx.fillStyle = color
+ ctx.fill()
+
+ let gradient = ctx.createLinearGradient(0, 0, 0, height)
+ gradient.addColorStop(0, "#FFF4")
+ gradient.addColorStop(0.75, "#FFF0")
+ ctx.fillStyle = gradient
+ ctx.fill()
+
+ ctx.strokeStyle = darker
+ ctx.lineWidth = 4
+ ctx.stroke()
+ }
+}
+
+registerPaint("stack-block", StackBlockPaint)
+registerPaint("boolean-block", BooleanBlockPaint)
+
+function drawBlock(ctx, x0, y0, x1, y1, size, {isHat, isCap, hasMouthBelow, hasMouthAbove, isDefine})
+{
+ let x2 = 2
+ if (hasMouthBelow) x2 += size + 2
+ let radius = size / 2
+ let radius2 = radius
+ if (isDefine) radius2 += size
+ if (isHat) {
+ ctx.moveTo(x0, y0 + size / 2)
+ ctx.quadraticCurveTo(x0, y0 - size, x0 + size * 3, y0 - size)
+ ctx.bezierCurveTo(x0 + size * 5, y0 - size, x0 + size * 6, y0, x0 + size * 8, y0)
+ }
+ else {
+ if (hasMouthAbove) {
+ ctx.moveTo(x0, y0 - radius * 2)
+ ctx.lineTo(x0 + size + 4, y0 - radius * 2)
+ ctx.arcTo(x0 + size + 4, y0, x0 + size * 2, y0, radius / 1.25)
+ }
+ else {
+ ctx.moveTo(x0, y0 + radius)
+ ctx.arcTo(x0, y0, x0 + radius, y0, radius2)
+ }
+ }
+ ctx.arcTo(x1, y0, x1, y1, radius2)
+ ctx.arcTo(x1, y1, x0, y1, radius)
+ if (!isCap) drawKnub(ctx, x0 + size + x2, y1, x0 + size * 2.5 + x2 + 2, y1 + size / 2 - 2)
+ if (hasMouthBelow) {
+ ctx.lineTo(x0 + size + radius / 1.25 + 4, y1)
+ ctx.arcTo(x0 + size + 4, y1, x0 + size + 4, y1 + radius / 1.25, radius / 1.25)
+ ctx.lineTo(x0 + size + 4, y1 + size)
+ ctx.lineTo(x0, y1 + size)
+ }
+ else {
+ ctx.arcTo(x0, y1, x0, y0, radius)
+ }
+ ctx.closePath()
+}
+
+function drawKnub(ctx, x0, y0, x1, y1)
+{
+ let radius = (y1 - y0) / 2
+ x1 -= radius
+ ctx.arcTo(x1 + radius, y0, x1, y1 - radius / 2, radius / 2)
+ ctx.arcTo(x1, y1, x0, y1, radius * 1.5)
+ ctx.arcTo(x0 + radius, y1, x0, y0, radius * 1.5)
+ ctx.arcTo(x0, y0, x0 - radius / 2, y0, radius / 2)
+}
--- /dev/null
+++ b/zip.js
@@ -1,0 +1,159 @@
+let encoder = new TextEncoder()
+let decoder = new TextDecoder()
+
+export async function zip(files)
+{
+ let size = 0
+ let offset = 0
+ let main = []
+ let central = []
+ for (let file of files) {
+ let info = await getInfo(file, offset)
+ main.push(info.main)
+ central.push(info.central)
+ offset += info.main.size
+ size += info.central.size
+ }
+ return new Blob([...main, ...central, new Uint8Array([0x50, 0x4B, 5, 6]), new Uint16Array([0, 0, files.length, files.length]), new Uint32Array([size, offset]), new Uint16Array([0])], {type: "application/zip"})
+}
+
+async function getInfo(file, offset)
+{
+ let bytes = new Uint8Array(await file.arrayBuffer())
+ let name = encoder.encode(file.name)
+ let crc32 = CRC32(bytes)
+ let main = new Blob([new Uint8Array([0x50, 0x4B, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), new Uint32Array([crc32, bytes.length, bytes.length]), new Uint16Array([name.length, 0]), name, file])
+ let central = new Blob([new Uint8Array([0x50, 0x4B, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), new Uint32Array([crc32, bytes.length, bytes.length]), new Uint16Array([name.length, 0, 0, 0, 0]), new Uint32Array([0, offset]), name])
+ return {main, central}
+}
+
+let table = []
+for (let n = 0 ; n < 256 ; n++) {
+ let c = n
+ for (let k = 0 ; k < 8 ; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
+ table.push(c)
+}
+
+function CRC32(bytes)
+{
+ let crc = 0 ^ -1
+ for (let i = 0 ; i < bytes.length ; i++) crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xFF]
+ return crc ^ -1
+}
+
+export async function unzip(bytes)
+{
+ let n = bytes.length - 22
+ while (true) {
+ if (n < 0) return
+ if (bytes[n] === 0x50 && bytes[n + 1] === 0x4B && bytes[n + 2] === 5 && bytes[n + 3] === 6) {
+ break
+ }
+ n--
+ }
+
+ let array = []
+ let uint
+ let read = length =>
+ {
+ array = []
+ uint = 0
+ for (let i = 0 ; i < length ; i++) {
+ if (n > bytes.length - 1) return true
+ uint += bytes[n] * 0x100 ** i
+ array.push(bytes[n])
+ n++
+ }
+ }
+
+ if (read(4)) return
+ if (read(2)) return
+ let index = uint
+ if (read(2)) return
+ if (uint !== index) return
+ if (read(2)) return
+ let count = uint
+ if (read(2)) return
+ if (uint !== count) return
+ if (read(4)) return
+ if (read(4)) return
+ let offset = uint
+ if (read(2)) return
+ if (uint !== bytes.length - n) return
+
+ let files = new Map()
+ let m = offset
+
+ for (let i = 0 ; i < count ; i++) {
+
+ n = m
+ if (read(4)) return
+ if (uint !== 0x02014B50) return
+ if (read(2)) return
+ if (read(2)) return
+ if (read(2)) return
+ let flags = uint
+ if (read(2)) return
+ let compression = uint
+ if (compression !== 0 && compression !== 8) return
+ if (read(2)) return
+ if (read(2)) return
+ if (read(4)) return
+ if (read(4)) return
+ let length = uint
+ if (read(4)) return
+ let totalLength = uint
+ if (read(2)) return
+ let nameLength = uint
+ if (read(2)) return
+ let extraLength = uint
+ if (read(2)) return
+ let commentLength = uint
+ if (read(2)) return
+ if (uint !== index) return
+ if (read(2)) return
+ if (read(4)) return
+ if (read(4)) return
+ let offset = uint
+ if (read(nameLength)) return
+ let name = decoder.decode(new Uint8Array(array))
+ if (read(extraLength)) return
+ if (read(commentLength)) return
+ m = n
+
+ n = offset
+ if (read(4)) return
+ if (uint !== 0x04034B50) return
+ if (read(2)) return
+ if (read(2)) return
+ if (uint !== flags) return
+ if (read(2)) return
+ if (uint !== compression) return
+ if (read(2)) return
+ if (read(2)) return
+ if (read(4)) return
+ if (read(4)) return
+ if (uint !== length)
+ if (read(4)) return
+ if (read(4)) return
+ if (uint !== totalLength) return
+ if (read(2)) return
+ if (uint !== nameLength) return
+ if (read(2)) return
+ let localExtraLength = uint
+ if (read(nameLength)) return
+ if (decoder.decode(new Uint8Array(array)) !== name) return
+ if (read(localExtraLength)) return
+ if (read(length)) return
+
+ let bytes = new Uint8Array(array)
+ if (compression) {
+ try { bytes = new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer()) }
+ catch { return }
+ }
+ if (bytes.length !== totalLength) return
+ files.set(name, bytes)
+ }
+
+ return files
+}
--
⑨