ref: 81d1e4e01d7001e6a0107792e6167136787295d6
dir: /stage.js/
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)