shithub: scrax

ref: 81d1e4e01d7001e6a0107792e6167136787295d6
dir: /stage.js/

View raw version
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)