From 017e89abede9905b531dc9a352c48a7ddfcc2618 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Wed, 10 Jul 2024 17:20:08 -0400 Subject: [PATCH 1/8] test 1 --- src/io/patch/conversion-layer.mjs | 417 +++++++ src/io/patch/interfaces.ts | 592 ++++++++++ src/io/patch/patch-target-thread.mjs | 10 + src/io/patch/scratch-block.mjs | 23 + src/io/patch/scratch-conversion-control.mjs | 171 +++ src/io/patch/scratch-conversion-helper.mjs | 120 ++ src/io/patch/scratch-conversion-operator.mjs | 165 +++ src/io/patch/scratch-conversion.mjs | 357 ++++++ src/io/patch/toPatch.ts | 1048 ++++++++++++++++++ 9 files changed, 2903 insertions(+) create mode 100644 src/io/patch/conversion-layer.mjs create mode 100644 src/io/patch/interfaces.ts create mode 100644 src/io/patch/patch-target-thread.mjs create mode 100644 src/io/patch/scratch-block.mjs create mode 100644 src/io/patch/scratch-conversion-control.mjs create mode 100644 src/io/patch/scratch-conversion-helper.mjs create mode 100644 src/io/patch/scratch-conversion-operator.mjs create mode 100644 src/io/patch/scratch-conversion.mjs create mode 100644 src/io/patch/toPatch.ts diff --git a/src/io/patch/conversion-layer.mjs b/src/io/patch/conversion-layer.mjs new file mode 100644 index 0000000..bbef7fc --- /dev/null +++ b/src/io/patch/conversion-layer.mjs @@ -0,0 +1,417 @@ +export default class ConversionLayer { + static patchApi = { + // 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: [] + } + }; +} 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-target-thread.mjs b/src/io/patch/patch-target-thread.mjs new file mode 100644 index 0000000..168318c --- /dev/null +++ b/src/io/patch/patch-target-thread.mjs @@ -0,0 +1,10 @@ +export default class PatchTargetThread { + // The text that makes up the generated code of the thread + script = ""; + + // The hat that starts the thread + triggerEventId = ""; + + // The (optional) option for the hat + triggerEventOption = ""; +} diff --git a/src/io/patch/scratch-block.mjs b/src/io/patch/scratch-block.mjs new file mode 100644 index 0000000..5e77520 --- /dev/null +++ b/src/io/patch/scratch-block.mjs @@ -0,0 +1,23 @@ +export default class ScratchBlock { + opcode = ""; + + next = ""; + + parent = ""; + + inputs = { + "STEPS": [ + 1, + [ + 4, + "100" + ] + ] + }; + + fields = {}; + + shadow = false; + + topLevel = false; +} \ No newline at end of file diff --git a/src/io/patch/scratch-conversion-control.mjs b/src/io/patch/scratch-conversion-control.mjs new file mode 100644 index 0000000..5097096 --- /dev/null +++ b/src/io/patch/scratch-conversion-control.mjs @@ -0,0 +1,171 @@ +import PatchTargetThread from "./patch-target-thread.mjs"; +import ScratchBlock from "./scratch-block.mjs"; + +import { indentLines, processInputs } from "./scratch-conversion-helper.mjs"; + +export default class ScratchConversionControl { + /** + * + * @param {Object.} blocks + * @param {string} blockId + * @param {Object. { + newLines += `\n ${line}`; + }); + + return newLines; +} + +/** + * + * @param {String} code + * @returns {Boolean} + */ +function needsParentheses(code) { + // 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; + } + + /* const forbiddenChars = ["<", ">", "=", "{", "}", ":", "+", "-", "*", "/", "^", "%", "!", "and", "or", "not", "[", "]", "|"] + if (code.includes("<") || code.includes(">") || code.includes("=")) + return false; */ + + return true; +} + +export function processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, autoParentheses = false, tryMakeNum = false) { + const returnVal = {}; + + 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) { + arg = convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys).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(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; +} diff --git a/src/io/patch/scratch-conversion-operator.mjs b/src/io/patch/scratch-conversion-operator.mjs new file mode 100644 index 0000000..64df8af --- /dev/null +++ b/src/io/patch/scratch-conversion-operator.mjs @@ -0,0 +1,165 @@ +import { processInputs } from "./scratch-conversion-helper.mjs" +import ScratchBlock from "./scratch-block.mjs"; + +export default class ScratchConversionOperator { + /** + * + * @param {Object.} blocks + * @param {string} blockId + * @param {Object. ${OPERAND2}`; + break; + } + case "operator_and": { + const { OPERAND1, OPERAND2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + script += `${OPERAND1} and ${OPERAND2}`; + break; + } + case "operator_or": { + const { OPERAND1, OPERAND2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + script += `${OPERAND1} or ${OPERAND2}`; + break; + } + case "operator_not": { + const { OPERAND } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + script += `not ${OPERAND}`; + break; + } + case "operator_random": { + const { FROM, TO } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + script += `patch_random(${FROM}, ${TO})`; + break; + } + case "operator_join": { + const { STRING1, STRING2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); + + // TODO: is there a more pythonic way to implement this? + script += `${STRING1} + ${STRING2}`; + break; + } + case "operator_letter_of": { + const { STRING } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); + const { LETTER } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + script += `${STRING}[${LETTER - 1}]`; + break; + } + case "operator_length": { + const { STRING } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); + + script += `len(${STRING})`; + break; + } + case "operator_contains": { + const { STRING1, STRING2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); + + script += `${STRING2} in ${STRING1}`; + break; + } + case "operator_mod": { + const { NUM1, NUM2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); + + script += `${NUM1} % ${NUM2}`; + break; + } + case "operator_round": { + const { NUM } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); + + script += `round(${NUM})`; + break; + } + case "operator_mathop": { + const { OPERATOR } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); + const { NUM } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); + + // Remove the quotation marks that processInputs adds + const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); + + const mathOpsDict = { + "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; + } +} \ No newline at end of file diff --git a/src/io/patch/scratch-conversion.mjs b/src/io/patch/scratch-conversion.mjs new file mode 100644 index 0000000..a2de2df --- /dev/null +++ b/src/io/patch/scratch-conversion.mjs @@ -0,0 +1,357 @@ +import JSZip from "jszip"; + +import ConversionLayer from "./conversion-layer.mjs"; +import Scratch3EventBlocks from "../blocks/scratch3_event.mjs"; + +import PatchTargetThread from "./patch-target-thread.mjs"; + +import ScratchConversionControl from "./scratch-conversion-control.mjs"; +import ScratchConversionOperator from "./scratch-conversion-operator.mjs"; + +import { processInputs } from "./scratch-conversion-helper.mjs"; +import Scratch3ControlBlocks from "../blocks/scratch3_control.mjs"; + +export default class ScratchConverter { + data = null; + + scratchJson = null; + + scratchControlConverter = new ScratchConversionControl(); + + scratchOperatorConverter = new ScratchConversionOperator(); + + /** + * + * @param {ArrayBuffer} scratchData An ArrayBuffer representation of the .sb3 file to convert + */ + constructor(scratchData) { + this.data = scratchData; + } + + /** + * Returns a .ptch1 patch project represented as an array buffer + * + * @returns {ArrayBuffer} The Patch project (.ptch1) represented as an array buffer + */ + async getPatchArrayBuffer() { + const scratchZip = await JSZip.loadAsync(this.data).then(newZip => newZip); + + const projectJson = await this.getPatchProjectJsonBlob(scratchZip).then(blob => blob); + if (!projectJson) { + return null; + } + + const zip = new JSZip(); + + zip.file("project.json", projectJson); + + const scratchFilesKeys = Object.keys(scratchZip.files); + + const filePromises = []; + + // eslint-disable-next-line no-restricted-syntax + for (const key of scratchFilesKeys) { + if (key !== "project.json") { + // TODO: consider checking if the file is an actual media file? + filePromises.push( + scratchZip.files[key].async("arraybuffer").then(arrayBuffer => ({ key: key, arrayBuffer: arrayBuffer })) + ); + } + } + + const files = await Promise.all(filePromises); + files.forEach(file => { + zip.file(file.key, file.arrayBuffer); + }); + + const zippedProject = await zip.generateAsync({ type: "arraybuffer" }).then(content => content); + return zippedProject; + } + + /** + * + * @param {JSZip} zip + * @returns {Blob} + */ + async getPatchProjectJsonBlob(zip) { + if (!zip.files["project.json"]) { + console.error("Couldn't find the project.json file in the scratch project. Abort."); + return null; + } + + const jsonDataString = await zip.files["project.json"].async("text").then(text => text); + const vmState = JSON.parse(jsonDataString); + + // 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].blocks, vmState.targets[i].variables); + } + + // 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 = {}; + vmState.targets[i].variables = {}; + 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: [] }; + + // Step 4: convert this back to a blob, make everything a child of "vmstate", and return it. + const newJsonBlob = new Blob([JSON.stringify(baseJson)], { type: "application/json" }); + return newJsonBlob; + } + + convertBlocksPart(blocks, hatId, nextId, patchApi, patchApiKeys) { + const thread = new PatchTargetThread(); + + thread.triggerEventId = blocks[hatId].opcode; + console.log("blocks[hatId].opcode", 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 + + // 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 = this.scratchControlConverter.convertControlBlock( + blocks, + currentBlockId, + patchApi, + patchApiKeys, + this.convertBlocksPart, + this + ); + thread.script += `${conversionResult}\n`; + } else if (opcode.substring(0, 9) === "operator_") { + const conversionResult = this.scratchOperatorConverter.convertOperatorBlock( + blocks, + currentBlockId, + patchApi, + patchApiKeys, + this.convertBlocksPart, + this + ); + 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( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + this.convertBlocksPart.bind(this), + true, + false + ); + + /* for (let i = 0; i < inputsKeys.length; i++) { + const inputsKey = inputsKeys[i]; + + // Add options to change this based on language later. + if (patchArgs !== "") { + patchArgs += ", "; + } + + // TODO: validate this more + let newArg = ""; + + const argType = getArgType(currentBlock.inputs[inputsKey]) + + switch (argType) { + case 0: { + newArg = `${currentBlock.inputs[inputsKey][1][1]}`; + break; + } + case 1: { + newArg = `"${currentBlock.inputs[inputsKey][1][1]}"`; + break; + } + case 2: { + // Nested block + const subThread = this.convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys); + // remove the newline + newArg = subThread.script.substring(0, subThread.script.length - 1); + break; + } + default: { + console.error("Unknown argType."); + break; + } + } + + patchArgs += newArg; + } */ + + let patchCode = ""; + + const conversionLayerResult = patchApi[patchKey]; + if (conversionLayerResult.hasOwnProperty("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.hasOwnProperty("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 + // TODO: should this be a global variable? + 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; + } + + return thread; + } + + /** + * Converts an object representation of a Scratch target's blocks into an object + * representation of the corresponding Patch threads and thread code. + * + * @param {Object.} blocks + * @param {Object.} variables + * @returns {PatchTargetThread[]} An array of object representations of the patch threads + */ + convertTargetBlocks(blocks, variables) { + // TODO: convert variables + // https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks + + const blocksKeys = Object.keys(blocks); + + const returnVal = []; + + const eventBlocks = new Scratch3EventBlocks({ on: () => {}, startHats: () => {} }); + const controlBlocks = new Scratch3ControlBlocks({ on: () => {}, startHats: () => {} }); + + const hats = Object.keys({ ...eventBlocks.getHats(), ...controlBlocks.getHats() }); + + const hatLocations = []; + + blocksKeys.forEach(blockId => { + const block = blocks[blockId]; + if (hats.includes(block.opcode)) { + hatLocations.push(blockId); + } + }); + + const { patchApi } = ConversionLayer; + const patchApiKeys = Object.keys(patchApi); + + hatLocations.forEach(hatId => { + const returnValPart = this.convertBlocksPart(blocks, hatId, blocks[hatId].next, patchApi, patchApiKeys); + + 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..d301254 --- /dev/null +++ b/src/io/patch/toPatch.ts @@ -0,0 +1,1048 @@ +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.mjs" + +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 patchConverter = new ScratchConverter(); + + return { + json: JSON.stringify(serializeProject(project)) + }; +} From f1983344b6d156eebcf039a29ae5c63a134f4837 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Wed, 10 Jul 2024 18:31:52 -0400 Subject: [PATCH 2/8] eod push --- .vscode/settings.json | 4 + ...nversion-layer.mjs => conversion-layer.ts} | 0 src/io/patch/patch-interfaces.ts | 61 ++++ src/io/patch/patch-target-thread.mjs | 10 - src/io/patch/scratch-block.mjs | 23 -- ...trol.mjs => scratch-conversion-control.ts} | 14 +- src/io/patch/scratch-conversion-helper.mjs | 120 ------ src/io/patch/scratch-conversion-helper.ts | 132 +++++++ src/io/patch/scratch-conversion-operator.mjs | 165 --------- src/io/patch/scratch-conversion-operator.ts | 344 ++++++++++++++++++ ...h-conversion.mjs => scratch-conversion.ts} | 96 ++--- src/io/patch/toPatch.ts | 8 +- 12 files changed, 577 insertions(+), 400 deletions(-) create mode 100644 .vscode/settings.json rename src/io/patch/{conversion-layer.mjs => conversion-layer.ts} (100%) create mode 100644 src/io/patch/patch-interfaces.ts delete mode 100644 src/io/patch/patch-target-thread.mjs delete mode 100644 src/io/patch/scratch-block.mjs rename src/io/patch/{scratch-conversion-control.mjs => scratch-conversion-control.ts} (95%) delete mode 100644 src/io/patch/scratch-conversion-helper.mjs create mode 100644 src/io/patch/scratch-conversion-helper.ts delete mode 100644 src/io/patch/scratch-conversion-operator.mjs create mode 100644 src/io/patch/scratch-conversion-operator.ts rename src/io/patch/{scratch-conversion.mjs => scratch-conversion.ts} (80%) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..496db49 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.detectIndentation": true, + "editor.tabSize": 2 +} \ No newline at end of file diff --git a/src/io/patch/conversion-layer.mjs b/src/io/patch/conversion-layer.ts similarity index 100% rename from src/io/patch/conversion-layer.mjs rename to src/io/patch/conversion-layer.ts diff --git a/src/io/patch/patch-interfaces.ts b/src/io/patch/patch-interfaces.ts new file mode 100644 index 0000000..3c89275 --- /dev/null +++ b/src/io/patch/patch-interfaces.ts @@ -0,0 +1,61 @@ +import { TextToSpeechLanguage } from "../../Project"; +import { Block, Costume, List, Sound, Variable } 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 interface PatchTarget { + isStage: boolean; + name: string; + variables: { + [key: string]: Variable; + }; + 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; +} + +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[]; +} diff --git a/src/io/patch/patch-target-thread.mjs b/src/io/patch/patch-target-thread.mjs deleted file mode 100644 index 168318c..0000000 --- a/src/io/patch/patch-target-thread.mjs +++ /dev/null @@ -1,10 +0,0 @@ -export default class PatchTargetThread { - // The text that makes up the generated code of the thread - script = ""; - - // The hat that starts the thread - triggerEventId = ""; - - // The (optional) option for the hat - triggerEventOption = ""; -} diff --git a/src/io/patch/scratch-block.mjs b/src/io/patch/scratch-block.mjs deleted file mode 100644 index 5e77520..0000000 --- a/src/io/patch/scratch-block.mjs +++ /dev/null @@ -1,23 +0,0 @@ -export default class ScratchBlock { - opcode = ""; - - next = ""; - - parent = ""; - - inputs = { - "STEPS": [ - 1, - [ - 4, - "100" - ] - ] - }; - - fields = {}; - - shadow = false; - - topLevel = false; -} \ No newline at end of file diff --git a/src/io/patch/scratch-conversion-control.mjs b/src/io/patch/scratch-conversion-control.ts similarity index 95% rename from src/io/patch/scratch-conversion-control.mjs rename to src/io/patch/scratch-conversion-control.ts index 5097096..f3c2234 100644 --- a/src/io/patch/scratch-conversion-control.mjs +++ b/src/io/patch/scratch-conversion-control.ts @@ -1,7 +1,4 @@ -import PatchTargetThread from "./patch-target-thread.mjs"; -import ScratchBlock from "./scratch-block.mjs"; - -import { indentLines, processInputs } from "./scratch-conversion-helper.mjs"; +import { indentLines, processInputs } from "./scratch-conversion-helper"; export default class ScratchConversionControl { /** @@ -14,7 +11,14 @@ export default class ScratchConversionControl { * @param {*} partialConverterThis * @returns {string} */ - convertControlBlock(blocks, currentBlockId, patchApi, patchApiKeys, partialConverter, partialConverterThis) { + convertControlBlock( + blocks: any, + currentBlockId: any, + patchApi: any, + patchApiKeys: any, + partialConverter: any, + partialConverterThis: any + ) { const convertBlocksPart = partialConverter.bind(partialConverterThis); const currentBlock = blocks[currentBlockId]; diff --git a/src/io/patch/scratch-conversion-helper.mjs b/src/io/patch/scratch-conversion-helper.mjs deleted file mode 100644 index ec5ce06..0000000 --- a/src/io/patch/scratch-conversion-helper.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// 0: number, 1: string, 2: nested, -1: error -export function getArgType(inputJson) { - 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) { - 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) { - // 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; - } - - /* const forbiddenChars = ["<", ">", "=", "{", "}", ":", "+", "-", "*", "/", "^", "%", "!", "and", "or", "not", "[", "]", "|"] - if (code.includes("<") || code.includes(">") || code.includes("=")) - return false; */ - - return true; -} - -export function processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, autoParentheses = false, tryMakeNum = false) { - const returnVal = {}; - - 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) { - arg = convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys).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(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; -} diff --git a/src/io/patch/scratch-conversion-helper.ts b/src/io/patch/scratch-conversion-helper.ts new file mode 100644 index 0000000..d53c1a1 --- /dev/null +++ b/src/io/patch/scratch-conversion-helper.ts @@ -0,0 +1,132 @@ +// 0: number, 1: string, 2: nested, -1: error +export function getArgType(inputJson: any) { + 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; + } + + /* const forbiddenChars = ["<", ">", "=", "{", "}", ":", "+", "-", "*", "/", "^", "%", "!", "and", "or", "not", "[", "]", "|"] + if (code.includes("<") || code.includes(">") || code.includes("=")) + return false; */ + + return true; +} + +export function processInputs( + blocks: any, + currentBlockId: any, + currentBlock: any, + patchApi: any, + patchApiKeys: any, + convertBlocksPart: any, + autoParentheses = false, + tryMakeNum = false +) { + const returnVal: any = {}; + + 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) { + arg = convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys).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; +} diff --git a/src/io/patch/scratch-conversion-operator.mjs b/src/io/patch/scratch-conversion-operator.mjs deleted file mode 100644 index 64df8af..0000000 --- a/src/io/patch/scratch-conversion-operator.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import { processInputs } from "./scratch-conversion-helper.mjs" -import ScratchBlock from "./scratch-block.mjs"; - -export default class ScratchConversionOperator { - /** - * - * @param {Object.} blocks - * @param {string} blockId - * @param {Object. ${OPERAND2}`; - break; - } - case "operator_and": { - const { OPERAND1, OPERAND2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - script += `${OPERAND1} and ${OPERAND2}`; - break; - } - case "operator_or": { - const { OPERAND1, OPERAND2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - script += `${OPERAND1} or ${OPERAND2}`; - break; - } - case "operator_not": { - const { OPERAND } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - script += `not ${OPERAND}`; - break; - } - case "operator_random": { - const { FROM, TO } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - script += `patch_random(${FROM}, ${TO})`; - break; - } - case "operator_join": { - const { STRING1, STRING2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); - - // TODO: is there a more pythonic way to implement this? - script += `${STRING1} + ${STRING2}`; - break; - } - case "operator_letter_of": { - const { STRING } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); - const { LETTER } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - script += `${STRING}[${LETTER - 1}]`; - break; - } - case "operator_length": { - const { STRING } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); - - script += `len(${STRING})`; - break; - } - case "operator_contains": { - const { STRING1, STRING2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, false); - - script += `${STRING2} in ${STRING1}`; - break; - } - case "operator_mod": { - const { NUM1, NUM2 } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); - - script += `${NUM1} % ${NUM2}`; - break; - } - case "operator_round": { - const { NUM } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); - - script += `round(${NUM})`; - break; - } - case "operator_mathop": { - const { OPERATOR } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true); - const { NUM } = processInputs(blocks, currentBlockId, currentBlock, patchApi, patchApiKeys, convertBlocksPart, true, true); - - // Remove the quotation marks that processInputs adds - const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); - - const mathOpsDict = { - "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; - } -} \ No newline at end of file diff --git a/src/io/patch/scratch-conversion-operator.ts b/src/io/patch/scratch-conversion-operator.ts new file mode 100644 index 0000000..8bfa4a6 --- /dev/null +++ b/src/io/patch/scratch-conversion-operator.ts @@ -0,0 +1,344 @@ +import { processInputs } from "./scratch-conversion-helper"; + +export default class ScratchConversionOperator { + /** + * + * @param {Object.} blocks + * @param {string} blockId + * @param {Object. ${OPERAND2}`; + break; + } + case "operator_and": { + const { OPERAND1, OPERAND2 } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + script += `${OPERAND1} and ${OPERAND2}`; + break; + } + case "operator_or": { + const { OPERAND1, OPERAND2 } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + script += `${OPERAND1} or ${OPERAND2}`; + break; + } + case "operator_not": { + const { OPERAND } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + script += `not ${OPERAND}`; + break; + } + case "operator_random": { + const { FROM, TO } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + script += `patch_random(${FROM}, ${TO})`; + break; + } + case "operator_join": { + const { STRING1, STRING2 } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + false + ); + + // TODO: is there a more pythonic way to implement this? + script += `${STRING1} + ${STRING2}`; + break; + } + case "operator_letter_of": { + const { STRING } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + false + ); + const { LETTER } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + script += `${STRING}[${LETTER - 1}]`; + break; + } + case "operator_length": { + const { STRING } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + false + ); + + script += `len(${STRING})`; + break; + } + case "operator_contains": { + const { STRING1, STRING2 } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + false + ); + + script += `${STRING2} in ${STRING1}`; + break; + } + case "operator_mod": { + const { NUM1, NUM2 } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true + ); + + script += `${NUM1} % ${NUM2}`; + break; + } + case "operator_round": { + const { NUM } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true + ); + + script += `round(${NUM})`; + break; + } + case "operator_mathop": { + const { OPERATOR } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true + ); + const { NUM } = processInputs( + blocks, + currentBlockId, + currentBlock, + patchApi, + patchApiKeys, + convertBlocksPart, + true, + true + ); + + // Remove the quotation marks that processInputs adds + const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); + + const mathOpsDict: any = { + 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.mjs b/src/io/patch/scratch-conversion.ts similarity index 80% rename from src/io/patch/scratch-conversion.mjs rename to src/io/patch/scratch-conversion.ts index a2de2df..416e549 100644 --- a/src/io/patch/scratch-conversion.mjs +++ b/src/io/patch/scratch-conversion.ts @@ -1,20 +1,15 @@ -import JSZip from "jszip"; - -import ConversionLayer from "./conversion-layer.mjs"; +import ConversionLayer from "./conversion-layer"; import Scratch3EventBlocks from "../blocks/scratch3_event.mjs"; -import PatchTargetThread from "./patch-target-thread.mjs"; - -import ScratchConversionControl from "./scratch-conversion-control.mjs"; -import ScratchConversionOperator from "./scratch-conversion-operator.mjs"; +import ScratchConversionControl from "./scratch-conversion-control"; +import ScratchConversionOperator from "./scratch-conversion-operator"; -import { processInputs } from "./scratch-conversion-helper.mjs"; +import { processInputs } from "./scratch-conversion-helper"; import Scratch3ControlBlocks from "../blocks/scratch3_control.mjs"; +import { PatchScratchProjectJSON } from "./patch-interfaces"; export default class ScratchConverter { - data = null; - - scratchJson = null; + data: string = ""; scratchControlConverter = new ScratchConversionControl(); @@ -22,65 +17,14 @@ export default class ScratchConverter { /** * - * @param {ArrayBuffer} scratchData An ArrayBuffer representation of the .sb3 file to convert + * @param {String} scratchData An ArrayBuffer representation of the .sb3 file to convert */ - constructor(scratchData) { + constructor(scratchData: string) { this.data = scratchData; } - /** - * Returns a .ptch1 patch project represented as an array buffer - * - * @returns {ArrayBuffer} The Patch project (.ptch1) represented as an array buffer - */ - async getPatchArrayBuffer() { - const scratchZip = await JSZip.loadAsync(this.data).then(newZip => newZip); - - const projectJson = await this.getPatchProjectJsonBlob(scratchZip).then(blob => blob); - if (!projectJson) { - return null; - } - - const zip = new JSZip(); - - zip.file("project.json", projectJson); - - const scratchFilesKeys = Object.keys(scratchZip.files); - - const filePromises = []; - - // eslint-disable-next-line no-restricted-syntax - for (const key of scratchFilesKeys) { - if (key !== "project.json") { - // TODO: consider checking if the file is an actual media file? - filePromises.push( - scratchZip.files[key].async("arraybuffer").then(arrayBuffer => ({ key: key, arrayBuffer: arrayBuffer })) - ); - } - } - - const files = await Promise.all(filePromises); - files.forEach(file => { - zip.file(file.key, file.arrayBuffer); - }); - - const zippedProject = await zip.generateAsync({ type: "arraybuffer" }).then(content => content); - return zippedProject; - } - - /** - * - * @param {JSZip} zip - * @returns {Blob} - */ - async getPatchProjectJsonBlob(zip) { - if (!zip.files["project.json"]) { - console.error("Couldn't find the project.json file in the scratch project. Abort."); - return null; - } - - const jsonDataString = await zip.files["project.json"].async("text").then(text => text); - const vmState = JSON.parse(jsonDataString); + getPatchProjectJson() { + const vmState = JSON.parse(this.data) as PatchScratchProjectJSON; // 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 @@ -112,16 +56,19 @@ export default class ScratchConverter { // TODO: global variables const baseJson = { vmstate: vmState, globalVariables: [] }; - // Step 4: convert this back to a blob, make everything a child of "vmstate", and return it. - const newJsonBlob = new Blob([JSON.stringify(baseJson)], { type: "application/json" }); - return newJsonBlob; + return JSON.stringify(baseJson); } - convertBlocksPart(blocks, hatId, nextId, patchApi, patchApiKeys) { + convertBlocksPart( + blocks: { [id: string]: Block }, + hatId: string, + nextId: string, + patchApi: typeof ConversionLayer.patchApi, + patchApiKeys: string[] + ) { const thread = new PatchTargetThread(); thread.triggerEventId = blocks[hatId].opcode; - console.log("blocks[hatId].opcode", blocks[hatId].opcode); // TODO: triggerEventOption const hatFieldsKeys = Object.keys(blocks[hatId].fields); if (hatFieldsKeys && hatFieldsKeys.length > 0) { @@ -313,20 +260,21 @@ export default class ScratchConverter { * @param {Object.} variables * @returns {PatchTargetThread[]} An array of object representations of the patch threads */ - convertTargetBlocks(blocks, variables) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + convertTargetBlocks(blocks: { [id: string]: Block }, variables: { [id: string]: number | string }) { // TODO: convert variables // https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks const blocksKeys = Object.keys(blocks); - const returnVal = []; + const returnVal: PatchTargetThread[] = []; const eventBlocks = new Scratch3EventBlocks({ on: () => {}, startHats: () => {} }); const controlBlocks = new Scratch3ControlBlocks({ on: () => {}, startHats: () => {} }); const hats = Object.keys({ ...eventBlocks.getHats(), ...controlBlocks.getHats() }); - const hatLocations = []; + const hatLocations: string[] = []; blocksKeys.forEach(blockId => { const block = blocks[blockId]; diff --git a/src/io/patch/toPatch.ts b/src/io/patch/toPatch.ts index d301254..a2be467 100644 --- a/src/io/patch/toPatch.ts +++ b/src/io/patch/toPatch.ts @@ -4,7 +4,7 @@ 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.mjs" +import ScratchConverter from "./scratch-conversion"; const BIS = sb3.BlockInputStatus; @@ -1040,9 +1040,11 @@ export default function toPatch(project: Project, options: Partial Date: Thu, 11 Jul 2024 13:39:17 -0400 Subject: [PATCH 3/8] finish up conversion --- src/Project.ts | 2 + src/cli/index.ts | 33 ++++++++- src/io/patch/conversion-layer.ts | 21 +++++- src/io/patch/patch-interfaces.ts | 28 +++++++- src/io/patch/scratch-conversion-control.ts | 27 ++++---- src/io/patch/scratch-conversion-helper.ts | 31 ++++++--- src/io/patch/scratch-conversion-operator.ts | 20 +++--- src/io/patch/scratch-conversion.ts | 74 +++++++++++++++++---- 8 files changed, 188 insertions(+), 48 deletions(-) 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..ddb05a8 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(); @@ -188,6 +190,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 +309,29 @@ 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; + } + } + + await fs.writeFile(fullOutputPath, patch.json); + }); + break; } } diff --git a/src/io/patch/conversion-layer.ts b/src/io/patch/conversion-layer.ts index bbef7fc..a6a8689 100644 --- a/src/io/patch/conversion-layer.ts +++ b/src/io/patch/conversion-layer.ts @@ -1,5 +1,24 @@ +import { PatchScratchBlock, PatchTargetThread } from "./patch-interfaces"; + +export interface ConversionLayerType { + [key: string]: { + opcode: string; + parameters: string[]; + returnParametersInstead?: string[]; + returnInstead?: string[]; + }; +} + +export type PartialConverterType = ( + blocks: { [id: string]: PatchScratchBlock }, + hatId: string, + nextId: string, + patchApi: ConversionLayerType, + patchApiKeys: string[] +) => PatchTargetThread; + export default class ConversionLayer { - static patchApi = { + static patchApi: ConversionLayerType = { // Motion blocks: move: { opcode: "motion_movesteps", diff --git a/src/io/patch/patch-interfaces.ts b/src/io/patch/patch-interfaces.ts index 3c89275..db7f5e2 100644 --- a/src/io/patch/patch-interfaces.ts +++ b/src/io/patch/patch-interfaces.ts @@ -1,5 +1,5 @@ import { TextToSpeechLanguage } from "../../Project"; -import { Block, Costume, List, Sound, Variable } from "./interfaces"; +import { BlockField, Costume, List, Monitor, Sound, Variable } from "./interfaces"; export class PatchTargetThread { // The text that makes up the generated code of the thread @@ -12,6 +12,28 @@ export class PatchTargetThread { 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; @@ -25,7 +47,7 @@ export interface PatchTarget { [key: string]: string; }; blocks: { - [key: string]: PatchScratchBlock; + [key: string]: PatchScratchBlock; }; comments: { [key: string]: Comment; @@ -35,6 +57,7 @@ export interface PatchTarget { sounds: Sound[]; volume: number; layerOrder: number; + threads?: PatchTargetThread[]; } export interface Stage extends PatchTarget { @@ -58,4 +81,5 @@ export interface Sprite extends PatchTarget { 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 index f3c2234..7eeca77 100644 --- a/src/io/patch/scratch-conversion-control.ts +++ b/src/io/patch/scratch-conversion-control.ts @@ -1,3 +1,6 @@ +import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; +import { PatchScratchBlock } from "./patch-interfaces"; +import ScratchConverter from "./scratch-conversion"; import { indentLines, processInputs } from "./scratch-conversion-helper"; export default class ScratchConversionControl { @@ -12,12 +15,12 @@ export default class ScratchConversionControl { * @returns {string} */ convertControlBlock( - blocks: any, - currentBlockId: any, - patchApi: any, - patchApiKeys: any, - partialConverter: any, - partialConverterThis: any + blocks: { [key: string]: PatchScratchBlock }, + currentBlockId: string, + patchApi: ConversionLayerType, + patchApiKeys: string[], + partialConverter: PartialConverterType, + partialConverterThis: ScratchConverter ) { const convertBlocksPart = partialConverter.bind(partialConverterThis); @@ -32,7 +35,7 @@ export default class ScratchConversionControl { let SUBSTACK = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.SUBSTACK[1], + currentBlock.inputs.SUBSTACK[1] as string, patchApi, patchApiKeys ).script; @@ -50,7 +53,7 @@ export default class ScratchConversionControl { let CONDITION = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.CONDITION[1], + currentBlock.inputs.CONDITION[1] as string, patchApi, patchApiKeys ).script; @@ -58,7 +61,7 @@ export default class ScratchConversionControl { let SUBSTACK = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.SUBSTACK[1], + currentBlock.inputs.SUBSTACK[1] as string, patchApi, patchApiKeys ).script; @@ -74,7 +77,7 @@ export default class ScratchConversionControl { let CONDITION = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.CONDITION[1], + currentBlock.inputs.CONDITION[1] as string, patchApi, patchApiKeys ).script; @@ -82,7 +85,7 @@ export default class ScratchConversionControl { let SUBSTACK = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.SUBSTACK[1], + currentBlock.inputs.SUBSTACK[1] as string, patchApi, patchApiKeys ).script; @@ -90,7 +93,7 @@ export default class ScratchConversionControl { let SUBSTACK2 = convertBlocksPart( blocks, currentBlockId, - currentBlock.inputs.SUBSTACK2[1], + currentBlock.inputs.SUBSTACK2[1] as string, patchApi, patchApiKeys ).script; diff --git a/src/io/patch/scratch-conversion-helper.ts b/src/io/patch/scratch-conversion-helper.ts index d53c1a1..2d18ef0 100644 --- a/src/io/patch/scratch-conversion-helper.ts +++ b/src/io/patch/scratch-conversion-helper.ts @@ -1,5 +1,8 @@ +import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; +import { PatchScratchBlock, PatchScratchBlockInput } from "./patch-interfaces"; + // 0: number, 1: string, 2: nested, -1: error -export function getArgType(inputJson: any) { +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 @@ -77,16 +80,20 @@ function needsParentheses(code: string) { } export function processInputs( - blocks: any, - currentBlockId: any, - currentBlock: any, - patchApi: any, - patchApiKeys: any, - convertBlocksPart: any, + blocks: { + [key: string]: PatchScratchBlock; + }, + currentBlockId: string, + currentBlock: PatchScratchBlock, + patchApi: ConversionLayerType, + patchApiKeys: string[], + convertBlocksPart: PartialConverterType, autoParentheses = false, tryMakeNum = false ) { - const returnVal: any = {}; + const returnVal: { + [key: string]: string; + } = {}; const inputsKeys = Object.keys(currentBlock.inputs); for (let i = 0; i < inputsKeys.length; i++) { @@ -100,7 +107,13 @@ export function processInputs( } else if (argType === 1) { arg = `"${currentBlock.inputs[inputsKey][1][1]}"`; } else if (argType === 2) { - arg = convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys).script; + arg = convertBlocksPart( + blocks, + currentBlockId, + currentBlock.inputs[inputsKey][1] as string, + patchApi, + patchApiKeys + ).script; arg = arg.substring(0, arg.length - 1); if (autoParentheses && needsParentheses(arg)) { arg = `(${arg})`; diff --git a/src/io/patch/scratch-conversion-operator.ts b/src/io/patch/scratch-conversion-operator.ts index 8bfa4a6..df1f498 100644 --- a/src/io/patch/scratch-conversion-operator.ts +++ b/src/io/patch/scratch-conversion-operator.ts @@ -1,3 +1,5 @@ +import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; +import { PatchScratchBlock } from "./patch-interfaces"; import { processInputs } from "./scratch-conversion-helper"; export default class ScratchConversionOperator { @@ -12,12 +14,14 @@ export default class ScratchConversionOperator { * @returns {string} */ convertOperatorBlock( - blocks: any, - currentBlockId: any, - patchApi: any, - patchApiKeys: any, - partialConverter: any, - partialConverterThis: any + blocks: { + [key: string]: PatchScratchBlock; + }, + currentBlockId: string, + patchApi: ConversionLayerType, + patchApiKeys: string[], + partialConverter: PartialConverterType, + partialConverterThis: unknown ) { const convertBlocksPart = partialConverter.bind(partialConverterThis); @@ -226,7 +230,7 @@ export default class ScratchConversionOperator { true ); - script += `${STRING}[${LETTER - 1}]`; + script += `${STRING}[${parseInt(LETTER) - 1}]`; break; } case "operator_length": { @@ -311,7 +315,7 @@ export default class ScratchConversionOperator { // Remove the quotation marks that processInputs adds const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); - const mathOpsDict: any = { + const mathOpsDict: { [key: string]: string } = { abs: `abs(${NUM})`, ceiling: `math.ceil(${NUM})`, sqrt: `math.sqrt(${NUM})`, diff --git a/src/io/patch/scratch-conversion.ts b/src/io/patch/scratch-conversion.ts index 416e549..f2f92cc 100644 --- a/src/io/patch/scratch-conversion.ts +++ b/src/io/patch/scratch-conversion.ts @@ -1,12 +1,54 @@ -import ConversionLayer from "./conversion-layer"; -import Scratch3EventBlocks from "../blocks/scratch3_event.mjs"; +import ConversionLayer, { ConversionLayerType } from "./conversion-layer"; import ScratchConversionControl from "./scratch-conversion-control"; import ScratchConversionOperator from "./scratch-conversion-operator"; import { processInputs } from "./scratch-conversion-helper"; -import Scratch3ControlBlocks from "../blocks/scratch3_control.mjs"; -import { PatchScratchProjectJSON } from "./patch-interfaces"; +import { PatchScratchBlock, PatchScratchProjectJSON, 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 = ""; @@ -34,7 +76,7 @@ export default class ScratchConverter { // 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].blocks, vmState.targets[i].variables); + vmState.targets[i].threads = this.convertTargetBlocks(vmState.targets[i].blocks, {}); //vmState.targets[i].variables); } // Step 2: remove blocks (this isn't strictly necessary) and variables + broadcasts (this is necessary) @@ -60,10 +102,10 @@ export default class ScratchConverter { } convertBlocksPart( - blocks: { [id: string]: Block }, + blocks: { [id: string]: PatchScratchBlock }, hatId: string, nextId: string, - patchApi: typeof ConversionLayer.patchApi, + patchApi: ConversionLayerType, patchApiKeys: string[] ) { const thread = new PatchTargetThread(); @@ -109,6 +151,7 @@ export default class ScratchConverter { currentBlockId, patchApi, patchApiKeys, + // eslint-disable-next-line @typescript-eslint/unbound-method this.convertBlocksPart, this ); @@ -119,6 +162,7 @@ export default class ScratchConverter { currentBlockId, patchApi, patchApiKeys, + // eslint-disable-next-line @typescript-eslint/unbound-method this.convertBlocksPart, this ); @@ -181,7 +225,7 @@ export default class ScratchConverter { let patchCode = ""; const conversionLayerResult = patchApi[patchKey]; - if (conversionLayerResult.hasOwnProperty("returnInstead")) { + if (conversionLayerResult.returnInstead) { let patchArgs = ""; for (let i = 0; i < conversionLayerResult.returnInstead.length; i++) { const val = conversionLayerResult.returnInstead[i]; @@ -195,7 +239,7 @@ export default class ScratchConverter { } patchCode = `${patchArgs}\n`; - } else if (conversionLayerResult.hasOwnProperty("returnParametersInstead")) { + } else if (conversionLayerResult.returnParametersInstead) { let patchArgs = ""; for (let i = 0; i < conversionLayerResult.returnParametersInstead.length; i++) { const parameter = conversionLayerResult.returnParametersInstead[i]; // .toUpperCase(); @@ -246,7 +290,7 @@ export default class ScratchConverter { } // Next block - currentBlockId = currentBlock.next; + currentBlockId = currentBlock.next as string; } return thread; @@ -261,7 +305,7 @@ export default class ScratchConverter { * @returns {PatchTargetThread[]} An array of object representations of the patch threads */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - convertTargetBlocks(blocks: { [id: string]: Block }, variables: { [id: string]: number | string }) { + convertTargetBlocks(blocks: { [key: string]: PatchScratchBlock }, variables: { [key: string]: number | string }) { // TODO: convert variables // https://en.scratch-wiki.info/wiki/Scratch_File_Format#Blocks @@ -269,10 +313,12 @@ export default class ScratchConverter { const returnVal: PatchTargetThread[] = []; - const eventBlocks = new Scratch3EventBlocks({ on: () => {}, startHats: () => {} }); + /*const eventBlocks = new Scratch3EventBlocks({ on: () => {}, startHats: () => {} }); const controlBlocks = new Scratch3ControlBlocks({ on: () => {}, startHats: () => {} }); - const hats = Object.keys({ ...eventBlocks.getHats(), ...controlBlocks.getHats() }); + const hats = Object.keys({ ...eventBlocks.getHats(), ...controlBlocks.getHats() });*/ + + const hats = Object.keys({ ...EventHats, ...ControlHats }); const hatLocations: string[] = []; @@ -287,7 +333,7 @@ export default class ScratchConverter { const patchApiKeys = Object.keys(patchApi); hatLocations.forEach(hatId => { - const returnValPart = this.convertBlocksPart(blocks, hatId, blocks[hatId].next, patchApi, patchApiKeys); + const returnValPart = this.convertBlocksPart(blocks, hatId, blocks[hatId].next as string, patchApi, patchApiKeys); if (returnValPart.script.includes("math.")) { returnValPart.script = `import math\n\n${returnValPart.script}`; From 59b07ca62782471bc1e5080a27e341d01004b026 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Thu, 11 Jul 2024 14:57:29 -0400 Subject: [PATCH 4/8] generate actual ptch1 file instead of just project.json --- src/cli/index.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index ddb05a8..a0de20e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -329,7 +329,25 @@ async function run() { } } - await fs.writeFile(fullOutputPath, patch.json); + 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; From a7dd8931fcc3518d484057a43d82cd24792597c0 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Thu, 11 Jul 2024 14:59:03 -0400 Subject: [PATCH 5/8] If the output file extension is .ptch1, it is now recognized as a patch file and will output as such. --- src/cli/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cli/index.ts b/src/cli/index.ts index a0de20e..25b62d3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -73,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."); } From 64c7575c2b58e0eeae18a90967b24616344316f0 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Thu, 11 Jul 2024 15:05:24 -0400 Subject: [PATCH 6/8] clean up comments --- src/io/patch/scratch-conversion-control.ts | 17 --------- src/io/patch/scratch-conversion-helper.ts | 4 -- src/io/patch/scratch-conversion.ts | 44 ---------------------- 3 files changed, 65 deletions(-) diff --git a/src/io/patch/scratch-conversion-control.ts b/src/io/patch/scratch-conversion-control.ts index 7eeca77..337d05e 100644 --- a/src/io/patch/scratch-conversion-control.ts +++ b/src/io/patch/scratch-conversion-control.ts @@ -4,16 +4,6 @@ import ScratchConverter from "./scratch-conversion"; import { indentLines, processInputs } from "./scratch-conversion-helper"; export default class ScratchConversionControl { - /** - * - * @param {Object.} blocks - * @param {string} blockId - * @param {Object.", "=", "{", "}", ":", "+", "-", "*", "/", "^", "%", "!", "and", "or", "not", "[", "]", "|"] - if (code.includes("<") || code.includes(">") || code.includes("=")) - return false; */ - return true; } diff --git a/src/io/patch/scratch-conversion.ts b/src/io/patch/scratch-conversion.ts index f2f92cc..919a1da 100644 --- a/src/io/patch/scratch-conversion.ts +++ b/src/io/patch/scratch-conversion.ts @@ -184,44 +184,6 @@ export default class ScratchConverter { false ); - /* for (let i = 0; i < inputsKeys.length; i++) { - const inputsKey = inputsKeys[i]; - - // Add options to change this based on language later. - if (patchArgs !== "") { - patchArgs += ", "; - } - - // TODO: validate this more - let newArg = ""; - - const argType = getArgType(currentBlock.inputs[inputsKey]) - - switch (argType) { - case 0: { - newArg = `${currentBlock.inputs[inputsKey][1][1]}`; - break; - } - case 1: { - newArg = `"${currentBlock.inputs[inputsKey][1][1]}"`; - break; - } - case 2: { - // Nested block - const subThread = this.convertBlocksPart(blocks, currentBlockId, currentBlock.inputs[inputsKey][1], patchApi, patchApiKeys); - // remove the newline - newArg = subThread.script.substring(0, subThread.script.length - 1); - break; - } - default: { - console.error("Unknown argType."); - break; - } - } - - patchArgs += newArg; - } */ - let patchCode = ""; const conversionLayerResult = patchApi[patchKey]; @@ -277,7 +239,6 @@ export default class ScratchConverter { } // Handle a special case: Patch implements the Ask block differently - // TODO: should this be a global variable? if (currentBlock.opcode === "sensing_askandwait") { patchKey = `_patchAnswer = ${patchKey}`; } @@ -313,11 +274,6 @@ export default class ScratchConverter { const returnVal: PatchTargetThread[] = []; - /*const eventBlocks = new Scratch3EventBlocks({ on: () => {}, startHats: () => {} }); - const controlBlocks = new Scratch3ControlBlocks({ on: () => {}, startHats: () => {} }); - - const hats = Object.keys({ ...eventBlocks.getHats(), ...controlBlocks.getHats() });*/ - const hats = Object.keys({ ...EventHats, ...ControlHats }); const hatLocations: string[] = []; From 6d532453bd94b7a45c093907b371a1dff7240aa3 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Fri, 12 Jul 2024 18:00:20 -0400 Subject: [PATCH 7/8] Refactor scratch to patch conversion code and add in variables support --- src/io/patch/conversion-layer.ts | 837 ++++++++++---------- src/io/patch/patch-interfaces.ts | 4 +- src/io/patch/scratch-conversion-control.ts | 236 ++---- src/io/patch/scratch-conversion-data.ts | 197 +++++ src/io/patch/scratch-conversion-helper.ts | 165 +++- src/io/patch/scratch-conversion-operator.ts | 470 ++++------- src/io/patch/scratch-conversion.ts | 198 +---- 7 files changed, 1012 insertions(+), 1095 deletions(-) create mode 100644 src/io/patch/scratch-conversion-data.ts diff --git a/src/io/patch/conversion-layer.ts b/src/io/patch/conversion-layer.ts index a6a8689..cc853f2 100644 --- a/src/io/patch/conversion-layer.ts +++ b/src/io/patch/conversion-layer.ts @@ -1,4 +1,4 @@ -import { PatchScratchBlock, PatchTargetThread } from "./patch-interfaces"; +import { PatchTarget, PatchTargetThread } from "./patch-interfaces"; export interface ConversionLayerType { [key: string]: { @@ -9,428 +9,429 @@ export interface ConversionLayerType { }; } -export type PartialConverterType = ( - blocks: { [id: string]: PatchScratchBlock }, - hatId: string, - nextId: string, - patchApi: ConversionLayerType, - patchApiKeys: string[] -) => PatchTargetThread; +export type PartialConverterType = (target: PatchTarget, hatId: string, nextId: string) => PatchTargetThread; -export default class ConversionLayer { - static patchApi: ConversionLayerType = { - // 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"] - }, +export type InputProcessorType = ( + target: PatchTarget, + currentBlockId: string, + autoParentheses?: boolean, + tryMakeNum?: boolean +) => { [key: string]: string }; - // 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"] - }, +export type LineIndenterType = (lines: string) => string; - // 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"] - }, +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"] + }, - // 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"] - }, + // 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"] + }, - // 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"] - }, + // 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"] + }, - 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"] - }, + // 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"] + }, - 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"] - }, + // 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"] + }, - endThread: { - opcode: "core_endthread", - parameters: [] - } - }; -} + 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/patch-interfaces.ts b/src/io/patch/patch-interfaces.ts index db7f5e2..0d5872d 100644 --- a/src/io/patch/patch-interfaces.ts +++ b/src/io/patch/patch-interfaces.ts @@ -1,5 +1,5 @@ import { TextToSpeechLanguage } from "../../Project"; -import { BlockField, Costume, List, Monitor, Sound, Variable } from "./interfaces"; +import { BlockField, Costume, List, Monitor, Sound } from "./interfaces"; export class PatchTargetThread { // The text that makes up the generated code of the thread @@ -38,7 +38,7 @@ export interface PatchTarget { isStage: boolean; name: string; variables: { - [key: string]: Variable; + [key: string]: [string, string | number]; }; lists: { [key: string]: List; diff --git a/src/io/patch/scratch-conversion-control.ts b/src/io/patch/scratch-conversion-control.ts index 337d05e..63b0175 100644 --- a/src/io/patch/scratch-conversion-control.ts +++ b/src/io/patch/scratch-conversion-control.ts @@ -1,161 +1,87 @@ -import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; -import { PatchScratchBlock } from "./patch-interfaces"; -import ScratchConverter from "./scratch-conversion"; -import { indentLines, processInputs } from "./scratch-conversion-helper"; +import { InputProcessorType, LineIndenterType, PartialConverterType } from "./conversion-layer"; +import { PatchTarget } from "./patch-interfaces"; -export default class ScratchConversionControl { - convertControlBlock( - blocks: { [key: string]: PatchScratchBlock }, - currentBlockId: string, - patchApi: ConversionLayerType, - patchApiKeys: string[], - partialConverter: PartialConverterType, - partialConverterThis: ScratchConverter - ) { - const convertBlocksPart = partialConverter.bind(partialConverterThis); +export default function convertControlBlock( + target: PatchTarget, + currentBlockId: string, + processInputs: InputProcessorType, + indentLines: LineIndenterType, + convertBlocksPart: PartialConverterType +) { + const currentBlock = target.blocks[currentBlockId]; + const { opcode } = currentBlock; - const currentBlock = blocks[currentBlockId]; - const { opcode } = currentBlock; + let script = ""; - let script = ""; - - switch (opcode) { - case "control_forever": { - // Forever loop - let SUBSTACK = convertBlocksPart( - blocks, - currentBlockId, - currentBlock.inputs.SUBSTACK[1] as string, - patchApi, - patchApiKeys - ).script; - SUBSTACK = SUBSTACK.substring(0, SUBSTACK.length - 1); - 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( - blocks, - currentBlockId, - currentBlock.inputs.CONDITION[1] as string, - patchApi, - patchApiKeys - ).script; - CONDITION = CONDITION.substring(0, CONDITION.length - 1); - let SUBSTACK = convertBlocksPart( - blocks, - currentBlockId, - currentBlock.inputs.SUBSTACK[1] as string, - patchApi, - patchApiKeys - ).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( - blocks, - currentBlockId, - currentBlock.inputs.CONDITION[1] as string, - patchApi, - patchApiKeys - ).script; - CONDITION = CONDITION.substring(0, CONDITION.length - 1); - let SUBSTACK = convertBlocksPart( - blocks, - currentBlockId, - currentBlock.inputs.SUBSTACK[1] as string, - patchApi, - patchApiKeys - ).script; - SUBSTACK = SUBSTACK.substring(0, SUBSTACK.length - 1); - let SUBSTACK2 = convertBlocksPart( - blocks, - currentBlockId, - currentBlock.inputs.SUBSTACK2[1] as string, - patchApi, - patchApiKeys - ).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( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - false - ); - const { TIMES } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); - script += `for _ in range(${TIMES}):`; - script += indentLines(SUBSTACK); - script += "\n"; - break; - } - case "control_wait_until": { - const { CONDITION } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - 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( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - 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; - } + 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; } + + 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 index 44f7adb..55bd2d0 100644 --- a/src/io/patch/scratch-conversion-helper.ts +++ b/src/io/patch/scratch-conversion-helper.ts @@ -1,5 +1,10 @@ -import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; -import { PatchScratchBlock, PatchScratchBlockInput } from "./patch-interfaces"; +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) { @@ -76,14 +81,8 @@ function needsParentheses(code: string) { } export function processInputs( - blocks: { - [key: string]: PatchScratchBlock; - }, + target: PatchTarget, currentBlockId: string, - currentBlock: PatchScratchBlock, - patchApi: ConversionLayerType, - patchApiKeys: string[], - convertBlocksPart: PartialConverterType, autoParentheses = false, tryMakeNum = false ) { @@ -91,6 +90,8 @@ export function processInputs( [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]; @@ -103,13 +104,8 @@ export function processInputs( } else if (argType === 1) { arg = `"${currentBlock.inputs[inputsKey][1][1]}"`; } else if (argType === 2) { - arg = convertBlocksPart( - blocks, - currentBlockId, - currentBlock.inputs[inputsKey][1] as string, - patchApi, - patchApiKeys - ).script; + // 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})`; @@ -139,3 +135,140 @@ export function processInputs( 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 index df1f498..c0dbc12 100644 --- a/src/io/patch/scratch-conversion-operator.ts +++ b/src/io/patch/scratch-conversion-operator.ts @@ -1,348 +1,162 @@ -import { ConversionLayerType, PartialConverterType } from "./conversion-layer"; -import { PatchScratchBlock } from "./patch-interfaces"; -import { processInputs } from "./scratch-conversion-helper"; - -export default class ScratchConversionOperator { - /** - * - * @param {Object.} blocks - * @param {string} blockId - * @param {Object. ${OPERAND2}`; - break; - } - case "operator_and": { - const { OPERAND1, OPERAND2 } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `${OPERAND1} < ${OPERAND2}`; + break; + } + case "operator_equals": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); - script += `${OPERAND1} and ${OPERAND2}`; - break; - } - case "operator_or": { - const { OPERAND1, OPERAND2 } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `${OPERAND1} == ${OPERAND2}`; + break; + } + case "operator_gt": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); - script += `${OPERAND1} or ${OPERAND2}`; - break; - } - case "operator_not": { - const { OPERAND } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `${OPERAND1} > ${OPERAND2}`; + break; + } + case "operator_and": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); - script += `not ${OPERAND}`; - break; - } - case "operator_random": { - const { FROM, TO } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `${OPERAND1} and ${OPERAND2}`; + break; + } + case "operator_or": { + const { OPERAND1, OPERAND2 } = processInputs(target, currentBlockId, true, true); - script += `patch_random(${FROM}, ${TO})`; - break; - } - case "operator_join": { - const { STRING1, STRING2 } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - false - ); + script += `${OPERAND1} or ${OPERAND2}`; + break; + } + case "operator_not": { + const { OPERAND } = processInputs(target, currentBlockId, true, true); - // TODO: is there a more pythonic way to implement this? - script += `${STRING1} + ${STRING2}`; - break; - } - case "operator_letter_of": { - const { STRING } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - false - ); - const { LETTER } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `not ${OPERAND}`; + break; + } + case "operator_random": { + const { FROM, TO } = processInputs(target, currentBlockId, true, true); - script += `${STRING}[${parseInt(LETTER) - 1}]`; - break; - } - case "operator_length": { - const { STRING } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - false - ); + script += `patch_random(${FROM}, ${TO})`; + break; + } + case "operator_join": { + const { STRING1, STRING2 } = processInputs(target, currentBlockId, true, false); - script += `len(${STRING})`; - break; - } - case "operator_contains": { - const { STRING1, STRING2 } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - 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 += `${STRING2} in ${STRING1}`; - break; - } - case "operator_mod": { - const { NUM1, NUM2 } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true - ); + script += `${STRING}[${parseInt(LETTER) - 1}]`; + break; + } + case "operator_length": { + const { STRING } = processInputs(target, currentBlockId, true, false); - script += `${NUM1} % ${NUM2}`; - break; - } - case "operator_round": { - const { NUM } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true - ); + script += `len(${STRING})`; + break; + } + case "operator_contains": { + const { STRING1, STRING2 } = processInputs(target, currentBlockId, true, false); - script += `round(${NUM})`; - break; - } - case "operator_mathop": { - const { OPERATOR } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true - ); - const { NUM } = processInputs( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - convertBlocksPart, - true, - true - ); + script += `${STRING2} in ${STRING1}`; + break; + } + case "operator_mod": { + const { NUM1, NUM2 } = processInputs(target, currentBlockId, true); - // Remove the quotation marks that processInputs adds - const formattedOperator = OPERATOR.substring(1, OPERATOR.length - 1); + script += `${NUM1} % ${NUM2}`; + break; + } + case "operator_round": { + const { NUM } = processInputs(target, currentBlockId, true); - 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 + 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; - } + 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; } + + return script; } diff --git a/src/io/patch/scratch-conversion.ts b/src/io/patch/scratch-conversion.ts index 919a1da..0f825cc 100644 --- a/src/io/patch/scratch-conversion.ts +++ b/src/io/patch/scratch-conversion.ts @@ -1,10 +1,5 @@ -import ConversionLayer, { ConversionLayerType } from "./conversion-layer"; - -import ScratchConversionControl from "./scratch-conversion-control"; -import ScratchConversionOperator from "./scratch-conversion-operator"; - -import { processInputs } from "./scratch-conversion-helper"; -import { PatchScratchBlock, PatchScratchProjectJSON, PatchTargetThread } from "./patch-interfaces"; +import { convertBlocksPart } from "./scratch-conversion-helper"; +import { PatchScratchProjectJSON, PatchTarget, PatchTargetThread } from "./patch-interfaces"; const EventHats = { event_whenflagclicked: { @@ -53,10 +48,6 @@ const ControlHats = { export default class ScratchConverter { data: string = ""; - scratchControlConverter = new ScratchConversionControl(); - - scratchOperatorConverter = new ScratchConversionOperator(); - /** * * @param {String} scratchData An ArrayBuffer representation of the .sb3 file to convert @@ -68,6 +59,8 @@ export default class ScratchConverter { 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 @@ -76,7 +69,7 @@ export default class ScratchConverter { // 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].blocks, {}); //vmState.targets[i].variables); + vmState.targets[i].threads = this.convertTargetBlocks(vmState.targets[i]); } // Step 2: remove blocks (this isn't strictly necessary) and variables + broadcasts (this is necessary) @@ -84,7 +77,18 @@ export default class ScratchConverter { // 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 = {}; } @@ -96,180 +100,25 @@ export default class ScratchConverter { // Step 4: make everything a child of "vmstate" and add global variables // TODO: global variables - const baseJson = { vmstate: vmState, globalVariables: [] }; + const baseJson = { vmstate: vmState, globalVariables: globalVariables }; return JSON.stringify(baseJson); } - convertBlocksPart( - blocks: { [id: string]: PatchScratchBlock }, - hatId: string, - nextId: string, - patchApi: ConversionLayerType, - patchApiKeys: string[] - ) { - const thread = new PatchTargetThread(); - - 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 - - // 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 = this.scratchControlConverter.convertControlBlock( - blocks, - currentBlockId, - patchApi, - patchApiKeys, - // eslint-disable-next-line @typescript-eslint/unbound-method - this.convertBlocksPart, - this - ); - thread.script += `${conversionResult}\n`; - } else if (opcode.substring(0, 9) === "operator_") { - const conversionResult = this.scratchOperatorConverter.convertOperatorBlock( - blocks, - currentBlockId, - patchApi, - patchApiKeys, - // eslint-disable-next-line @typescript-eslint/unbound-method - this.convertBlocksPart, - this - ); - 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( - blocks, - currentBlockId, - currentBlock, - patchApi, - patchApiKeys, - this.convertBlocksPart.bind(this), - 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; - } - /** * Converts an object representation of a Scratch target's blocks into an object * representation of the corresponding Patch threads and thread code. * - * @param {Object.} blocks - * @param {Object.} variables + * @param {PatchTarget} target * @returns {PatchTargetThread[]} An array of object representations of the patch threads */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - convertTargetBlocks(blocks: { [key: string]: PatchScratchBlock }, variables: { [key: string]: number | string }) { + 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[] = []; @@ -285,11 +134,8 @@ export default class ScratchConverter { } }); - const { patchApi } = ConversionLayer; - const patchApiKeys = Object.keys(patchApi); - hatLocations.forEach(hatId => { - const returnValPart = this.convertBlocksPart(blocks, hatId, blocks[hatId].next as string, patchApi, patchApiKeys); + const returnValPart = convertBlocksPart(target, hatId, blocks[hatId].next as string); if (returnValPart.script.includes("math.")) { returnValPart.script = `import math\n\n${returnValPart.script}`; From 7fded50a2efeec22e2a9c22a0c62e4c58896ad06 Mon Sep 17 00:00:00 2001 From: Benjamin Montgomery Date: Mon, 15 Jul 2024 16:16:00 -0400 Subject: [PATCH 8/8] Remove vscode configuration --- .vscode/settings.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 496db49..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "editor.detectIndentation": true, - "editor.tabSize": 2 -} \ No newline at end of file