diff --git a/src/Project.ts b/src/Project.ts index 72ae2da..3c9e771 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -4,6 +4,7 @@ import fromSb3, { fromSb3JSON } from "./io/sb3/fromSb3"; import toSb3 from "./io/sb3/toSb3"; import toLeopard from "./io/leopard/toLeopard"; import toScratchblocks from "./io/scratchblocks/toScratchblocks"; +import toPatch from "./io/patch/toPatch"; export type TextToSpeechLanguage = | "ar" @@ -37,6 +38,7 @@ export default class Project { public toSb3 = toSb3.bind(null, this); public toLeopard = toLeopard.bind(null, this); public toScratchblocks = toScratchblocks.bind(null, this); + public toPatch = toPatch.bind(null, this); public stage: Stage = new Stage(); public sprites: Sprite[] = []; diff --git a/src/cli/index.ts b/src/cli/index.ts index c0e9139..25b62d3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,7 +16,9 @@ program .requiredOption("-i, --input ", "The path to the input project") .addOption(new Option("-it, --input-type ", "The type of input file").choices(["sb3"])) .requiredOption("-o, --output ", "The path to the output project") - .addOption(new Option("-ot, --output-type ", "The type of output file").choices(["leopard", "leopard-zip"])) + .addOption( + new Option("-ot, --output-type ", "The type of output file").choices(["leopard", "leopard-zip", "patch"]) + ) .addOption(new Option("-t, --trace", "Show a detailed error trace")) .addOption( new Option("--leopard-url ", "The URL to use for Leopard").default("https://unpkg.com/leopard@^1/dist/") @@ -28,7 +30,7 @@ const options: { input: string; inputType: "sb3"; output: string; - outputType: "leopard" | "leopard-zip"; + outputType: "leopard" | "leopard-zip" | "patch"; trace: boolean | undefined; leopardUrl: string; } = program.opts(); @@ -71,6 +73,8 @@ try { throw new InferTypeError("output", "Scratch 3.0 output projects are not currently supported."); } else if (path.extname(output) === "") { outputType = "leopard"; + } else if (output.endsWith(".ptch1")) { + outputType = "patch"; } else { throw new InferTypeError("output", "Could not infer output type."); } @@ -188,6 +192,10 @@ async function run() { }); } + function toPatch() { + return project.toPatch({}); + } + switch (outputType) { case "leopard": { const leopard = await writeStep(`${chalk.bold("Converting")} project to ${chalk.white("Leopard")}.`, toLeopard); @@ -303,6 +311,47 @@ async function run() { zip.generateNodeStream({ type: "nodebuffer", streamFiles: true }).pipe(createWriteStream(fullOutputPath)); }); + break; + } + case "patch": { + const patch = await writeStep(`${chalk.bold("Converting")} project to ${chalk.white("Patch")}.`, toPatch); + + const fullOutputPath = path.resolve(process.cwd(), output); + + await writeStep(`${chalk.bold("Exporting")} project to zip file ${chalk.white(fullOutputPath)}.`, async () => { + // First, check if file name is already taken + try { + await fs.access(fullOutputPath); + throw new StepError("Output file already exists."); + } catch (err) { + if (err instanceof Object && "code" in err && err.code === "ENOENT") { + // File does not exist, good + } else { + throw err; + } + } + + const zip = new JSZip(); + + zip.file("project.json", Buffer.from(patch.json)); + + for (const target of [project.stage, ...project.sprites]) { + for (const costume of target.costumes) { + const filename = `${costume.md5}.${costume.ext}`; + const asset = Buffer.from(costume.asset as ArrayBuffer); + zip.file(filename, asset); + } + + for (const sound of target.sounds) { + const filename = `${sound.md5}.${sound.ext}`; + const asset = Buffer.from(sound.asset as ArrayBuffer); + zip.file(filename, asset); + } + } + + zip.generateNodeStream({ type: "nodebuffer", streamFiles: true }).pipe(createWriteStream(fullOutputPath)); + }); + break; } } diff --git a/src/io/patch/conversion-layer.ts b/src/io/patch/conversion-layer.ts new file mode 100644 index 0000000..cc853f2 --- /dev/null +++ b/src/io/patch/conversion-layer.ts @@ -0,0 +1,437 @@ +import { PatchTarget, PatchTargetThread } from "./patch-interfaces"; + +export interface ConversionLayerType { + [key: string]: { + opcode: string; + parameters: string[]; + returnParametersInstead?: string[]; + returnInstead?: string[]; + }; +} + +export type PartialConverterType = (target: PatchTarget, hatId: string, nextId: string) => PatchTargetThread; + +export type InputProcessorType = ( + target: PatchTarget, + currentBlockId: string, + autoParentheses?: boolean, + tryMakeNum?: boolean +) => { [key: string]: string }; + +export type LineIndenterType = (lines: string) => string; + +export default { + // Motion blocks: + move: { + opcode: "motion_movesteps", + parameters: ["STEPS"] + }, + goToXY: { + opcode: "motion_gotoxy", + parameters: ["X", "Y"] + }, + goTo: { + opcode: "motion_goto", + parameters: ["TO"] + }, + turnRight: { + opcode: "motion_turnright", + parameters: ["DEGREES"] + }, + turnLeft: { + opcode: "motion_turnleft", + parameters: ["DEGREES"] + }, + pointInDirection: { + opcode: "motion_pointindirection", + parameters: ["DIRECTION"] + }, + pointTowards: { + opcode: "motion_pointtowards", + parameters: ["TOWARDS"] + }, + glide: { + opcode: "motion_glidesecstoxy", + parameters: ["SECS", "X", "Y"] + }, + glideTo: { + opcode: "motion_glideto", + parameters: ["SECS", "TO"] + }, + ifOnEdgeBounce: { + opcode: "motion_ifonedgebounce", + parameters: [] + }, + setRotationStyle: { + opcode: "motion_setrotationstyle", + parameters: ["STYLE"] + }, + changeX: { + opcode: "motion_changexby", + parameters: ["DX"] + }, + setX: { + opcode: "motion_setx", + parameters: ["X"] + }, + changeY: { + opcode: "motion_changeyby", + parameters: ["DY"] + }, + setY: { + opcode: "motion_sety", + parameters: ["Y"] + }, + getX: { + opcode: "motion_xposition", + parameters: [] + }, + getY: { + opcode: "motion_yposition", + parameters: [] + }, + getDirection: { + opcode: "motion_direction", + parameters: [] + }, + goToMenu: { + opcode: "motion_goto_menu", + parameters: ["TO"], + returnParametersInstead: ["TO"] + }, + glideToMenu: { + opcode: "motion_glideto_menu", + parameters: ["TO"], + returnParametersInstead: ["TO"] + }, + pointTowardsMenu: { + opcode: "motion_pointtowards_menu", + parameters: ["TOWARDS"], + returnParametersInstead: ["TOWARDS"] + }, + + // Looks blocks: + say: { + opcode: "looks_say", + parameters: ["MESSAGE"] + }, + sayFor: { + opcode: "looks_sayforsecs", + parameters: ["MESSAGE", "SECS"] + }, + think: { + opcode: "looks_think", + parameters: ["MESSAGE"] + }, + thinkFor: { + opcode: "looks_thinkforsecs", + parameters: ["MESSAGE", "SECS"] + }, + show: { + opcode: "looks_show", + parameters: [] + }, + hide: { + opcode: "looks_hide", + parameters: [] + }, + setCostumeTo: { + opcode: "looks_switchcostumeto", + parameters: ["COSTUME"] + }, + setBackdropTo: { + opcode: "looks_switchbackdropto", + parameters: ["BACKDROP"] + }, + setBackdropToAndWait: { + opcode: "looks_switchbackdroptoandwait", + parameters: ["BACKDROP"] + }, + nextCostume: { + opcode: "looks_nextcostume", + parameters: [] + }, + nextBackdrop: { + opcode: "looks_nextbackdrop", + parameters: [] + }, + changeGraphicEffectBy: { + opcode: "looks_changeeffectby", + parameters: ["EFFECT", "CHANGE"] + }, + setGraphicEffectTo: { + opcode: "looks_seteffectto", + parameters: ["EFFECT", "VALUE"] + }, + clearGraphicEffects: { + opcode: "looks_cleargraphiceffects", + parameters: [] + }, + changeSizeBy: { + opcode: "looks_changesizeby", + parameters: ["CHANGE"] + }, + setSizeTo: { + opcode: "looks_setsizeto", + parameters: ["SIZE"] + }, + setLayerTo: { + opcode: "looks_gotofrontback", + parameters: ["FRONT_BACK"] + }, + changeLayerBy: { + opcode: "looks_goforwardbackwardlayers", + parameters: ["FORWARD_BACKWARD", "NUM"] + }, + getSize: { + opcode: "looks_size", + parameters: [] + }, + getCostume: { + opcode: "looks_costumenumbername", + parameters: [] + }, + getBackdrop: { + opcode: "looks_backdropnumbername", + parameters: [] + }, + costume: { + opcode: "looks_costume", + parameters: ["COSTUME"], + returnParametersInstead: ["COSTUME"] + }, + backdrops: { + opcode: "looks_backdrops", + parameters: ["BACKDROP"], + returnParametersInstead: ["BACKDROP"] + }, + + // Sound blocks: + playSound: { + opcode: "sound_play", + parameters: ["SOUND_MENU"] + }, + playSoundUntilDone: { + opcode: "sound_playuntildone", + parameters: ["SOUND_MENU"] + }, + stopAllSounds: { + opcode: "sound_stopallsounds", + parameters: [] + }, + setSoundEffectTo: { + opcode: "sound_seteffectto", + parameters: ["EFFECT", "VALUE"] + }, + changeSoundEffectBy: { + opcode: "sound_changeeffectby", + parameters: ["EFFECT", "VALUE"] + }, + clearSoundEffects: { + opcode: "sound_cleareffects", + parameters: [] + }, + setVolumeTo: { + opcode: "sound_setvolumeto", + parameters: ["VOLUME"] + }, + changeVolumeBy: { + opcode: "sound_changevolumeby", + parameters: ["VOLUME"] + }, + getVolume: { + opcode: "sound_volume", + parameters: [] + }, + soundsMenu: { + opcode: "sound_sounds_menu", + parameters: ["SOUND_MENU"], + returnParametersInstead: ["SOUND_MENU"] + }, + + // Broadcast blocks: + // The way these work in Scratch is that you have to create each broadcast, then it becomes an option in the dropdown + // on the blocks. However, in Patch, it will just accept any string on both the send and recieve. For this reason, + // broadcasts are removed from each Scratch target in the conversion from Scratch to Patch. + broadcast: { + opcode: "event_broadcast", + parameters: ["BROADCAST_INPUT"] + }, + broadcastAndWait: { + opcode: "event_broadcastandwait", + parameters: ["BROADCAST_INPUT"] + }, + + // Sensing blocks: + isTouching: { + opcode: "sensing_touchingobject", + parameters: ["TOUCHINGOBJECTMENU"] + }, + isTouchingColor: { + opcode: "sensing_touchingcolor", + parameters: ["COLOR"] + }, + isColorTouchingColor: { + opcode: "sensing_coloristouchingcolor", + parameters: ["COLOR", "COLOR2"] + }, + distanceTo: { + opcode: "sensing_distanceto", + parameters: ["DISTANCETOMENU"] + }, + getTimer: { + opcode: "sensing_timer", + parameters: [] + }, + resetTimer: { + opcode: "sensing_resettimer", + parameters: [] + }, + getAttributeOf: { + opcode: "sensing_of", + parameters: ["OBJECT", "PROPERTY"] + }, + getMouseX: { + opcode: "sensing_mousex", + parameters: [] + }, + getMouseY: { + opcode: "sensing_mousey", + parameters: [] + }, + isMouseDown: { + opcode: "sensing_mousedown", + parameters: [] + }, + // setDragMode: { + // opcode: "sensing_setdragmode", + // parameters: ["degrees"], + // }, + isKeyPressed: { + opcode: "sensing_keypressed", + parameters: ["KEY_OPTION"] + }, + current: { + opcode: "sensing_current", + parameters: ["CURRENTMENU"] + }, + daysSince2000: { + opcode: "sensing_dayssince2000", + parameters: [] + }, + getLoudness: { + opcode: "sensing_loudness", + parameters: [] + }, + getUsername: { + opcode: "sensing_username", + parameters: [] + }, + ask: { + opcode: "sensing_askandwait", + parameters: ["QUESTION"] + }, + // getAnswer: { + // opcode: "sensing_answer" + // }, + getAnswer: { + opcode: "sensing_answer", + parameters: [], + returnInstead: ["_patchAnswer"] + }, + touchingObjectMenu: { + opcode: "sensing_touchingobjectmenu", + parameters: ["TOUCHINGOBJECTMENU"], + returnParametersInstead: ["TOUCHINGOBJECTMENU"] + }, + distanceToMenu: { + opcode: "sensing_distancetomenu", + parameters: ["DISTANCETOMENU"], + returnParametersInstead: ["DISTANCETOMENU"] + }, + keyOptions: { + opcode: "sensing_keyoptions", + parameters: ["KEY_OPTION"], + returnParametersInstead: ["KEY_OPTION"] + }, + getAttributeOfObjectMenu: { + opcode: "sensing_of_object_menu", + parameters: ["OBJECT"], + returnParametersInstead: ["OBJECT"] + }, + + wait: { + opcode: "control_wait", + parameters: ["DURATION"] + }, + // waitUntil: { + // opcode: "control_wait_until", + // parameters: ["condition"], + // }, + stop: { + opcode: "control_stop", + parameters: ["STOP_OPTION"] + }, + createClone: { + opcode: "control_create_clone_of", + parameters: ["CLONE_OPTION"] + }, + deleteClone: { + opcode: "control_delete_this_clone", + parameters: [] + }, + createCloneMenu: { + opcode: "control_create_clone_of_menu", + parameters: ["CLONE_OPTION"], + returnParametersInstead: ["CLONE_OPTION"] + }, + + erasePen: { + opcode: "pen_clear", + parameters: [] + }, + stampPen: { + opcode: "pen_stamp", + parameters: [] + }, + penDown: { + opcode: "pen_penDown", + parameters: [] + }, + penUp: { + opcode: "pen_penUp", + parameters: [] + }, + setPenColor: { + opcode: "pen_setPenColorToColor", + parameters: ["COLOR"] + }, + changePenEffect: { + opcode: "pen_changePenColorParamBy", + parameters: ["COLOR_PARAM", "VALUE"] + }, + setPenEffect: { + opcode: "pen_setPenColorParamTo", + parameters: ["COLOR_PARAM", "VALUE"] + }, + changePenSize: { + opcode: "pen_changePenSizeBy", + parameters: ["SIZE"] + }, + setPenSize: { + opcode: "pen_setPenSizeTo", + parameters: ["SIZE"] + }, + penEffectMenu: { + opcode: "pen_menu_colorParam", + // The opcode should be camelcase; this isn't a mistake (unless it isn't camelcase, in which case it shouls + // be made camelcase). + parameters: ["colorParam"], + returnParametersInstead: ["colorParam"] + }, + + endThread: { + opcode: "core_endthread", + parameters: [] + } +} as ConversionLayerType; diff --git a/src/io/patch/interfaces.ts b/src/io/patch/interfaces.ts new file mode 100644 index 0000000..9e7157b --- /dev/null +++ b/src/io/patch/interfaces.ts @@ -0,0 +1,592 @@ +import { OpCode } from "../../OpCode"; +import { KnownBlock, ProcedureBlock } from "../../Block"; +import { TextToSpeechLanguage } from "../../Project"; +import * as _BlockInput from "../../BlockInput"; + +// Note: This schema is designed to match the definitions in +// https://github.com/LLK/scratch-parser/blob/master/lib/sb3_definitions.json + +// Values storable in variables and lists. +export type ScalarValue = string | number | boolean; + +// 32-length hex string - the MD5 of the asset. +// Does not include the asset's file extension. +export type AssetId = string; + +// [name, value, cloud] +// For example: ["Highscore", 3000, true] +// Note: Scratch's server prevents uploading non-number values to the cloud +// variable server, but this restriction is not enforced in the sb3 schema. +export type Variable = [string, ScalarValue] | [string, ScalarValue, true]; + +// [name, contents] +// For example: ["My List", [1, 2, true, "banana"]] +export type List = [string, ScalarValue[]]; + +export interface Costume { + assetId: AssetId; + dataFormat: "png" | "svg" | "jpeg" | "jpg" | "bmp" | "gif"; + name: string; + + md5ext?: string; + + bitmapResolution?: number; + rotationCenterX?: number; + rotationCenterY?: number; +} + +export interface Sound { + assetId: AssetId; + dataFormat: "wav" | "wave" | "mp3"; + name: string; + + md5ext?: string; + + rate?: number; + sampleCount?: number; +} + +// JSON representation of an XML object. Structure varies per opcode. +interface Mutation { + [attribute: string]: Mutation[] | string; + children: Mutation[]; +} + +export interface ProceduresPrototypeMutation { + proccode: string; + argumentnames: string; + argumentids: string; + argumentdefaults: string; + warp: "true" | "false"; +} + +export interface ProceduresCallMutation { + proccode: string; + argumentnames: string; + argumentids: string; + argumentdefaults: string; + warp: "true" | "false"; +} + +type MutationFor = Op extends OpCode.procedures_prototype + ? ProceduresPrototypeMutation + : Op extends OpCode.procedures_call + ? ProceduresCallMutation + : Mutation | undefined; + +export interface Block { + opcode: Op; + + next?: string | null; + parent?: string | null; + + inputs: { + [key: string]: BlockInput; + }; + fields: { + [key: string]: BlockField; + }; + + mutation: MutationFor; + + shadow: boolean; + topLevel: boolean; + + x?: number; + y?: number; +} + +export type BlockField = Readonly<[string, string | null] | [string]>; + +interface Comment { + blockId: string; + x: number; + y: number; + width: number; + height: number; + minimized: boolean; + text: string; +} + +export interface Target { + isStage: boolean; + name: string; + variables: { + [key: string]: Variable; + }; + lists: { + [key: string]: List; + }; + broadcasts: { + [key: string]: string; + }; + blocks: { + [key: string]: Block; + }; + comments: { + [key: string]: Comment; + }; + currentCostume: number; + costumes: Costume[]; + sounds: Sound[]; + volume: number; + layerOrder: number; +} + +export interface Stage extends Target { + isStage: true; + tempo: number; + videoTransparency: number; + videoState: "on" | "off"; + textToSpeechLanguage: TextToSpeechLanguage | null; +} + +export interface Sprite extends Target { + isStage: false; + visible: boolean; + x: number; + y: number; + size: number; + direction: number; + draggable: boolean; + rotationStyle: "all around" | "left-right" | "don't rotate"; +} + +interface MonitorBase { + id: string; + mode: "default" | "large" | "slider" | "list"; + opcode: "data_variable" | "data_listcontents"; + params: { + [key: string]: string; + }; + spriteName: string; + width?: number | null; + height?: number | null; + x: number; + y: number; + visible: boolean; +} + +export interface VariableMonitor extends MonitorBase { + mode: "default" | "large" | "slider"; + opcode: "data_variable"; + params: { + VARIABLE: string; + }; + value: ScalarValue; + sliderMin: number; + sliderMax: number; + isDiscrete: boolean; +} + +export interface ListMonitor extends MonitorBase { + mode: "list"; + opcode: "data_listcontents"; + params: { + LIST: string; + }; + value: ScalarValue[]; +} + +export type Monitor = VariableMonitor | ListMonitor; + +interface Meta { + semver: string; + vm?: string; + agent?: string; +} + +export interface ProjectJSON { + targets: Target[]; + monitors?: Monitor[]; + // TODO: extensions: Extension[]; + meta: Meta; +} + +export const fieldTypeMap: { + [opcode in OpCode]?: { + [fieldName: string]: _BlockInput.Any["type"]; + }; +} = { + // Standalone blocks + + [OpCode.motion_setrotationstyle]: { STYLE: "rotationStyle" }, + [OpCode.motion_align_scene]: { ALIGNMENT: "scrollAlignment" }, + [OpCode.looks_gotofrontback]: { FRONT_BACK: "frontBackMenu" }, + [OpCode.looks_goforwardbackwardlayers]: { FORWARD_BACKWARD: "forwardBackwardMenu" }, + [OpCode.looks_changeeffectby]: { EFFECT: "graphicEffect" }, + [OpCode.looks_backdropnumbername]: { NUMBER_NAME: "costumeNumberName" }, + [OpCode.looks_costumenumbername]: { NUMBER_NAME: "costumeNumberName" }, + [OpCode.looks_seteffectto]: { EFFECT: "graphicEffect" }, + [OpCode.sound_seteffectto]: { EFFECT: "soundEffect" }, + [OpCode.sound_changeeffectby]: { EFFECT: "soundEffect" }, + [OpCode.event_whenkeypressed]: { KEY_OPTION: "key" }, + [OpCode.event_whenbackdropswitchesto]: { BACKDROP: "backdrop" }, + [OpCode.event_whengreaterthan]: { WHENGREATERTHANMENU: "greaterThanMenu" }, + [OpCode.event_whenbroadcastreceived]: { BROADCAST_OPTION: "broadcast" }, + [OpCode.control_stop]: { STOP_OPTION: "stopMenu" }, + [OpCode.control_for_each]: { VARIABLE: "variable" }, + [OpCode.sensing_setdragmode]: { DRAG_MODE: "dragModeMenu" }, + [OpCode.sensing_of]: { PROPERTY: "propertyOfMenu" }, + [OpCode.sensing_current]: { CURRENTMENU: "currentMenu" }, + [OpCode.operator_mathop]: { OPERATOR: "mathopMenu" }, + [OpCode.data_variable]: { VARIABLE: "variable" }, + [OpCode.data_setvariableto]: { VARIABLE: "variable" }, + [OpCode.data_changevariableby]: { VARIABLE: "variable" }, + [OpCode.data_showvariable]: { VARIABLE: "variable" }, + [OpCode.data_hidevariable]: { VARIABLE: "variable" }, + [OpCode.data_listcontents]: { LIST: "list" }, + [OpCode.data_addtolist]: { LIST: "list" }, + [OpCode.data_deleteoflist]: { LIST: "list" }, + [OpCode.data_deletealloflist]: { LIST: "list" }, + [OpCode.data_insertatlist]: { LIST: "list" }, + [OpCode.data_replaceitemoflist]: { LIST: "list" }, + [OpCode.data_itemoflist]: { LIST: "list" }, + [OpCode.data_itemnumoflist]: { LIST: "list" }, + [OpCode.data_lengthoflist]: { LIST: "list" }, + [OpCode.data_listcontainsitem]: { LIST: "list" }, + [OpCode.data_showlist]: { LIST: "list" }, + [OpCode.data_hidelist]: { LIST: "list" }, + [OpCode.argument_reporter_string_number]: { VALUE: "string" }, + [OpCode.argument_reporter_boolean]: { VALUE: "string" }, + + // Shadow blocks - generally these are menus or specialized inputs + // + // These are treated differently than normal inputs and sometimes specially + // processed, so each item shows the blocks which refer to this menu. + + [OpCode.motion_pointtowards_menu]: { TOWARDS: "pointTowardsTarget" }, + // - OpCode.motion_pointtowards + + [OpCode.motion_glideto_menu]: { TO: "goToTarget" }, + // - OpCode.motion_glideto + + [OpCode.motion_goto_menu]: { TO: "goToTarget" }, + // - OpCode.motion_goto + + [OpCode.looks_costume]: { COSTUME: "costume" }, + // - OpCode.looks_switchcostumeto + + [OpCode.looks_backdrops]: { BACKDROP: "backdrop" }, + // - OpCode.looks_switchbackdropto + // - OpCode.looks_switchbackdroptoandwait + + [OpCode.sound_sounds_menu]: { SOUND_MENU: "sound" }, + // - OpCode.sound_play + // - OpCode.sound_playuntildone + + [OpCode.event_broadcast_menu]: { BROADCAST_OPTION: "broadcast" }, + // (Not directly used in any blocks; this is generally serialized + // as a BlockInputStatus.BROADCAST_PRIMITIVE instead) + + [OpCode.control_create_clone_of_menu]: { CLONE_OPTION: "cloneTarget" }, + // - OpCode.control_create_clone_of + + [OpCode.sensing_touchingobjectmenu]: { TOUCHINGOBJECTMENU: "touchingTarget" }, + // - OpCode.sensing_touchingobject + + [OpCode.sensing_distancetomenu]: { DISTANCETOMENU: "distanceToMenu" }, + // - OpCode.sensing_distanceto + + [OpCode.sensing_keyoptions]: { KEY_OPTION: "key" }, + // - OpCode.sensing_keypressed + + [OpCode.sensing_of_object_menu]: { OBJECT: "target" }, + // - OpCode.sensing_of + + [OpCode.pen_menu_colorParam]: { colorParam: "penColorParam" }, + // - OpCode.pen_changePenColorParamBy + // - OpCode.pen_setPenColorParamTo + // + // (!) NOTE: The above blocks' input is `COLOR_PARAM`, but this + // shadow block's field is `colorParam`. + + [OpCode.music_menu_DRUM]: { DRUM: "musicDrum" }, + // - OpCode.music_playDrumForBeats + + [OpCode.music_menu_INSTRUMENT]: { INSTRUMENT: "musicInstrument" }, + // - OpCode.music_setInstrument + + [OpCode.note]: { NOTE: "number" }, + // - OpCode.music_playNoteForBeats + + [OpCode.videoSensing_menu_ATTRIBUTE]: { ATTRIBUTE: "videoSensingAttribute" }, + // - OpCode.videoSensing_videoOn + + [OpCode.videoSensing_menu_SUBJECT]: { SUBJECT: "videoSensingSubject" }, + // - OpCode.videoSensing_videoOn + + [OpCode.videoSensing_menu_VIDEO_STATE]: { VIDEO_STATE: "videoSensingVideoState" }, + // - OpCode.videoSensing_videoToggle + + [OpCode.wedo2_menu_MOTOR_ID]: { MOTOR_ID: "wedo2MotorId" }, + // - OpCode.wedo2_motorOnFor + // - OpCode.wedo2_motorOn + // - OpCode.wedo2_motorOff + // - OpCode.wedo2_startMotorPower + // - OpCode.wedo2_setMotorDirection + + [OpCode.wedo2_menu_MOTOR_DIRECTION]: { MOTOR_DIRECTION: "wedo2MotorDirection" }, + // - OpCode.wedo2_setMotorDirection + + [OpCode.wedo2_menu_TILT_DIRECTION]: { TILT_DIRECTION: "wedo2TiltDirection" }, + // - OpCode.wedo2_getTiltAngle + + [OpCode.wedo2_menu_TILT_DIRECTION_ANY]: { TILT_DIRECTION_ANY: "wedo2TiltDirectionAny" }, + // - OpCode.wedo2_whenTilted + // - OpCode.wedo2_isTilted + + [OpCode.wedo2_menu_OP]: { OP: "wedo2Op" } + // - OpCode.wedo2_whenDistance +}; + +export enum BlockInputStatus { + INPUT_SAME_BLOCK_SHADOW = 1, + INPUT_BLOCK_NO_SHADOW, + INPUT_DIFF_BLOCK_SHADOW, + MATH_NUM_PRIMITIVE, + POSITIVE_NUM_PRIMITIVE, + WHOLE_NUM_PRIMITIVE, + INTEGER_NUM_PRIMITIVE, + ANGLE_NUM_PRIMITIVE, + COLOR_PICKER_PRIMITIVE, + TEXT_PRIMITIVE, + BROADCAST_PRIMITIVE, + VAR_PRIMITIVE, + LIST_PRIMITIVE +} + +export import BIS = BlockInputStatus; + +export const BooleanOrSubstackInputStatus = BIS.INPUT_BLOCK_NO_SHADOW; + +export type BlockInput = Readonly< + | [BIS.INPUT_SAME_BLOCK_SHADOW, BlockInputValue | null] + | [BIS.INPUT_BLOCK_NO_SHADOW, BlockInputValue | null] + | [BIS.INPUT_DIFF_BLOCK_SHADOW, BlockInputValue | null, BlockInputValue] +>; + +export type BlockInputValue = Readonly< + | string // Block ID + | [BIS.MATH_NUM_PRIMITIVE, number | string] + | [BIS.POSITIVE_NUM_PRIMITIVE, number | string] + | [BIS.WHOLE_NUM_PRIMITIVE, number | string] + | [BIS.INTEGER_NUM_PRIMITIVE, number | string] + | [BIS.ANGLE_NUM_PRIMITIVE, number | string] + | [BIS.COLOR_PICKER_PRIMITIVE, string] + | [BIS.TEXT_PRIMITIVE, string] + | [BIS.BROADCAST_PRIMITIVE, string, string] + | [BIS.VAR_PRIMITIVE, string, string] + | [BIS.LIST_PRIMITIVE, string, string] +>; + +// Most values in this mapping are taken from scratch-gui/src/lib/make- +// toolbox-xml.js. They're used so that the primitive/opcode values of +// outputted shadow blocks are correct. +// +// Many of the entries here may seem to be missing inputs. That's becuase it +// only maps Scratch 3.0 inputs - not fields. sb-edit doesn't distinguish +// between fields and inputs, but it's critical that projects that export to +// sb3 do. +// +// Note: Boolean and substack inputs are weird. They alone are stored using +// the INPUT_BLOCK_NO_SHADOW (2) input status; when empty, they may be either +// stored as [2, null] or simply not stored at all. +// +// The BooleanOrSubstackInputStatus constant is exported for use in finding +// these values, rather than directly accessing BIS.INPUT_BLOCK_NO_SHADOW +// (which could imply special handling for the other INPUT_BLOCK_* values, +// when none such is required and whose values are never specified in this +// mapping). +export const inputPrimitiveOrShadowMap = { + [OpCode.motion_movesteps]: { STEPS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_turnright]: { DEGREES: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_turnleft]: { DEGREES: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_pointindirection]: { DIRECTION: BIS.ANGLE_NUM_PRIMITIVE }, + [OpCode.motion_pointtowards]: { TOWARDS: OpCode.motion_pointtowards_menu }, + [OpCode.motion_gotoxy]: { X: BIS.MATH_NUM_PRIMITIVE, Y: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_goto]: { TO: OpCode.motion_goto_menu }, + [OpCode.motion_glidesecstoxy]: { SECS: BIS.MATH_NUM_PRIMITIVE, X: BIS.MATH_NUM_PRIMITIVE, Y: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_glideto]: { SECS: BIS.MATH_NUM_PRIMITIVE, TO: OpCode.motion_glideto_menu }, + [OpCode.motion_changexby]: { DX: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_setx]: { X: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_changeyby]: { DY: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_sety]: { Y: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_ifonedgebounce]: {}, + [OpCode.motion_setrotationstyle]: {}, + [OpCode.motion_xposition]: {}, + [OpCode.motion_yposition]: {}, + [OpCode.motion_direction]: {}, + [OpCode.motion_scroll_right]: { DISTANCE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_scroll_up]: { DISTANCE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.motion_align_scene]: {}, + [OpCode.looks_sayforsecs]: { MESSAGE: BIS.TEXT_PRIMITIVE, SECS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_say]: { MESSAGE: BIS.TEXT_PRIMITIVE }, + [OpCode.looks_thinkforsecs]: { MESSAGE: BIS.TEXT_PRIMITIVE, SECS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_think]: { MESSAGE: BIS.TEXT_PRIMITIVE }, + [OpCode.looks_show]: {}, + [OpCode.looks_hide]: {}, + [OpCode.looks_switchcostumeto]: { COSTUME: OpCode.looks_costume }, + [OpCode.looks_nextcostume]: {}, + [OpCode.looks_nextbackdrop]: {}, + [OpCode.looks_switchbackdropto]: { BACKDROP: OpCode.looks_backdrops }, + [OpCode.looks_switchbackdroptoandwait]: { BACKDROP: OpCode.looks_backdrops }, + [OpCode.looks_changeeffectby]: { CHANGE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_seteffectto]: { VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_changesizeby]: { CHANGE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_setsizeto]: { SIZE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_cleargraphiceffects]: {}, + [OpCode.looks_gotofrontback]: {}, + [OpCode.looks_goforwardbackwardlayers]: { NUM: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_costumenumbername]: {}, + [OpCode.looks_backdropnumbername]: {}, + [OpCode.looks_size]: {}, + [OpCode.looks_hideallsprites]: {}, + [OpCode.looks_changestretchby]: { CHANGE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.looks_setstretchto]: { STRETCH: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.sound_play]: { SOUND_MENU: OpCode.sound_sounds_menu }, + [OpCode.sound_playuntildone]: { SOUND_MENU: OpCode.sound_sounds_menu }, + [OpCode.sound_stopallsounds]: {}, + [OpCode.sound_changeeffectby]: { VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.sound_seteffectto]: { VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.sound_cleareffects]: {}, + [OpCode.sound_changevolumeby]: { VOLUME: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.sound_setvolumeto]: { VOLUME: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.sound_volume]: {}, + [OpCode.event_whenflagclicked]: {}, + [OpCode.event_whenkeypressed]: {}, + [OpCode.event_whenstageclicked]: {}, + [OpCode.event_whenthisspriteclicked]: {}, + [OpCode.event_whenbackdropswitchesto]: {}, + [OpCode.event_whengreaterthan]: { VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.event_whenbroadcastreceived]: {}, + [OpCode.event_broadcast]: { BROADCAST_INPUT: BIS.BROADCAST_PRIMITIVE }, + [OpCode.event_broadcastandwait]: { BROADCAST_INPUT: BIS.BROADCAST_PRIMITIVE }, + [OpCode.control_wait]: { DURATION: BIS.POSITIVE_NUM_PRIMITIVE }, + [OpCode.control_repeat]: { TIMES: BIS.WHOLE_NUM_PRIMITIVE, SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_forever]: { SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_if]: { CONDITION: BooleanOrSubstackInputStatus, SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_if_else]: { + CONDITION: BooleanOrSubstackInputStatus, + SUBSTACK: BooleanOrSubstackInputStatus, + SUBSTACK2: BooleanOrSubstackInputStatus + }, + [OpCode.control_wait_until]: { CONDITION: BooleanOrSubstackInputStatus }, + [OpCode.control_repeat_until]: { CONDITION: BooleanOrSubstackInputStatus, SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_while]: { CONDITION: BooleanOrSubstackInputStatus, SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_for_each]: { VALUE: BIS.MATH_NUM_PRIMITIVE, SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_all_at_once]: { SUBSTACK: BooleanOrSubstackInputStatus }, + [OpCode.control_stop]: {}, + [OpCode.control_start_as_clone]: {}, + [OpCode.control_create_clone_of]: { CLONE_OPTION: OpCode.control_create_clone_of_menu }, + [OpCode.control_delete_this_clone]: {}, + [OpCode.sensing_touchingobject]: { TOUCHINGOBJECTMENU: OpCode.sensing_touchingobjectmenu }, + [OpCode.sensing_touchingcolor]: { COLOR: BIS.COLOR_PICKER_PRIMITIVE }, + [OpCode.sensing_coloristouchingcolor]: { COLOR: BIS.COLOR_PICKER_PRIMITIVE, COLOR2: BIS.COLOR_PICKER_PRIMITIVE }, + [OpCode.sensing_distanceto]: { DISTANCETOMENU: OpCode.sensing_distancetomenu }, + [OpCode.sensing_askandwait]: { QUESTION: BIS.TEXT_PRIMITIVE }, + [OpCode.sensing_answer]: {}, + [OpCode.sensing_keypressed]: { KEY_OPTION: OpCode.sensing_keyoptions }, + [OpCode.sensing_mousedown]: {}, + [OpCode.sensing_mousex]: {}, + [OpCode.sensing_mousey]: {}, + [OpCode.sensing_setdragmode]: {}, + [OpCode.sensing_loudness]: {}, + [OpCode.sensing_timer]: {}, + [OpCode.sensing_resettimer]: {}, + [OpCode.sensing_of]: { OBJECT: OpCode.sensing_of_object_menu }, + [OpCode.sensing_current]: {}, + [OpCode.sensing_dayssince2000]: {}, + [OpCode.sensing_username]: {}, + [OpCode.sensing_userid]: {}, + [OpCode.sensing_loud]: {}, + [OpCode.operator_add]: { NUM1: BIS.MATH_NUM_PRIMITIVE, NUM2: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_subtract]: { NUM1: BIS.MATH_NUM_PRIMITIVE, NUM2: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_multiply]: { NUM1: BIS.MATH_NUM_PRIMITIVE, NUM2: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_divide]: { NUM1: BIS.MATH_NUM_PRIMITIVE, NUM2: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_random]: { FROM: BIS.MATH_NUM_PRIMITIVE, TO: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_lt]: { OPERAND1: BIS.TEXT_PRIMITIVE, OPERAND2: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_equals]: { OPERAND1: BIS.TEXT_PRIMITIVE, OPERAND2: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_gt]: { OPERAND1: BIS.TEXT_PRIMITIVE, OPERAND2: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_and]: { OPERAND1: BooleanOrSubstackInputStatus, OPERAND2: BooleanOrSubstackInputStatus }, + [OpCode.operator_or]: { OPERAND1: BooleanOrSubstackInputStatus, OPERAND2: BooleanOrSubstackInputStatus }, + [OpCode.operator_not]: { OPERAND: BooleanOrSubstackInputStatus }, + [OpCode.operator_join]: { STRING1: BIS.TEXT_PRIMITIVE, STRING2: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_letter_of]: { LETTER: BIS.WHOLE_NUM_PRIMITIVE, STRING: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_length]: { STRING: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_contains]: { STRING1: BIS.TEXT_PRIMITIVE, STRING2: BIS.TEXT_PRIMITIVE }, + [OpCode.operator_mod]: { NUM1: BIS.MATH_NUM_PRIMITIVE, NUM2: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_round]: { NUM: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.operator_mathop]: { NUM: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_playDrumForBeats]: { DRUM: OpCode.music_menu_DRUM, BEATS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_restForBeats]: { BEATS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_playNoteForBeats]: { NOTE: OpCode.note, BEATS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_setInstrument]: { INSTRUMENT: OpCode.music_menu_INSTRUMENT }, + [OpCode.music_setTempo]: { TEMPO: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_changeTempo]: { TEMPO: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_getTempo]: {}, + [OpCode.music_midiPlayDrumForBeats]: { DRUM: BIS.MATH_NUM_PRIMITIVE, BEATS: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.music_midiSetInstrument]: { INSTRUMENT: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_clear]: {}, + [OpCode.pen_stamp]: {}, + [OpCode.pen_penDown]: {}, + [OpCode.pen_penUp]: {}, + [OpCode.pen_setPenColorToColor]: { COLOR: BIS.COLOR_PICKER_PRIMITIVE }, + [OpCode.pen_changePenColorParamBy]: { COLOR_PARAM: OpCode.pen_menu_colorParam, VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_setPenColorParamTo]: { COLOR_PARAM: OpCode.pen_menu_colorParam, VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_changePenSizeBy]: { SIZE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_setPenSizeTo]: { SIZE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_setPenShadeToNumber]: { SHADE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_changePenShadeBy]: { SHADE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_setPenHueToNumber]: { HUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.pen_changePenHueBy]: { HUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.videoSensing_whenMotionGreaterThan]: { REFERENCE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.videoSensing_videoOn]: { + ATTRIBUTE: OpCode.videoSensing_menu_ATTRIBUTE, + SUBJECT: OpCode.videoSensing_menu_SUBJECT + }, + [OpCode.videoSensing_videoToggle]: { VIDEO_STATE: OpCode.videoSensing_menu_VIDEO_STATE }, + [OpCode.videoSensing_setVideoTransparency]: { TRANSPARENCY: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.wedo2_motorOnFor]: { MOTOR_ID: OpCode.wedo2_menu_MOTOR_ID, DURATION: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.wedo2_motorOn]: { MOTOR_ID: OpCode.wedo2_menu_MOTOR_ID }, + [OpCode.wedo2_motorOff]: { MOTOR_ID: OpCode.wedo2_menu_MOTOR_ID }, + [OpCode.wedo2_startMotorPower]: { MOTOR_ID: OpCode.wedo2_menu_MOTOR_ID, POWER: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.wedo2_setMotorDirection]: { + MOTOR_ID: OpCode.wedo2_menu_MOTOR_ID, + MOTOR_DIRECTION: OpCode.wedo2_menu_MOTOR_DIRECTION + }, + [OpCode.wedo2_setLightHue]: { HUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.wedo2_whenDistance]: { OP: OpCode.wedo2_menu_OP, REFERENCE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.wedo2_whenTilted]: { TILT_DIRECTION_ANY: OpCode.wedo2_menu_TILT_DIRECTION_ANY }, + [OpCode.wedo2_getDistance]: {}, + [OpCode.wedo2_isTilted]: { TILT_DIRECTION_ANY: OpCode.wedo2_menu_TILT_DIRECTION_ANY }, + [OpCode.wedo2_getTiltAngle]: { TILT_DIRECTION: OpCode.wedo2_menu_TILT_DIRECTION }, + [OpCode.wedo2_playNoteFor]: { NOTE: BIS.MATH_NUM_PRIMITIVE, DURATION: BIS.MATH_NUM_PRIMITIVE }, + // Data category values from scratch-blocks/core/data_category.js + [OpCode.data_variable]: {}, + [OpCode.data_setvariableto]: { VALUE: BIS.TEXT_PRIMITIVE }, + [OpCode.data_changevariableby]: { VALUE: BIS.MATH_NUM_PRIMITIVE }, + [OpCode.data_showvariable]: {}, + [OpCode.data_hidevariable]: {}, + [OpCode.data_listcontents]: {}, + [OpCode.data_addtolist]: { ITEM: BIS.TEXT_PRIMITIVE }, + [OpCode.data_deleteoflist]: { INDEX: BIS.INTEGER_NUM_PRIMITIVE }, + [OpCode.data_deletealloflist]: {}, + [OpCode.data_insertatlist]: { INDEX: BIS.INTEGER_NUM_PRIMITIVE, ITEM: BIS.TEXT_PRIMITIVE }, + [OpCode.data_replaceitemoflist]: { INDEX: BIS.INTEGER_NUM_PRIMITIVE, ITEM: BIS.TEXT_PRIMITIVE }, + [OpCode.data_itemoflist]: { INDEX: BIS.INTEGER_NUM_PRIMITIVE }, + [OpCode.data_itemnumoflist]: { ITEM: BIS.TEXT_PRIMITIVE }, + [OpCode.data_lengthoflist]: {}, + [OpCode.data_listcontainsitem]: { ITEM: BIS.TEXT_PRIMITIVE }, + [OpCode.data_showlist]: {}, + [OpCode.data_hidelist]: {}, + [OpCode.argument_reporter_boolean]: {}, + [OpCode.argument_reporter_string_number]: {} +} as const satisfies { + // Custom procedure blocks should be serialized separately from how normal + // blocks are, since most of their data is stored on a "mutation" field not + // accounted for here. + [opcode in Exclude]: { + [fieldName: string]: number | OpCode; + }; +}; diff --git a/src/io/patch/patch-interfaces.ts b/src/io/patch/patch-interfaces.ts new file mode 100644 index 0000000..0d5872d --- /dev/null +++ b/src/io/patch/patch-interfaces.ts @@ -0,0 +1,85 @@ +import { TextToSpeechLanguage } from "../../Project"; +import { BlockField, Costume, List, Monitor, Sound } from "./interfaces"; + +export class PatchTargetThread { + // The text that makes up the generated code of the thread + script: string = ""; + + // The hat that starts the thread + triggerEventId: string = ""; + + // The (optional) option for the hat + triggerEventOption: string = ""; +} + +export type PatchScratchBlockInput = [number, string | (number | string)[]]; + +export interface PatchScratchBlock { + opcode: string; + + next?: string | null; + parent?: string | null; + + inputs: { + [key: string]: PatchScratchBlockInput; + }; + fields: { + [key: string]: BlockField; + }; + + shadow: boolean; + topLevel: boolean; + + x?: number; + y?: number; +} + +export interface PatchTarget { + isStage: boolean; + name: string; + variables: { + [key: string]: [string, string | number]; + }; + lists: { + [key: string]: List; + }; + broadcasts: { + [key: string]: string; + }; + blocks: { + [key: string]: PatchScratchBlock; + }; + comments: { + [key: string]: Comment; + }; + currentCostume: number; + costumes: Costume[]; + sounds: Sound[]; + volume: number; + layerOrder: number; + threads?: PatchTargetThread[]; +} + +export interface Stage extends PatchTarget { + isStage: true; + tempo: number; + videoTransparency: number; + videoState: "on" | "off"; + textToSpeechLanguage: TextToSpeechLanguage | null; +} + +export interface Sprite extends PatchTarget { + isStage: false; + visible: boolean; + x: number; + y: number; + size: number; + direction: number; + draggable: boolean; + rotationStyle: "all around" | "left-right" | "don't rotate"; +} + +export interface PatchScratchProjectJSON { + targets: PatchTarget[]; + monitors?: Monitor[]; +} diff --git a/src/io/patch/scratch-conversion-control.ts b/src/io/patch/scratch-conversion-control.ts new file mode 100644 index 0000000..63b0175 --- /dev/null +++ b/src/io/patch/scratch-conversion-control.ts @@ -0,0 +1,87 @@ +import { InputProcessorType, LineIndenterType, PartialConverterType } from "./conversion-layer"; +import { PatchTarget } from "./patch-interfaces"; + +export default function convertControlBlock( + target: PatchTarget, + currentBlockId: string, + processInputs: InputProcessorType, + indentLines: LineIndenterType, + convertBlocksPart: PartialConverterType +) { + const currentBlock = target.blocks[currentBlockId]; + const { opcode } = currentBlock; + + let script = ""; + + switch (opcode) { + case "control_forever": { + // Forever loop + let SUBSTACK = convertBlocksPart(target, currentBlockId, currentBlock.inputs.SUBSTACK[1] as string).script; + SUBSTACK = SUBSTACK.substring(0, SUBSTACK.length - 1); + // Add options to change this based on language later. + // TODO: change this to game loop if/when game loop is added + script += "\n"; + script += "while True:"; + script += indentLines(SUBSTACK); + break; + } + case "control_if": { + // If (but no else) statement + // 2 args: "CONDITION" and "SUBSTACK" + let CONDITION = convertBlocksPart(target, currentBlockId, currentBlock.inputs.CONDITION[1] as string).script; + CONDITION = CONDITION.substring(0, CONDITION.length - 1); + let SUBSTACK = convertBlocksPart(target, currentBlockId, currentBlock.inputs.SUBSTACK[1] as string).script; + SUBSTACK = SUBSTACK.substring(0, SUBSTACK.length - 1); + script += `if ${CONDITION}:`; + script += indentLines(SUBSTACK); + script += "\n"; + break; + } + case "control_if_else": { + // If + else statement + // 3 args: "CONDITION", "SUBSTACK", and "SUBSTACK2" + let CONDITION = convertBlocksPart(target, currentBlockId, currentBlock.inputs.CONDITION[1] as string).script; + CONDITION = CONDITION.substring(0, CONDITION.length - 1); + let SUBSTACK = convertBlocksPart(target, currentBlockId, currentBlock.inputs.SUBSTACK[1] as string).script; + SUBSTACK = SUBSTACK.substring(0, SUBSTACK.length - 1); + let SUBSTACK2 = convertBlocksPart(target, currentBlockId, currentBlock.inputs.SUBSTACK2[1] as string).script; + SUBSTACK2 = SUBSTACK2.substring(0, SUBSTACK2.length - 1); + script += `if ${CONDITION}:`; + script += indentLines(SUBSTACK); + script += "\nelse:"; + script += indentLines(SUBSTACK2); + script += "\n"; + break; + } + case "control_repeat": { + const { SUBSTACK } = processInputs(target, currentBlockId, false); + const { TIMES } = processInputs(target, currentBlockId, true, true); + script += `for _ in range(${TIMES}):`; + script += indentLines(SUBSTACK); + script += "\n"; + break; + } + case "control_wait_until": { + const { CONDITION } = processInputs(target, currentBlockId, false); + console.warn("WARN: the Wait Until block isn't supported in Patch, so a basic substitute will be used."); + script += `while True:`; + script += indentLines(`if ${CONDITION}:${indentLines(`break`)}`); + script += "\n"; + break; + } + case "control_repeat_until": { + const { SUBSTACK, CONDITION } = processInputs(target, currentBlockId, false); + console.warn("WARN: the Repeat Until block isn't supported in Patch, so a basic substitute will be used."); + script += `while True:`; + script += indentLines(`if ${CONDITION}:${indentLines(`break`)}\nelse:${indentLines(SUBSTACK)}`); + script += "\n"; + break; + } + default: { + console.warn("The control block conversion couldn't figure out how to handle opcode %s.", currentBlock.opcode); + break; + } + } + + return script; +} diff --git a/src/io/patch/scratch-conversion-data.ts b/src/io/patch/scratch-conversion-data.ts new file mode 100644 index 0000000..2ca7837 --- /dev/null +++ b/src/io/patch/scratch-conversion-data.ts @@ -0,0 +1,197 @@ +import { InputProcessorType } from "./conversion-layer"; +import { PatchTarget } from "./patch-interfaces"; + +function checkVariableName(target: PatchTarget, variable: string) { + let trimmed = variable.substring(1, variable.length - 1); + + const variableKeys = Object.keys(target.variables); + + for (let i = 0; i < variableKeys.length; i++) { + if (target.variables[variableKeys[i]][0] === trimmed) { + trimmed = `${target.name}_${trimmed}`; + + return trimmed; + } + } + + const listKeys = Object.keys(target.lists); + + for (let i = 0; i < listKeys.length; i++) { + if (target.lists[listKeys[i]][0] === trimmed) { + trimmed = `${target.name}_${trimmed}`; + + return trimmed; + } + } + + return trimmed; +} + +/** + * + * @param {*} target + * @param {string} blockId + * @param {*} processInputs + * @returns {string} + */ +export default function convertDataBlock( + target: PatchTarget, + currentBlockId: string, + processInputs: InputProcessorType +) { + const currentBlock = target.blocks[currentBlockId]; + const { opcode } = currentBlock; + + let script = ""; + + switch (opcode) { + case "data_setvariableto": { + // Set variable + const { VARIABLE } = processInputs(target, currentBlockId, false); + const { VALUE } = processInputs(target, currentBlockId, false, true); + const VARIABLE_TRIMMED = checkVariableName(target, VARIABLE); + // Add options to change this based on language later. + script += `${VARIABLE_TRIMMED} = ${VALUE}`; + break; + } + case "data_changevariableby": { + // Change variable by + const { VARIABLE } = processInputs(target, currentBlockId, false); + const { VALUE } = processInputs(target, currentBlockId, false, true); + const VARIABLE_TRIMMED = checkVariableName(target, VARIABLE); + // Add options to change this based on language later. + script += `${VARIABLE_TRIMMED} += ${VALUE}`; + break; + } + case "data_showvariable": { + // Display variable on screen + console.warn("WARN: the Show Variable block isn't supported in Patch."); + break; + } + case "data_hidevariable": { + // Stop displaying variable on screen + console.warn("WARN: the Hide Variable block isn't supported in Patch."); + break; + } + case "data_addtolist": { + // Append to list + const { LIST } = processInputs(target, currentBlockId, false); + const { ITEM } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}.append(${ITEM})`; + break; + } + case "data_deleteoflist": { + // Delete item at index from list + const { LIST } = processInputs(target, currentBlockId, false); + const { INDEX } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}.pop(${INDEX})`; + break; + } + case "data_deletealloflist": { + // Clear a list + const { LIST } = processInputs(target, currentBlockId, false); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}.clear()`; + break; + } + case "data_insertatlist": { + // Insert an item into the list + const { LIST } = processInputs(target, currentBlockId, false); + const { ITEM, INDEX } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}.insert(${INDEX}, ${ITEM})`; + break; + } + case "data_replaceitemoflist": { + // Replace a list item + const { LIST } = processInputs(target, currentBlockId, false); + const { ITEM, INDEX } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}[${INDEX}] = ${ITEM}`; + break; + } + case "data_itemoflist": { + // Get a list item + const { LIST } = processInputs(target, currentBlockId, false); + const { INDEX } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}[${INDEX}]`; + break; + } + case "data_itemnumoflist": { + // Get the index of an item in the list + const { LIST } = processInputs(target, currentBlockId, false); + const { ITEM } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${LIST_TRIMMED}.index(${ITEM})`; + break; + } + case "data_lengthoflist": { + // Get the length of the list + const { LIST } = processInputs(target, currentBlockId, false); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `len(${LIST_TRIMMED})`; + break; + } + case "data_listcontainsitem": { + // Check if the list contains a certain item + const { LIST } = processInputs(target, currentBlockId, false); + const { ITEM } = processInputs(target, currentBlockId, false, true); + const LIST_TRIMMED = checkVariableName(target, LIST); + // Add options to change this based on language later. + console.warn( + "WARN: using lists as variables isn't currently supported in Patch. Code will be generated but it may or may not function." + ); + script += `${ITEM} in ${LIST_TRIMMED}`; + break; + } + case "data_showlist": { + console.warn("WARN: the Show List block isn't supported in Patch."); + break; + } + case "data_hidelist": { + console.warn("WARN: the Hide List block isn't supported in Patch."); + break; + } + default: { + console.warn("The data block conversion couldn't figure out how to handle opcode %s.", currentBlock.opcode); + break; + } + } + + return script; +} diff --git a/src/io/patch/scratch-conversion-helper.ts b/src/io/patch/scratch-conversion-helper.ts new file mode 100644 index 0000000..55bd2d0 --- /dev/null +++ b/src/io/patch/scratch-conversion-helper.ts @@ -0,0 +1,274 @@ +import { PatchScratchBlockInput, PatchTarget } from "./patch-interfaces"; +import { PatchTargetThread } from "./patch-interfaces"; + +import patchApi from "./conversion-layer"; +import convertControlBlock from "./scratch-conversion-control"; +import convertOperatorBlock from "./scratch-conversion-operator"; +import convertDataBlock from "./scratch-conversion-data"; + +// 0: number, 1: string, 2: nested, -1: error +export function getArgType(inputJson: PatchScratchBlockInput) { + const argType = inputJson[1][0]; + // See here for meanings of the numbers: https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks + + if (inputJson[0] === 1) { + // TODO: check proper validation for argType === 8 (angle) + if (argType === 4 || argType === 5 || argType === 6 || argType === 7 || argType === 8) { + return 0; + } + // Type 11 has 2 strings after the 11; the first one is a name and the second one is an ID. + // We will just treat it as a regular string; the user-inputted name will end up being used + // and not the randomly generated id + if (argType === 9 || argType === 10 || argType === 11 || argType === 12 || argType === 13) { + return 1; + } + return 2; + } + if (inputJson[0] === 2 || inputJson[0] === 3) { + // Blocks + return 2; + } + + console.warn("Couldn't determine argument type."); + return -1; +} + +export function indentLines(lines: string) { + let newLines = ""; + + const lineList = lines.split("\n"); + // Make sure the lines have proper indentation + lineList.forEach(line => { + newLines += `\n ${line}`; + }); + + return newLines; +} + +/** + * + * @param {String} code + * @returns {Boolean} + */ +function needsParentheses(code: string) { + // First, check if code is just a string + if (code[0] === '"' && code[code.length - 1] === '"') { + // double quotes string + // yes, the for loop should start at 1 not 0 and it should go until 1 before the end + for (let i = 1; i < code.length - 1; i++) { + if (code[i] === '"' && code[i - 1] !== "\\") { + // this isn't just one continuous string + return true; + } + } + + return false; + } + if (code[0] === "'" && code[code.length - 1] === "'") { + // single quotes string + // yes, the for loop should start at 1 not 0 and it should go until 1 before the end + for (let i = 1; i < code.length - 1; i++) { + if (code[i] === "'" && code[i - 1] !== "\\") { + // this isn't just one continuous string + return true; + } + } + + return false; + } + + return true; +} + +export function processInputs( + target: PatchTarget, + currentBlockId: string, + autoParentheses = false, + tryMakeNum = false +) { + const returnVal: { + [key: string]: string; + } = {}; + + const currentBlock = target.blocks[currentBlockId]; + + const inputsKeys = Object.keys(currentBlock.inputs); + for (let i = 0; i < inputsKeys.length; i++) { + const inputsKey = inputsKeys[i]; + + let arg = ""; + + const argType = getArgType(currentBlock.inputs[inputsKey]); + if (argType === 0) { + arg = `${currentBlock.inputs[inputsKey][1][1]}`; + } else if (argType === 1) { + arg = `"${currentBlock.inputs[inputsKey][1][1]}"`; + } else if (argType === 2) { + // TODO: check this line + arg = convertBlocksPart(target, currentBlockId, currentBlock.inputs[inputsKey][1] as string).script; + arg = arg.substring(0, arg.length - 1); + if (autoParentheses && needsParentheses(arg)) { + arg = `(${arg})`; + } + } + + // eslint-disable-next-line no-restricted-globals + if (tryMakeNum && argType === 1 && arg.length >= 3 && !isNaN(Number.parseInt(arg.substring(1, arg.length - 1)))) { + arg = arg.substring(1, arg.length - 1); + } + + returnVal[inputsKey] = arg; + } + + const fieldsKeys = Object.keys(currentBlock.fields); + for (let i = 0; i < fieldsKeys.length; i++) { + const fieldsKey = fieldsKeys[i]; + + if (returnVal[fieldsKey]) { + console.warn( + "The parameter %s was found in both the fields and the inputs. Using the one in the fields.", + fieldsKey + ); + } + returnVal[fieldsKey] = `"${currentBlock.fields[fieldsKey][0]}"`; + } + + return returnVal; +} + +export function convertBlocksPart(target: PatchTarget, hatId: string, nextId: string) { + const thread = new PatchTargetThread(); + const { blocks } = target; + + thread.triggerEventId = blocks[hatId].opcode; + // TODO: triggerEventOption + const hatFieldsKeys = Object.keys(blocks[hatId].fields); + if (hatFieldsKeys && hatFieldsKeys.length > 0) { + if (blocks[hatId].opcode === "event_whenkeypressed") { + // eslint-disable-next-line prefer-destructuring + thread.triggerEventOption = blocks[hatId].fields[hatFieldsKeys[0]][0].toUpperCase(); + } else { + // eslint-disable-next-line prefer-destructuring + thread.triggerEventOption = blocks[hatId].fields[hatFieldsKeys[0]][0]; + } + } + + // Convert code + let currentBlockId = nextId; + while (currentBlockId) { + const currentBlock = blocks[currentBlockId]; + // Store a copy of the opcode so we don't have to keep doing currentBlock.opcode + const { opcode } = currentBlock; + + // TODO: figure out nested blocks + + const patchApiKeys = Object.keys(patchApi); + + // Convert the block + // Duplicates shouldn't exist in the translation API, but if they do the first entry will be used + let patchKey = null; + for (let i = 0; i < patchApiKeys.length; i++) { + const key = patchApiKeys[i]; + + if (patchApi[key].opcode === opcode) { + patchKey = key; + break; + } + } + + if (!patchKey) { + if (opcode.substring(0, 8) === "control_") { + const conversionResult = convertControlBlock( + target, + currentBlockId, + processInputs, + indentLines, + convertBlocksPart + ); + thread.script += `${conversionResult}\n`; + } else if (opcode.substring(0, 9) === "operator_") { + const conversionResult = convertOperatorBlock(target, currentBlockId, processInputs); + thread.script += `${conversionResult}\n`; + } else if (opcode.substring(0, 5) === "data_") { + const conversionResult = convertDataBlock(target, currentBlockId, processInputs); + thread.script += `${conversionResult}\n`; + } else { + // Couldn't find the opcode in the map. + console.error("Error translating from scratch to patch. Unable to find the key for the opcode %s.", opcode); + } + } else { + // const inputsKeys = Object.keys(currentBlock.inputs); + const detectedArgs = processInputs(target, currentBlockId, true, false); + + let patchCode = ""; + + const conversionLayerResult = patchApi[patchKey]; + if (conversionLayerResult.returnInstead) { + let patchArgs = ""; + for (let i = 0; i < conversionLayerResult.returnInstead.length; i++) { + const val = conversionLayerResult.returnInstead[i]; + + // Add options to change this based on language later. + if (patchArgs !== "") { + patchArgs += ", "; + } + + patchArgs += val; + } + + patchCode = `${patchArgs}\n`; + } else if (conversionLayerResult.returnParametersInstead) { + let patchArgs = ""; + for (let i = 0; i < conversionLayerResult.returnParametersInstead.length; i++) { + const parameter = conversionLayerResult.returnParametersInstead[i]; // .toUpperCase(); + + // Add options to change this based on language later. + if (patchArgs !== "") { + patchArgs += ", "; + } + + if (detectedArgs[parameter]) { + patchArgs += detectedArgs[parameter]; + } else { + console.warn("Couldn't find parameter with opcode %s.", parameter); + patchArgs += '"# Error: couldn\'t find the parameter to go here."'; + } + } + + patchCode = `${patchArgs}\n`; + } else { + let patchArgs = ""; + for (let i = 0; i < conversionLayerResult.parameters.length; i++) { + const parameter = conversionLayerResult.parameters[i]; // .toUpperCase(); + + // Add options to change this based on language later. + if (patchArgs !== "") { + patchArgs += ", "; + } + + if (detectedArgs[parameter]) { + patchArgs += detectedArgs[parameter]; + } else { + console.warn("Couldn't find parameter with opcode %s.", parameter); + patchArgs += '"# Error: couldn\'t find the parameter to go here."'; + } + } + + // Handle a special case: Patch implements the Ask block differently + if (currentBlock.opcode === "sensing_askandwait") { + patchKey = `_patchAnswer = ${patchKey}`; + } + + // Join all the bits and pieces together. Add options to change this based on language later. + patchCode = `${patchKey}(${patchArgs})\n`; + } + + thread.script += patchCode; + } + + // Next block + currentBlockId = currentBlock.next as string; + } + + return thread; +} diff --git a/src/io/patch/scratch-conversion-operator.ts b/src/io/patch/scratch-conversion-operator.ts new file mode 100644 index 0000000..c0dbc12 --- /dev/null +++ b/src/io/patch/scratch-conversion-operator.ts @@ -0,0 +1,162 @@ +import { InputProcessorType } from "./conversion-layer"; +import { PatchTarget } from "./patch-interfaces"; + +/** + * + * @param {Target} target + * @param {string} currentBlockId + * @param {InputProcessorType} processinputs + * @returns {string} + */ +export default function convertOperatorBlock( + target: PatchTarget, + currentBlockId: string, + processInputs: InputProcessorType +) { + const currentBlock = target.blocks[currentBlockId]; + const { opcode } = currentBlock; + + let script = ""; + + switch (opcode) { + case "operator_add": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); + + script += `${NUM1} + ${NUM2}`; + break; + } + case "operator_subtract": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); + + script += `${NUM1} - ${NUM2}`; + break; + } + case "operator_multiply": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); + + script += `${NUM1} * ${NUM2}`; + break; + } + case "operator_divide": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); + + script += `${NUM1} / ${NUM2}`; + break; + } + case "operator_lt": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); + + script += `${OPERAND1} < ${OPERAND2}`; + break; + } + case "operator_equals": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); + + script += `${OPERAND1} == ${OPERAND2}`; + break; + } + case "operator_gt": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); + + script += `${OPERAND1} > ${OPERAND2}`; + break; + } + case "operator_and": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); + + script += `${OPERAND1} and ${OPERAND2}`; + break; + } + case "operator_or": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); + + script += `${OPERAND1} or ${OPERAND2}`; + break; + } + case "operator_not": { + const { OPERAND } = processInputs(target, currentBlockId, true, true); + + script += `not ${OPERAND}`; + break; + } + case "operator_random": { + const { FROM, TO } = processInputs(target, currentBlockId, true, true); + + script += `patch_random(${FROM}, ${TO})`; + break; + } + case "operator_join": { + const { STRING1, STRING2 } = processInputs(target, currentBlockId, true, false); + + // TODO: is there a more pythonic way to implement this? + script += `${STRING1} + ${STRING2}`; + break; + } + case "operator_letter_of": { + const { STRING } = processInputs(target, currentBlockId, true, false); + const { LETTER } = processInputs(target, currentBlockId, true, true); + + script += `${STRING}[${parseInt(LETTER) - 1}]`; + break; + } + case "operator_length": { + const { STRING } = processInputs(target, currentBlockId, true, false); + + script += `len(${STRING})`; + break; + } + case "operator_contains": { + const { STRING1, STRING2 } = processInputs(target, currentBlockId, true, false); + + script += `${STRING2} in ${STRING1}`; + break; + } + case "operator_mod": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); + + script += `${NUM1} % ${NUM2}`; + break; + } + case "operator_round": { + const { NUM } = processInputs(target, currentBlockId, true); + + script += `round(${NUM})`; + break; + } + case "operator_mathop": { + const { OPERATOR } = processInputs(target, currentBlockId, true); + const { NUM } = processInputs(target, currentBlockId, true, true); + + // Remove the quotation marks that processInputs adds + const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); + + const mathOpsDict: { [key: string]: string } = { + abs: `abs(${NUM})`, + ceiling: `math.ceil(${NUM})`, + sqrt: `math.sqrt(${NUM})`, + floor: `math.floor(${NUM})`, + /* Trig in scratch uses degrees. To keep this consistent, we must convert the inputs of + trig (but not inverse trig) */ + sin: `math.sin(math.radians(${NUM}))`, + cos: `math.cos(math.radians(${NUM}))`, + tan: `math.tan(math.radians(${NUM}))`, + asin: `math.degrees(math.asin(${NUM}))`, + acos: `math.degrees(math.acos(${NUM}))`, + atan: `math.degrees(math.atan(${NUM}))`, + /* in Python, math.log defaults to base e, not base 10 */ + ln: `math.log(${NUM})`, + log: `math.log(${NUM}, 10)`, + "e ^": `pow(math.e, ${NUM})` /* `math.exp(${ NUM })`, */, + "10 ^": `pow(10, ${NUM})` + }; + + script += mathOpsDict[formattedOperator]; + break; + } + default: { + break; + } + } + + return script; +} diff --git a/src/io/patch/scratch-conversion.ts b/src/io/patch/scratch-conversion.ts new file mode 100644 index 0000000..0f825cc --- /dev/null +++ b/src/io/patch/scratch-conversion.ts @@ -0,0 +1,153 @@ +import { convertBlocksPart } from "./scratch-conversion-helper"; +import { PatchScratchProjectJSON, PatchTarget, PatchTargetThread } from "./patch-interfaces"; + +const EventHats = { + event_whenflagclicked: { + label: "When Flag Clicked", + restartExistingThreads: true + }, + event_whenkeypressed: { + label: "When Key Pressed", + restartExistingThreads: false + }, + event_whenthisspriteclicked: { + label: "When This Sprite Clicked", + restartExistingThreads: true + }, + event_whentouchingobject: { + label: "When Touching", + restartExistingThreads: false, + edgeActivated: true + }, + event_whenstageclicked: { + label: "When Stage Clicked", + restartExistingThreads: true + }, + event_whenbackdropswitchesto: { + label: "When Backdrop Switches To", + restartExistingThreads: true + }, + event_whengreaterthan: { + label: "When Greater Than", + restartExistingThreads: false, + edgeActivated: true + }, + event_whenbroadcastreceived: { + label: "When Broadcast Received", + restartExistingThreads: true + } +}; + +const ControlHats = { + control_start_as_clone: { + restartExistingThreads: false, + label: "When I Start As Clone" + } +}; + +export default class ScratchConverter { + data: string = ""; + + /** + * + * @param {String} scratchData An ArrayBuffer representation of the .sb3 file to convert + */ + constructor(scratchData: string) { + this.data = scratchData; + } + + getPatchProjectJson() { + const vmState = JSON.parse(this.data) as PatchScratchProjectJSON; + + const globalVariables: { name: string; value: string | number }[] = []; + + // This function will convert each target's blocks and local variables into Patch code. + // Then, it will remove the blocks from the JSON (not strictly necessary) and handle backgrounds and other + // things that Patch and Scratch store differently. Also, everything will be moved to being a child of a json + // object called "vmstate" that exists for some reason. + // TODO: add more validation of scratch project + + // Step 1: blocks + variables to code; then add code + for (let i = 0; i < vmState.targets.length; i++) { + vmState.targets[i].threads = this.convertTargetBlocks(vmState.targets[i]); + } + + // Step 2: remove blocks (this isn't strictly necessary) and variables + broadcasts (this is necessary) + // Get rid of the variables removing part once sprite-wide variables are a thing. Keep the broadcasts + // remover however. + for (let i = 0; i < vmState.targets.length; i++) { + vmState.targets[i].blocks = {}; + const variableKeys = Object.keys(vmState.targets[i].variables); + variableKeys.forEach(key => { + const variable = vmState.targets[i].variables[key]; + if (vmState.targets[i].isStage) { + // In Scratch, global variables are actually stored as sprite variables on the stage. + globalVariables.push({ name: variable[0], value: variable[1] }); + } else { + globalVariables.push({ name: `${vmState.targets[i].name}_${variable[0]}`, value: variable[1] }); + } + }); + vmState.targets[i].variables = {}; + vmState.targets[i].lists = {}; + vmState.targets[i].broadcasts = {}; + } + + // Step 3: some odd jobs + // TODO: implement these + + // Remove monitors as Patch doesn't support them + vmState.monitors = []; + + // Step 4: make everything a child of "vmstate" and add global variables + // TODO: global variables + const baseJson = { vmstate: vmState, globalVariables: globalVariables }; + + return JSON.stringify(baseJson); + } + + /** + * Converts an object representation of a Scratch target's blocks into an object + * representation of the corresponding Patch threads and thread code. + * + * @param {PatchTarget} target + * @returns {PatchTargetThread[]} An array of object representations of the patch threads + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + convertTargetBlocks(target: PatchTarget) { + // TODO: convert variables + // https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks + + const { blocks } = target; + + const blocksKeys = Object.keys(blocks); + + const returnVal: PatchTargetThread[] = []; + + const hats = Object.keys({ ...EventHats, ...ControlHats }); + + const hatLocations: string[] = []; + + blocksKeys.forEach(blockId => { + const block = blocks[blockId]; + if (hats.includes(block.opcode)) { + hatLocations.push(blockId); + } + }); + + hatLocations.forEach(hatId => { + const returnValPart = convertBlocksPart(target, hatId, blocks[hatId].next as string); + + if (returnValPart.script.includes("math.")) { + returnValPart.script = `import math\n\n${returnValPart.script}`; + } + + if (returnValPart.script.includes("patch_random(")) { + returnValPart.script = `import random\n\n# This mimics the behavior of Scratch's random block\ndef patch_random(num1, num2):\n if ((num1 % 1) == 0) and ((num2 % 1) == 0):\n return random.randint(num1, num2)\n else:\n return round(random.uniform(num1, num2), 2)\n\n${returnValPart.script}`; + } + + returnVal.push(returnValPart); + }); + + return returnVal; + } +} diff --git a/src/io/patch/toPatch.ts b/src/io/patch/toPatch.ts new file mode 100644 index 0000000..a2be467 --- /dev/null +++ b/src/io/patch/toPatch.ts @@ -0,0 +1,1050 @@ +import Block, { BlockBase } from "../../Block"; +import Project, { TextToSpeechLanguage } from "../../Project"; +import Target, { Sprite, Stage } from "../../Target"; +import * as BlockInput from "../../BlockInput"; +import * as sb3 from "./interfaces"; +import { OpCode } from "../../OpCode"; +import ScratchConverter from "./scratch-conversion"; + +const BIS = sb3.BlockInputStatus; + +export interface ToPatchOptions { + warn: (message: string) => void; +} + +export interface ToPatchOutput { + json: string; +} + +export default function toPatch(project: Project, options: Partial = {}): ToPatchOutput { + // Serialize a project. Returns an object containing the text to be stored + // in the caller's project.json output file. toSb3 should be bound or applied + // so that 'this' refers to the Project object to be serialized. + + let warn: ToPatchOptions["warn"] = (): void => undefined; + if (options.warn) { + warn = options.warn; + } + + function serializeInputsToFields( + inputs: { [key: string]: BlockInput.Any }, + fieldEntries: (typeof sb3.fieldTypeMap)[OpCode] + ): sb3.Block["fields"] { + // Serialize provided inputs into a "fields" mapping that can be stored + // on a serialized block. + // + // Where the Scratch 3.0 term "input" refers to a slot that accepts blocks, + // the term "field" is any user-interactive slot that cannot be obscured as + // such. Fields are also the interactive element within any shadow block, + // making their presence key to nearly all inputs. + // + // While inputs are fairly complex to serialize, fields are comparatively + // simple. A field always contains only its selected or entered value, so + // it's not concerned with a variety of structures like an input is. + // The format for a field mapping looks something like this: + // + // { + // VARIABLE: ["my variable", "theVariablesId"] + // } + // + // Some fields take an ID; some don't. Here's another example: + // + // { + // DISTANCETOMENU: ["_mouse_"] + // } + // + // The important thing to remember with fields during serialization is that + // they refer to slots that don't accept blocks: non-droppable menus, + // primarily, but the value in a (non-compressed) shadow input too. + + const fields: sb3.Block["fields"] = {}; + + if (!fieldEntries) { + return fields; + } + + for (const key of Object.keys(fieldEntries)) { + // TODO: remove type assertion + const input = inputs[key] as BlockInput.FieldAny | BlockInput.Variable | BlockInput.List; + // Fields are stored as a plain [value, id?] pair. + let valueOrName; + let id: string | null; + switch (input.type) { + case "variable": + case "list": + valueOrName = input.value.name; + id = input.value.id; + break; + default: + valueOrName = input.value; + id = null; + break; + } + fields[key] = [valueOrName, id]; + } + + return fields; + } + + interface SerializeInputShadowOptions { + blockData: sb3.Target["blocks"]; + + parentId: string; + primitiveOrOpCode: number | OpCode; + shadowId: string; + } + + function serializeInputShadow( + value: Readonly, + options: SerializeInputShadowOptions + ): sb3.BlockInputValue | null { + // Serialize the shadow block representing a provided value and type. + // + // To gather an understanding of what shadow blocks are used for, have + // a look at serializeInputsToInputs; the gist is that they represent the + // actual place in which you type a value or select an option from a + // dropdown menu, and they can be obscured by having a block placed in + // their place. + // + // A shadow block's only concerns are with revealing an interactive field + // to the user. They exist so that inputs can let a non-shadow block stand + // in place of that field. + // + // There are two forms in which a shadow block can be serialized. The rarer + // structure, though the more basic one, is simply that of a typical block, + // only with the "shadow: true" flag set. A shadow block contains a single + // field; this field is stored the same as in any non-shadow block. Such a + // serialized shadow block might look like this: + // + // { + // opcode: "math_number", + // shadow: true, + // parent: "someBlockId", + // fields: { + // NUM: [50] + // } + // } + // + // The second form contains essentially the same data, but in a more + // "compressed" form. In this form, the shadow block is stored as a simple + // [type, value] pair, where the type is a constant representing the opcode + // and field name in which the value should be placed when deserializing. + // Because it's so much more concise than the non-compressed form, most + // inputs are serialized in this way. The same input above in compressed + // form would look like this: + // + // [4, 50] + // + // As described in serializeInputsToInputs, compressed shadow blocks are + // stored inline with the input they correspond to, not as separate blocks + // with IDs. + // + // Within this code, we use the primimtiveOrOpCode option to determine how + // the shadow should be serialized. If it is a number, it's referring to a + // "primitive", the term uses for shadows when they are in the compressed + // form. If it's a string, it is a (shadow) block opcode, and should be + // serialized in the expanded form. + + const { blockData, parentId, primitiveOrOpCode, shadowId } = options; + + let shadowValue = null; + + if (primitiveOrOpCode === BIS.BROADCAST_PRIMITIVE) { + // Broadcast primitives, unlike all other primitives, expect two values: + // the broadcast name and its ID. We just reuse the name for its ID; + // after all, the name is the unique identifier sb-edit uses to refer to + // the broadcast. + shadowValue = [BIS.BROADCAST_PRIMITIVE, value as string, value as string] as const; + } else if (primitiveOrOpCode === BIS.COLOR_PICKER_PRIMITIVE) { + // Color primitive. Convert the {r, g, b} object into hex form. + // TODO: remove type assertion and actually check if the value is an RGB literal + const hex = (k: "r" | "g" | "b"): string => + ((value as BlockInput.Color["value"]) || { r: 0, g: 0, b: 0 })[k].toString(16).padStart(2, "0"); + shadowValue = [BIS.COLOR_PICKER_PRIMITIVE, "#" + hex("r") + hex("g") + hex("b")] as const; + } else if (typeof primitiveOrOpCode === "number") { + // Primitive shadow, can be stored in compressed form. + shadowValue = [primitiveOrOpCode, String(value)] as const; + } else { + // Note: Only 1-field shadow blocks are supported. + const shadowOpCode = primitiveOrOpCode; + const fieldEntries = sb3.fieldTypeMap[shadowOpCode]; + if (fieldEntries) { + const fieldKey = Object.keys(fieldEntries)[0]; + const fields = { [fieldKey]: [value as string] as const }; + + blockData[shadowId] = { + opcode: shadowOpCode, + + next: null, + parent: parentId, + + fields, + inputs: {}, + + mutation: undefined, + + shadow: true, + topLevel: false + }; + + shadowValue = shadowId; + } + } + + return shadowValue; + } + + interface SerializeInputsToInputsOptions { + target: Target; + + blockData: sb3.Target["blocks"]; + + initialBroadcastName: string; + customBlockDataMap: CustomBlockDataMap; + + block: Block; + inputEntries: { + [fieldName: string]: number | OpCode; + }; + + // Only used when an input is obscured; this will be used for the default + // value in the input's shadow (i.e. value when the contained reproter is + // pulled out). + initialValues: { + [key in keyof PassedInputs]: Readonly; + }; + } + + function serializeInputsToInputs( + inputs: PassedInputs, + options: SerializeInputsToInputsOptions + ): sb3.Block["inputs"] { + // Serialize provided inputs into an "inputs" mapping that can be stored + // on a serialized block. + // + // In any Scratch block, the majority of behavior configuration is provided + // by setting values for fields and inputs. In Scratch 3.0, the term + // "input" refers to any slot in a block where a block may be placed. + // (Fields are slots which don't accept blocks - non-droppable dropdown + // menus, most of the time.) + // + // During serialization, there are three fundamental ways to describe an + // input, each associated with a particular constant numeral. They're all + // based on the concept of a "shadow block", which is a representation of + // the non-block contents of an input. They're described in the following + // list, and are detailed further in serializeInputShadow. + // + // (1) INPUT_SAME_BLOCK_SHADOW: + // The input contains only a shadow block - no non-shadow. Take the + // number input (612), for example. The element you type in to change + // that value is the shadow block. Because there is nothing obscuring + // the shadow block, the input is serialized as INPUT_SAME_BLOCK_SHADOW. + // (2) INPUT_BLOCK_NO_SHADOW: + // The input contains a non-shadow block - but no shadow. These are + // relatively rare, since most inputs contain a shadow block (even when + // obscured - see (3) below). Examples of inputs that don't are boolean + // and substack slots, both prominent in blocks in the Control category. + // (3) INPUT_DIFF_BLOCK_SHADOW: + // The input contains a non-shadow block - and a shadow block, too. + // This is the case when you've placed an ordinary Scratch block into the + // input, obscuring the shadow block in its place. It's worth noting that + // Scratch 3.0 remembers the type and value of an obscured shadow: if you + // place a block ((4) * (13)) into that number input (612), and later + // remove it, Scratch will reveal the (612) shadow again. + // + // There is one other way an input may be serialized, which is simply not + // storing it at all. This occurs when an input contains neither a shadow + // nor a non-shadow, as in INPUT_BLOCK_NO_SHADOW (2) but with the block + // removed. (It's technically also valid to output null as the ID of the + // block inside an INPUT_BLOCK_NO_SHADOW to represent such inputs. In this + // code we choose not to store them at all.) + // + // When all is said and done, a block's inputs are stored as a mapping of + // each input's ID to an array whose first item is one of the constants + // described above, and whose following items depend on which constant. + // For the block "go to x: (50) y: ((x position) of (dog))", that mapping + // might look something like this: + // + // inputs: { + // X: [INPUT_SAME_BLOCK_SHADOW, [4, 50]], + // Y: [INPUT_DIFF_BLOCK_SHADOW, "some block id", [4, -50]] + // } + // + // The arrays [4, 50] and [4, -50] represent the two shadow blocks these + // inputs hold (the latter obscured by the "of" block). The value 4 is a + // constant referring to a "math_number" - essentially, it is the type of + // the shadow contained within that input. + // + // It's worth noting that some shadows are serialized as + // actual blocks on the target's "blocks" dictionary, and referred to by + // ID; the inputs in "switch to costume (item (random) of (costumes))" + // follow this structure: + // + // inputs: { + // COSTUME: [INPUT_DIFF_BLOCK_SHADOW, "id 1", "id 2"] + // } + // + // ...where id 2 is the ID of the obscured shadow block. Specific details + // on how shadow blocks are serialized and whether they're stored as + // arrays or referenced by block ID is described in serializeInputShadow. + // As far as the input mapping is concerned, all that matters is that the + // two formats may be interchanged with one another. + // + // Also note that there are a couple blocks (specifically the variable and + // list-contents getters) which are serialized altogether in the compressed + // much the same as a compressed shadow, irregardless of the type of the + // input they've been placed inside. This is because they're so common in + // a project, and do not have any inputs of their own - only a field to + // identify which variable or list the block corresponds to. The input + // mapping for the block "set x to (spawn x)" would look something like + // this: + // + // inputs: { + // X: [INPUT_DIFF_BLOCK_SHADOW, [12, "spawn x", "someId"], [4, 0]] + // } + // + // ...where someId is the ID of the variable, and [4, 0] is the obscured + // shadow block, as usual. + + const { block, blockData, initialBroadcastName, customBlockDataMap, initialValues, inputEntries, target } = options; + + const resultInputs: sb3.Block["inputs"] = {}; + + for (const [key, entry] of Object.entries(inputEntries)) { + const input = inputs[key]; + if (entry === sb3.BooleanOrSubstackInputStatus) { + let blockId: string | null = null; + + if (input) { + const options = { + target, + blockData, + initialBroadcastName, + customBlockDataMap, + parent: block + }; + + switch (input.type) { + case "blocks": + if (input.value !== null) blockId = serializeBlockStack(input.value, options); + break; + case "block": + blockId = serializeBlock(input.value, options); + break; + } + } + + if (blockId) { + resultInputs[key] = [BIS.INPUT_BLOCK_NO_SHADOW, blockId]; + } + } else { + let valueForShadow; + if (input.type === "block") { + valueForShadow = initialValues[key]; + // Special-case some input opcodes for more realistic initial values. + switch (entry) { + case OpCode.looks_costume: + if (target.costumes[0]) { + valueForShadow = target.costumes[0].name; + } + break; + case OpCode.sound_sounds_menu: + if (target.sounds[0]) { + valueForShadow = target.sounds[0].name; + } + break; + case OpCode.event_broadcast_menu: + valueForShadow = initialBroadcastName; + break; + } + } else { + valueForShadow = input.value; + } + + const shadowValue = serializeInputShadow(valueForShadow, { + blockData, + parentId: block.id, + shadowId: block.id + "-" + key, + primitiveOrOpCode: entry + }); + + if (input.type === "block") { + let obscuringBlockValue; + + switch (input.value.opcode) { + case OpCode.data_variable: { + const { id: variableId, name: variableName } = input.value.inputs.VARIABLE.value; + obscuringBlockValue = [BIS.VAR_PRIMITIVE, variableName, variableId] as const; + break; + } + case OpCode.data_listcontents: { + const { id: listId, name: listName } = input.value.inputs.LIST.value; + obscuringBlockValue = [BIS.LIST_PRIMITIVE, listName, listId] as const; + break; + } + default: { + obscuringBlockValue = serializeBlock(input.value, { + blockData, + initialBroadcastName, + customBlockDataMap, + parent: block, + target + }); + break; + } + } + + if (shadowValue) { + resultInputs[key] = [BIS.INPUT_DIFF_BLOCK_SHADOW, obscuringBlockValue, shadowValue]; + } else { + resultInputs[key] = [BIS.INPUT_BLOCK_NO_SHADOW, obscuringBlockValue]; + } + } else { + resultInputs[key] = [BIS.INPUT_SAME_BLOCK_SHADOW, shadowValue]; + } + } + } + + return resultInputs; + } + + interface SerializeInputsOptions { + target: Target; + + blockData: sb3.Target["blocks"]; + + initialBroadcastName: string; + customBlockDataMap: CustomBlockDataMap; + } + + function serializeInputs( + block: Block, + options: SerializeInputsOptions + ): { + inputs: sb3.Block["inputs"]; + fields: sb3.Block["fields"]; + mutation?: sb3.Block["mutation"]; + } | null { + // Serialize a block's inputs, returning the data which should be stored on + // the serialized block, as well as any associated blockData. + // + // This function looks more intimidating than it ought to; most of the meat + // here is related to serializing specific blocks whose resultant data must + // be generated differently than other blocks. (Custom blocks are related + // the main ones to blame.) + // + // serializeInputs is in charge of converting the inputs on the provided + // block into the structures that Scratch 3.0 expects. There are (usually) + // two mappings into which inputs are stored: fields and inputs. Specific + // details on how these are serialized is discussed in their corresponding + // functions (which serializeInputs defers to for most blocks), but the + // gist is: + // + // * Fields store actual data, while inputs refer to shadow blocks. + // (Each shadow block contains a field for storing the value of that + // input, though often they are serialized as a "compressed" form that + // doesn't explicitly label that field. See serializeInputsToInputs.) + // * Reporter blocks can be placed only into inputs - not fields. + // (In actuality, the input is not replaced by a block; rather, the way + // it is stored is changed to refer to the ID of the placed block, and + // the shadow block contianing the field value is maintained, "obscured" + // but able to be recovered if the obscuring block is moved elsewhere.) + // + // Blocks may also have a "mutation" field. This is an XML attribute + // mapping containing data specific to a particular instance of a block + // that wouldn't fit on the block's input and field mappings. Specific + // details may vary greatly based on the opcode. + + const { blockData, target, initialBroadcastName, customBlockDataMap } = options; + + const fields = serializeInputsToFields(block.inputs, sb3.fieldTypeMap[block.opcode]); + + let inputs: sb3.Block["inputs"] = {}; + let mutation: sb3.Block["mutation"]; + + if (block.isKnownBlock()) { + switch (block.opcode) { + case OpCode.procedures_definition: { + const prototypeId = block.id + "-prototype"; + + const { args, warp } = customBlockDataMap[block.inputs.PROCCODE.value]; + + const prototypeInputs: sb3.Block["inputs"] = {}; + for (const arg of args) { + const shadowId = arg.id + "-prototype-shadow"; + blockData[shadowId] = { + opcode: { + boolean: OpCode.argument_reporter_boolean, + numberOrString: OpCode.argument_reporter_string_number + }[arg.type], + + next: null, + parent: prototypeId, + + inputs: {}, + fields: { + VALUE: [arg.name] + }, + + mutation: undefined, + + shadow: true, + topLevel: false + }; + + prototypeInputs[arg.id] = [BIS.INPUT_SAME_BLOCK_SHADOW, shadowId]; + } + + blockData[prototypeId] = { + opcode: OpCode.procedures_prototype, + + next: null, + parent: block.id, + + inputs: prototypeInputs, + fields: {}, + + shadow: true, + topLevel: false, + + mutation: { + tagName: "mutation", + children: [], + proccode: block.inputs.PROCCODE.value, + argumentids: JSON.stringify(args.map(arg => arg.id)), + argumentnames: JSON.stringify(args.map(arg => arg.name)), + argumentdefaults: JSON.stringify(args.map(arg => arg.default)), + warp: JSON.stringify(warp) as "true" | "false" + } + }; + + inputs.custom_block = [BIS.INPUT_SAME_BLOCK_SHADOW, prototypeId]; + + break; + } + + case OpCode.procedures_call: { + const proccode = block.inputs.PROCCODE.value; + const customBlockData = customBlockDataMap[proccode]; + if (!customBlockData) { + warn( + `Missing custom block prototype for proccode ${proccode} (${block.id} in ${target.name}); skipping this block` + ); + return null; + } + + const { args, warp } = customBlockData; + + mutation = { + tagName: "mutation", + children: [], + proccode, + argumentids: JSON.stringify(args.map(arg => arg.id)), + warp: JSON.stringify(warp) as "true" | "false" + }; + + const inputEntries: Record = {}; + const constructedInputs: Record = {}; + const initialValues: Record = {}; + for (let i = 0; i < args.length; i++) { + const { type, id } = args[i]; + switch (type) { + case "boolean": + inputEntries[id] = sb3.BooleanOrSubstackInputStatus; + // A boolean input's initialValues entry will never be + // referenced (because empty boolean inputs don't contain + // shadow blocks), so there's no need to set it. + break; + case "numberOrString": + inputEntries[id] = BIS.TEXT_PRIMITIVE; + initialValues[id] = ""; + break; + } + constructedInputs[id] = block.inputs.INPUTS.value[i]; + } + + inputs = serializeInputsToInputs(constructedInputs, { + target, + + blockData, + + initialBroadcastName, + customBlockDataMap, + + block, + initialValues, + inputEntries + }); + + break; + } + + default: { + const inputEntries = sb3.inputPrimitiveOrShadowMap[block.opcode]; + + const initialValues: Record> = {}; + for (const key of Object.keys(inputEntries)) { + const defaultInput = BlockBase.getDefaultInput(block.opcode, key); + if (defaultInput) { + initialValues[key] = defaultInput.initial; + } + } + + inputs = serializeInputsToInputs(block.inputs, { + target, + + blockData, + + initialBroadcastName, + customBlockDataMap, + + block, + initialValues, + inputEntries + }); + + break; + } + } + } + + return { inputs, fields, mutation }; + } + + interface SerializeBlockOptions { + target: Target; + + blockData: sb3.Target["blocks"]; + + initialBroadcastName: string; + customBlockDataMap: CustomBlockDataMap; + + parent?: Block; + x?: number; + y?: number; + } + + function serializeBlock(block: Block, options: SerializeBlockOptions): string | null { + // Serialize a block, mutating the passed block data and returning the + // ID which should be used when referring to this block, or null if no + // such block could be serialized. + // + // As discussed in serializeTarget, blocks are serialized into a single + // flat dictionary (per target), rather than an abstract syntax tree. + // Within a serialized block, it's common to find reference to another + // block by its ID. This is seen in linking to the next and parent blocks, + // and to inputs. + // + // In Scratch 3.0 (contrasting with 2.0 as well as the intermediate format + // created for sb-edit), "inputs" are stored in not one but two containers + // per block: inputs, and fields. The difference is discussed in their + // corresponding functions. Blocks may also carry a mutation, a mapping of + // XML property names and values, for use in some blocks (notably those + // associated with custom blocks). All this data is serialized and detailed + // in serializeInputs. + // + // serializeBlock is in charge of serializing an individual block, as well + // as its following block, and building the links between it and its parent + // and siblings. As with other block-related functions, data is collected + // into a flat mapping of IDs to their associated serialized block. + // + // It's possible for a block to be skipped altogether during serialization, + // because it referred to some value which could not be converted into + // valid SB3 data. For reporters, this means leaving an empty input; for + // stack blocks, it means skipping to the next block in the sibling array + // (or leaving an empty connection if there is none). It's up to the caller + // to handle serializeBlock returning a null blockId usefully. + // + // Note that while serializeBlock will recursively serialize input blocks, + // it will not serialize the following sibling block. As such, the + // serialized block will always contain {next: null}. The caller is + // responsible for updating this and setting it to the following block ID. + // (The function serializeBlockStack is generally where this happens.) + + const { blockData, initialBroadcastName, customBlockDataMap, parent, target } = options; + + const serializeInputsResult = serializeInputs(block, { + target, + + blockData, + + initialBroadcastName, + customBlockDataMap + }); + + if (!serializeInputsResult) { + return null; + } + + const { inputs, fields, mutation } = serializeInputsResult; + + const obj: sb3.Block = { + opcode: block.opcode, + + parent: parent ? parent.id : null, + next: null, // The caller is responsible for setting this. + topLevel: !parent, + + inputs, + fields, + mutation, + + shadow: false + }; + + if (obj.topLevel) { + obj.x = options.x; + obj.y = options.y; + } + + const blockId = block.id; + + blockData[blockId] = obj; + + return blockId; + } + + // Since serializeBlockStack passes the actual serialization behavior to + // serializeBlock, the two functions share the same options type. + type SerializeBlockStackOptions = SerializeBlockOptions; + + function serializeBlockStack(blocks: Block[], options: SerializeBlockStackOptions): string | null { + // Serialize a stack of blocks, returning the ID of the first successfully + // serialized block, or null if there is none. + // + // When serializing a block returns null, there is an expectation that the + // block should be "skipped" by the caller. When dealing with stack blocks, + // that means making a connection between the previous block and the first + // successfully successfully serialized following block. This function + // handles that case, as well as building the connections between stack + // blocks in general. + // + // Note that the passed options object will be mutated, to change the + // parent block to the previous block in the stack. + + const { blockData } = options; + + let previousBlockId: string | null = null; + let firstBlockId: string | null = null; + + for (const block of blocks) { + const blockId = serializeBlock(block, options); + + if (!blockId) { + continue; + } + + if (!firstBlockId) { + firstBlockId = blockId; + } + + if (previousBlockId) { + blockData[previousBlockId].next = blockId; + } + + previousBlockId = blockId; + options.parent = block; + } + + return firstBlockId; + } + + interface CustomBlockArg { + default: string; + id: string; + name: string; + type: "boolean" | "numberOrString"; + } + + interface CustomBlockData { + args: CustomBlockArg[]; + warp: boolean; + } + + interface CustomBlockDataMap { + [proccode: string]: CustomBlockData; + } + + function collectCustomBlockData(target: Target): CustomBlockDataMap { + // Parse the scripts in a target, collecting metadata about each custom + // block's arguments and other info, and return a mapping of proccode to + // the associated data. + // + // It's necesary to collect this data prior to serializing any associated + // procedures_call blocks, because they require access to data only found + // on the associated procedures_definition. (Specifically, the types of + // each input on the custom block, since those will influence the initial + // value & shadow type in the serialized caller block's inputs.) + + const data: CustomBlockDataMap = {}; + + for (const script of target.scripts) { + const block = script.blocks[0]; + if (block.opcode !== OpCode.procedures_definition) { + continue; + } + + const proccode = block.inputs.PROCCODE.value; + const warp = block.inputs.WARP.value; + + const args: CustomBlockArg[] = []; + + const argData = block.inputs.ARGUMENTS.value; + for (let i = 0; i < argData.length; i++) { + const { name, type } = argData[i]; + + if (type === "label") { + continue; + } + + const id = `${block.id}-argument-${i}`; + + args.push({ + id, + name, + type, + default: { + boolean: "false", + numberOrString: "" + }[type] + }); + } + + data[proccode] = { args, warp }; + } + + return data; + } + + interface SerializeTargetOptions { + initialBroadcastName: string; + + broadcasts: sb3.Sprite["broadcasts"]; + } + + function serializeTarget(target: Target, options: SerializeTargetOptions): sb3.Target { + // Serialize a target. This function typically isn't used on its own, in + // favor of the specialized functions for sprites and stage. It contains + // the base code shared across all targets - sounds and costumes, variables + // and lists, and, of course, blocks, for example. + // + // In Scratch 3.0, the representation for the code in a sprite is a flat, + // one-dimensional mapping of block ID to block data. To identify which + // blocks are the first block in a "script", a topLevel flag is used. + // This differs considerably from 2.0, where the scripts property of any + // target contained an AST (abstract syntax tree) representation. + // + // When a block is serialized, a flat block mapping is returned, and this + // is combined into the mapping of whatever is consuming the serialized + // data. Eventually, all blocks (and their subblocks, inputs, etc) have + // been serialized, and the collected data is stored on the target. + // + // serializeTarget also handles converting costumes, sounds, variables, + // etc into the structures Scratch 3.0 expects. + + function mapToIdObject( + values: Entry[], + fn: (x: Entry) => ReturnType + ): { [key: string]: ReturnType } { + // Map an Array of objects with an "id` property + // (e.g [{id: 1, prop: "val"}, ...]) + // into an object whose keys are the `id` property, + // and whose values are the passed objects transformed by `fn`. + const ret: Record = {}; + for (const object of values) { + ret[object.id] = fn(object); + } + return ret; + } + + const { broadcasts, initialBroadcastName } = options; + + const blockData: sb3.Target["blocks"] = {}; + + const customBlockDataMap = collectCustomBlockData(target); + + for (const script of target.scripts) { + serializeBlockStack(script.blocks, { + target, + blockData, + initialBroadcastName, + customBlockDataMap, + x: script.x, + y: script.y + }); + } + + return { + name: target.name, + isStage: target.isStage, + + currentCostume: target.costumeNumber, + layerOrder: target.layerOrder, + volume: target.volume, + + blocks: blockData, + broadcasts, + + // @todo sb-edit doesn't support comments (as of feb 12, 2020) + comments: {}, + + sounds: target.sounds.map(sound => ({ + name: sound.name, + dataFormat: sound.ext, + assetId: sound.md5, + md5ext: sound.md5 + "." + sound.ext, + sampleCount: sound.sampleCount ?? undefined, + rate: sound.sampleRate ?? undefined + })), + + costumes: target.costumes.map(costume => ({ + name: costume.name, + assetId: costume.md5, + md5ext: costume.md5 + "." + costume.ext, + bitmapResolution: costume.bitmapResolution, + dataFormat: costume.ext, + rotationCenterX: costume.centerX ?? undefined, + rotationCenterY: costume.centerY ?? undefined + })), + + variables: mapToIdObject(target.variables, ({ name, value, cloud }) => { + if (cloud) { + return [name, value, cloud]; + } else { + return [name, value]; + } + }), + + lists: mapToIdObject(target.lists, ({ name, value }) => [name, value]) + }; + } + + const rotationStyleMap: { [key: string]: "all around" | "left-right" | "don't rotate" } = { + normal: "all around", + leftRight: "left-right", + none: "don't rotate" + }; + + interface SerializeSpriteOptions { + initialBroadcastName: string; + } + + function serializeSprite(sprite: Sprite, options: SerializeSpriteOptions): sb3.Sprite { + // Serialize a sprite. Extending from a serialized target, sprites carry + // a variety of properties for their on-screen position and appearance. + + const { initialBroadcastName } = options; + return { + ...serializeTarget(sprite, { + initialBroadcastName, + + // Broadcasts are stored on the stage, not on any sprite. + broadcasts: {} + }), + isStage: false, + x: sprite.x, + y: sprite.y, + size: sprite.size, + direction: sprite.direction, + rotationStyle: rotationStyleMap[sprite.rotationStyle], + draggable: sprite.isDraggable, + visible: sprite.visible + }; + } + + interface SerializeStageOptions { + initialBroadcastName: string; + + broadcasts: sb3.Target["broadcasts"]; + tempo: number; + textToSpeechLanguage: TextToSpeechLanguage | null; + videoState: "on" | "off"; + videoTransparency: number; + } + + function serializeStage(stage: Stage, options: SerializeStageOptions): sb3.Stage { + // Serialize a stage. Extending from a serialized target, the stage carries + // additional properties for values shared across the project - notably, + // the broadcast dictionary, as well as values for some extensions. + + const { broadcasts, initialBroadcastName } = options; + return { + ...serializeTarget(stage, { broadcasts, initialBroadcastName }), + isStage: true, + tempo: options.tempo, + textToSpeechLanguage: options.textToSpeechLanguage, + videoState: options.videoState, + videoTransparency: options.videoTransparency + }; + } + + function serializeProject(project: Project): sb3.ProjectJSON { + // Serialize a project. This is the master function used when project.toSb3 + // is called. The main purpose of serializeProject is to serialize each + // target (sprite or stage) and collect them together in the final output + // format. It also provides utility functions shared across every target's + // serialization, e.g. broadcast utilities. + + // Set the broadcast name used in obscured broadcast inputs to the first + // sorted-alphabetically broadcast's name. While we're parsing through + // all the broadcast names in the project, also store them on a simple + // mapping of (name -> name), to be stored on the stage. (toSb3 uses a + // broadcast's name as its ID.) + let lowestName; + const broadcasts: Record = {}; + for (const target of [project.stage, ...project.sprites]) { + for (const block of target.blocks) { + if ( + block.opcode === OpCode.event_whenbroadcastreceived || + block.opcode === OpCode.event_broadcast || + block.opcode === OpCode.event_broadcastandwait + ) { + const broadcastInput = + block.opcode === OpCode.event_whenbroadcastreceived + ? block.inputs.BROADCAST_OPTION + : block.inputs.BROADCAST_INPUT; + + if (broadcastInput.type === "broadcast") { + const currentName = broadcastInput.value; + if (typeof lowestName === "undefined" || currentName < lowestName) { + lowestName = currentName; + } + broadcasts[currentName] = currentName; + } + } + } + } + + const initialBroadcastName = lowestName || "message1"; + + return { + targets: [ + serializeStage(project.stage, { + initialBroadcastName, + + broadcasts, + tempo: project.tempo, + textToSpeechLanguage: project.textToSpeechLanguage, + videoState: project.videoOn ? "on" : "off", + videoTransparency: project.videoAlpha + }), + ...project.sprites.map(sprite => + serializeSprite(sprite, { + initialBroadcastName + }) + ) + ], + meta: { + semver: "3.0.0" + } + }; + } + + const json = JSON.stringify(serializeProject(project)); + + const patchConverter = new ScratchConverter(json); + + return { + json: patchConverter.getPatchProjectJson() + }; +}