Spaces:
Runtime error
Runtime error
| const BlockType = require('../../extension-support/block-type'); | |
| const ArgumentType = require('../../extension-support/argument-type'); | |
| const Cast = require('../../util/cast'); | |
| const Clone = require('../../util/clone'); | |
| const getStateOfSprite = (target) => { | |
| return { | |
| x: target.x, | |
| y: target.y, | |
| size: target.size, | |
| stretch: Clone.simple(target.stretch), // array | |
| transform: Clone.simple(target.transform), // array | |
| direction: target.direction, | |
| rotationStyle: target.rotationStyle, | |
| visible: target.visible, | |
| effects: Clone.simple(target.effects || {}), // object | |
| currentCostume: target.currentCostume, | |
| tintColor: target.tintColor | |
| }; | |
| }; | |
| const setStateOfSprite = (target, state) => { | |
| target.setXY(state.x, state.y); | |
| target.setSize(state.size); | |
| target.setStretch(...state.stretch); | |
| target.setTransform(state.transform); | |
| target.setDirection(state.direction); | |
| target.setRotationStyle(state.rotationStyle); | |
| target.setVisible(state.visible); | |
| if (state.effects) { | |
| for (const effect in state.effects) { | |
| target.setEffect(effect, state.effects[effect]); | |
| } | |
| } | |
| target.setCostume(state.currentCostume); | |
| }; | |
| // i've decided to tell ChatGPT to generate these due to some conditions: | |
| // - the color util does NOT have these implemented | |
| // - we know hsvToDecimal will ONLY get an HSV generated by decimalToHSV, and we know hsvToDecimal will have decimals in it's params | |
| // - these functions need to be as performant as possible (i dont know how to do that, so the AI may know better) | |
| // we already only run these if we really need to anyways, as it will be slow | |
| // | |
| // i could be completely wrong and these functions suck, but i dont really have any way of judging that | |
| // this seems to be good for now, we only use them for tintColor anyways to make sure its not a mess | |
| function decimalToHSV(decimalColor, hsv = { h: 0, s: 0, v: 0 }) { | |
| const r = (decimalColor >> 16) & 255; | |
| const g = (decimalColor >> 8) & 255; | |
| const b = decimalColor & 255; | |
| const max = Math.max(r, g, b); | |
| const min = Math.min(r, g, b); | |
| const delta = max - min; | |
| let h; | |
| // Calculate hue | |
| if (delta === 0) { | |
| h = 0; | |
| } else if (max === r) { | |
| h = (0.5 + ((g - b) / delta) % 6) | 0; | |
| } else if (max === g) { | |
| h = (0.5 + ((b - r) / delta + 2)) | 0; | |
| } else { | |
| h = (0.5 + ((r - g) / delta + 4)) | 0; | |
| } | |
| hsv.h = (0.5 + (h * 60 + 360) % 360) | 0; | |
| hsv.s = max === 0 ? 0 : (delta / max); | |
| hsv.v = max / 255; | |
| return hsv; | |
| } | |
| function hsvToDecimal(h, s, v) { | |
| const c = v * s; | |
| const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); | |
| const m = v - c; | |
| let r, g, b; | |
| if (h < 60) { | |
| [r, g, b] = [c, x, 0]; | |
| } else if (h < 120) { | |
| [r, g, b] = [x, c, 0]; | |
| } else if (h < 180) { | |
| [r, g, b] = [0, c, x]; | |
| } else if (h < 240) { | |
| [r, g, b] = [0, x, c]; | |
| } else if (h < 300) { | |
| [r, g, b] = [x, 0, c]; | |
| } else { | |
| [r, g, b] = [c, 0, x]; | |
| } | |
| const decimalR = (0.5 + (r + m) * 255) | 0; | |
| const decimalG = (0.5 + (g + m) * 255) | 0; | |
| const decimalB = (0.5 + (b + m) * 255) | 0; | |
| return (decimalR << 16) | (decimalG << 8) | decimalB; | |
| } | |
| /** | |
| * @param {number} time should be 0-1 | |
| * @param {number} a value at 0 | |
| * @param {number} b value at 1 | |
| * @returns {number} | |
| */ | |
| const interpolate = (time, a, b) => { | |
| // don't restrict range of time as some easing functions are expected to go outside the range | |
| const multiplier = b - a; | |
| const result = time * multiplier + a; | |
| return result; | |
| }; | |
| const snap = (x) => 1; | |
| const snapcenter = (x) => Math.round(x); | |
| const snapend = (x) => Math.ceil(x); | |
| const linear = (x) => x; | |
| const sine = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return 1 - Math.cos((x * Math.PI) / 2); | |
| } | |
| case "out": { | |
| return Math.sin((x * Math.PI) / 2); | |
| } | |
| case "in out": { | |
| return -(Math.cos(Math.PI * x) - 1) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const quad = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return x * x; | |
| } | |
| case "out": { | |
| return 1 - (1 - x) * (1 - x); | |
| } | |
| case "in out": { | |
| return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const cubic = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return x * x * x; | |
| } | |
| case "out": { | |
| return 1 - Math.pow(1 - x, 3); | |
| } | |
| case "in out": { | |
| return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const quart = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return x * x * x * x; | |
| } | |
| case "out": { | |
| return 1 - Math.pow(1 - x, 4); | |
| } | |
| case "in out": { | |
| return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const quint = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return x * x * x * x * x; | |
| } | |
| case "out": { | |
| return 1 - Math.pow(1 - x, 5); | |
| } | |
| case "in out": { | |
| return x < 0.5 | |
| ? 16 * x * x * x * x * x | |
| : 1 - Math.pow(-2 * x + 2, 5) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const expo = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return x === 0 ? 0 : Math.pow(2, 10 * x - 10); | |
| } | |
| case "out": { | |
| return x === 1 ? 1 : 1 - Math.pow(2, -10 * x); | |
| } | |
| case "in out": { | |
| return x === 0 | |
| ? 0 | |
| : x === 1 | |
| ? 1 | |
| : x < 0.5 | |
| ? Math.pow(2, 20 * x - 10) / 2 | |
| : (2 - Math.pow(2, -20 * x + 10)) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const circ = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return 1 - Math.sqrt(1 - Math.pow(x, 2)); | |
| } | |
| case "out": { | |
| return Math.sqrt(1 - Math.pow(x - 1, 2)); | |
| } | |
| case "in out": { | |
| return x < 0.5 | |
| ? (1 - Math.sqrt(1 - Math.pow(2 * x, 2))) / 2 | |
| : (Math.sqrt(1 - Math.pow(-2 * x + 2, 2)) + 1) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const back = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| const c1 = 1.70158; | |
| const c3 = c1 + 1; | |
| return c3 * x * x * x - c1 * x * x; | |
| } | |
| case "out": { | |
| const c1 = 1.70158; | |
| const c3 = c1 + 1; | |
| return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); | |
| } | |
| case "in out": { | |
| const c1 = 1.70158; | |
| const c2 = c1 * 1.525; | |
| return x < 0.5 | |
| ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 | |
| : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const elastic = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| const c4 = (2 * Math.PI) / 3; | |
| return x === 0 | |
| ? 0 | |
| : x === 1 | |
| ? 1 | |
| : -Math.pow(2, 10 * x - 10) * Math.sin((x * 10 - 10.75) * c4); | |
| } | |
| case "out": { | |
| const c4 = (2 * Math.PI) / 3; | |
| return x === 0 | |
| ? 0 | |
| : x === 1 | |
| ? 1 | |
| : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; | |
| } | |
| case "in out": { | |
| const c5 = (2 * Math.PI) / 4.5; | |
| return x === 0 | |
| ? 0 | |
| : x === 1 | |
| ? 1 | |
| : x < 0.5 | |
| ? -(Math.pow(2, 20 * x - 10) * Math.sin((20 * x - 11.125) * c5)) / 2 | |
| : (Math.pow(2, -20 * x + 10) * Math.sin((20 * x - 11.125) * c5)) / 2 + | |
| 1; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const bounce = (x, dir) => { | |
| switch (dir) { | |
| case "in": { | |
| return 1 - bounce(1 - x, "out"); | |
| } | |
| case "out": { | |
| const n1 = 7.5625; | |
| const d1 = 2.75; | |
| if (x < 1 / d1) { | |
| return n1 * x * x; | |
| } else if (x < 2 / d1) { | |
| return n1 * (x -= 1.5 / d1) * x + 0.75; | |
| } else if (x < 2.5 / d1) { | |
| return n1 * (x -= 2.25 / d1) * x + 0.9375; | |
| } else { | |
| return n1 * (x -= 2.625 / d1) * x + 0.984375; | |
| } | |
| } | |
| case "in out": { | |
| return x < 0.5 | |
| ? (1 - bounce(1 - 2 * x, "out")) / 2 | |
| : (1 + bounce(2 * x - 1, "out")) / 2; | |
| } | |
| default: | |
| return 0; | |
| } | |
| }; | |
| const EasingMethods = { | |
| linear, | |
| sine, | |
| quad, | |
| cubic, | |
| quart, | |
| quint, | |
| expo, | |
| circ, | |
| back, | |
| elastic, | |
| bounce, | |
| snap, | |
| snapcenter, | |
| snapend, | |
| }; | |
| class AnimationExtension { | |
| constructor(runtime) { | |
| /** | |
| * The runtime instantiating this block package. | |
| * @type {Runtime} | |
| */ | |
| this.runtime = runtime; | |
| this.animations = Object.create(null); | |
| this.progressingTargets = []; | |
| this.progressingTargetData = Object.create(null); | |
| this.runtime.on('RUNTIME_PRE_PAUSED', () => { | |
| for (const targetId in this.progressingTargetData) { | |
| const targetData = this.progressingTargetData[targetId]; | |
| for (const animationName in targetData) { | |
| const animationData = targetData[animationName]; | |
| animationData.projectPaused = true; | |
| } | |
| } | |
| this.runtime.updateCurrentMSecs(); | |
| this.runtime.emit('ANIMATIONS_FORCE_STEP'); | |
| }); | |
| this.runtime.on('RUNTIME_UNPAUSED', () => { | |
| this.runtime.updateCurrentMSecs(); // currentMSecs is the same as when we originally paused, fix that | |
| for (const targetId in this.progressingTargetData) { | |
| const targetData = this.progressingTargetData[targetId]; | |
| for (const animationName in targetData) { | |
| const animationData = targetData[animationName]; | |
| animationData.projectPaused = false; | |
| } | |
| } | |
| }); | |
| } | |
| now() { | |
| return this.runtime.currentMSecs; | |
| } | |
| deserialize(data) { | |
| this.animations = data; | |
| } | |
| serialize() { | |
| return this.animations; | |
| } | |
| orderCategoryBlocks(blocks) { | |
| const buttons = { | |
| create: blocks[0], | |
| delete: blocks[1] | |
| }; | |
| const varBlock = blocks[2]; | |
| blocks.splice(0, 3); | |
| // create the variable block xml's | |
| const varBlocks = Object.keys(this.animations) | |
| .map(animationName => varBlock.replace('{animationId}', animationName)); | |
| if (varBlocks.length <= 0) { | |
| return [buttons.create]; | |
| } | |
| // push the button to the top of the var list | |
| varBlocks.reverse(); | |
| varBlocks.push(buttons.delete); | |
| varBlocks.push(buttons.create); | |
| // merge the category blocks and variable blocks into one block list | |
| blocks = varBlocks | |
| .reverse() | |
| .concat(blocks); | |
| return blocks; | |
| } | |
| getInfo() { | |
| return { | |
| id: "jgAnimation", | |
| name: "Animation", | |
| isDynamic: true, | |
| orderBlocks: this.orderCategoryBlocks.bind(this), | |
| blocks: [ | |
| { opcode: 'createAnimation', text: 'New Animation', blockType: BlockType.BUTTON, }, | |
| { opcode: 'deleteAnimation', text: 'Delete an Animation', blockType: BlockType.BUTTON, }, | |
| { | |
| opcode: 'getAnimation', text: '[ANIMATION]', blockType: BlockType.REPORTER, | |
| arguments: { | |
| ANIMATION: { menu: 'animations', defaultValue: '{animationId}', type: ArgumentType.STRING, } | |
| }, | |
| }, | |
| { text: "Animations", blockType: BlockType.LABEL, }, | |
| { | |
| opcode: "playAnimation", | |
| blockType: BlockType.COMMAND, | |
| text: "play [ANIM] [OFFSET] and [FORWARDS] after last keyframe", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| OFFSET: { | |
| type: ArgumentType.STRING, | |
| menu: 'offsetMenu', | |
| }, | |
| FORWARDS: { | |
| type: ArgumentType.STRING, | |
| menu: 'forwardsMenu', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "pauseAnimation", | |
| blockType: BlockType.COMMAND, | |
| text: "pause [ANIM]", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "unpauseAnimation", | |
| blockType: BlockType.COMMAND, | |
| text: "unpause [ANIM]", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "stopAnimation", | |
| blockType: BlockType.COMMAND, | |
| text: "stop [ANIM]", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { text: "Keyframes", blockType: BlockType.LABEL, }, | |
| { | |
| opcode: "addStateKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "add current state with [EASING] [DIRECTION] as keyframe with duration [LENGTH] in animation [ANIM]", | |
| arguments: { | |
| EASING: { | |
| type: ArgumentType.STRING, | |
| menu: 'easingMode', | |
| }, | |
| DIRECTION: { | |
| type: ArgumentType.STRING, | |
| menu: 'easingDir', | |
| }, | |
| LENGTH: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: 1, | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "addJSONKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "add keyframe JSON [JSON] as keyframe in animation [ANIM]", | |
| arguments: { | |
| JSON: { | |
| type: ArgumentType.STRING, | |
| defaultValue: '{}', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "setStateKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "set keyframe [IDX] in animation [ANIM] to current state with [EASING] [DIRECTION] and duration [LENGTH] ", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| EASING: { | |
| type: ArgumentType.STRING, | |
| menu: 'easingMode', | |
| }, | |
| DIRECTION: { | |
| type: ArgumentType.STRING, | |
| menu: 'easingDir', | |
| }, | |
| LENGTH: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: 1, | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "setJSONKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "set keyframe [IDX] in animation [ANIM] to JSON [JSON]", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| JSON: { | |
| type: ArgumentType.STRING, | |
| defaultValue: '{}', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "deleteKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "delete keyframe [IDX] from [ANIM]", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "deleteAllKeyframes", | |
| blockType: BlockType.COMMAND, | |
| text: "delete all keyframes [ANIM]", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "getKeyframe", | |
| blockType: BlockType.REPORTER, | |
| text: "get keyframe [IDX] from [ANIM]", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "getKeyframeCount", | |
| blockType: BlockType.REPORTER, | |
| disableMonitor: true, | |
| text: "amount of keyframes in [ANIM]", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "isPausedAnimation", | |
| blockType: BlockType.BOOLEAN, | |
| disableMonitor: true, | |
| hideFromPalette: true, | |
| text: "is [ANIM] paused?", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "isPropertyAnimation", | |
| blockType: BlockType.BOOLEAN, | |
| disableMonitor: true, | |
| text: "is [ANIM] [ANIMPROP]?", | |
| arguments: { | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| ANIMPROP: { | |
| type: ArgumentType.STRING, | |
| menu: 'animationDataProperty', | |
| }, | |
| }, | |
| }, | |
| { text: "Operations", blockType: BlockType.LABEL, }, | |
| { | |
| opcode: "goToKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "go to keyframe [IDX] in [ANIM]", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| { | |
| opcode: "snapToKeyframe", | |
| blockType: BlockType.COMMAND, | |
| text: "snap to keyframe [IDX] in [ANIM]", | |
| arguments: { | |
| IDX: { | |
| type: ArgumentType.NUMBER, | |
| defaultValue: '1', | |
| }, | |
| ANIM: { | |
| type: ArgumentType.STRING, | |
| menu: 'animations', | |
| }, | |
| }, | |
| }, | |
| ], | |
| menus: { | |
| animations: '_animationsMenu', | |
| easingMode: { | |
| acceptReporters: true, | |
| items: Object.keys(EasingMethods), | |
| }, | |
| easingDir: { | |
| acceptReporters: true, | |
| items: ["in", "out", "in out"], | |
| }, | |
| animationDataProperty: { | |
| acceptReporters: false, | |
| items: ["playing", "paused"], | |
| }, | |
| offsetMenu: { | |
| acceptReporters: false, | |
| items: [ | |
| { text: "relative to current state", value: "relative" }, | |
| { text: "snapped to first keyframe", value: "snapped" } | |
| ], | |
| }, | |
| forwardsMenu: { | |
| acceptReporters: false, | |
| items: [ | |
| { text: "stay", value: "stay" }, | |
| { text: "reset to original state", value: "reset" }, | |
| ], | |
| }, | |
| } | |
| }; | |
| } | |
| _animationsMenu() { | |
| const animations = Object.keys(this.animations); | |
| if (animations.length <= 0) { | |
| return [ | |
| { | |
| text: '', | |
| value: '' | |
| } | |
| ]; | |
| } | |
| return animations.map(animation => ({ | |
| text: animation, | |
| value: animation | |
| })); | |
| } | |
| _parseKeyframeOrKeyframes(string) { | |
| let json; | |
| try { | |
| json = JSON.parse(string); | |
| } catch { | |
| json = {}; | |
| } | |
| if (typeof json !== 'object') { | |
| return {}; | |
| } | |
| if (Array.isArray(json)) { | |
| for (const item of json) { | |
| if (typeof item !== 'object') { | |
| return {}; | |
| } | |
| } | |
| } | |
| return json; | |
| } | |
| _tweenValue(start, end, easeMethod, easeDirection, progress) { | |
| if (!Object.prototype.hasOwnProperty.call(EasingMethods, easeMethod)) { | |
| // Unknown method | |
| return start; | |
| } | |
| const easingFunction = EasingMethods[easeMethod]; | |
| const tweened = easingFunction(progress, easeDirection); | |
| return interpolate(tweened, start, end); | |
| } | |
| _progressAnimation(target, startState, endState, mode, direction, progress) { | |
| const tweenNum = (start, end) => { | |
| return this._tweenValue(start, end, mode, direction, progress); | |
| }; | |
| const staticValue = tweenNum(0, 1); | |
| target.setXY( | |
| tweenNum(startState.x, endState.x), | |
| tweenNum(startState.y, endState.y) | |
| ); | |
| target.setSize(tweenNum(startState.size, endState.size)); | |
| target.setStretch( | |
| tweenNum(startState.stretch[0], endState.stretch[0]), | |
| tweenNum(startState.stretch[1], endState.stretch[1]) | |
| ); | |
| target.setTransform([ | |
| tweenNum(startState.transform[0], endState.transform[0]), | |
| tweenNum(startState.transform[1], endState.transform[1]) | |
| ]); | |
| target.setDirection(tweenNum(startState.direction, endState.direction)); | |
| target.setRotationStyle(Math.round(staticValue) === 0 ? startState.rotationStyle : endState.rotationStyle); | |
| target.setVisible(Math.round(staticValue) === 0 ? startState.visible : endState.visible); | |
| for (const effect in startState.effects) { | |
| if (effect === 'tintColor' && startState.effects.tintColor !== endState.effects.tintColor) { | |
| const startHsv = decimalToHSV(startState.effects.tintColor - 1); | |
| const endHsv = decimalToHSV(endState.effects.tintColor - 1); | |
| const currentHsv = { | |
| h: tweenNum(startHsv.h, endHsv.h), | |
| s: tweenNum(startHsv.s, endHsv.s), | |
| v: tweenNum(startHsv.v, endHsv.v), | |
| }; | |
| target.setEffect('tintColor', hsvToDecimal(currentHsv.h, currentHsv.s, currentHsv.v)); | |
| continue; | |
| } | |
| target.setEffect(effect, tweenNum(startState.effects[effect], endState.effects[effect])); | |
| } | |
| target.setCostume(Math.round(staticValue) === 0 ? startState.currentCostume : endState.currentCostume); | |
| } | |
| createAnimation() { | |
| const newAnimation = prompt('Create animation named:', 'animation ' + (Object.keys(this.animations).length + 1)); | |
| if (!newAnimation) return; | |
| if (newAnimation in this.animations) return alert(`"${newAnimation}" is taken!`); | |
| this.animations[newAnimation] = { | |
| keyframes: [] | |
| }; | |
| vm.emitWorkspaceUpdate(); | |
| this.serialize(); | |
| } | |
| deleteAnimation() { | |
| const animationName = prompt('Which animation would you like to delete?'); | |
| if (animationName in this.animations) { | |
| for (const target of this.runtime.targets) { | |
| this.stopAnimation({ | |
| ANIM: animationName | |
| }, { | |
| target | |
| }); | |
| } | |
| delete this.animations[animationName]; | |
| } | |
| vm.emitWorkspaceUpdate(); | |
| this.serialize(); | |
| } | |
| getAnimation(args) { | |
| const animationName = Cast.toString(args.ANIMATION); | |
| if (!(animationName in this.animations)) return '{}'; | |
| return JSON.stringify(this.animations[animationName]); | |
| } | |
| addKeyframe(animation, state) { | |
| if (!(animation in this.animations)) { | |
| return; | |
| } | |
| this.animations[animation].keyframes.push(state); | |
| } | |
| setKeyframe(animation, state, idx) { | |
| if (!(animation in this.animations)) { | |
| return; | |
| } | |
| const keyframes = this.animations[animation].keyframes; | |
| if (idx > keyframes.length - 1) { | |
| return; | |
| } | |
| if (idx < 0) { | |
| return; | |
| } | |
| keyframes[idx] = state; | |
| } | |
| addStateKeyframe(args, util) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const state = getStateOfSprite(util.target); | |
| this.addKeyframe(animationName, { | |
| ...state, | |
| easingMode: Cast.toString(args.EASING), | |
| easingDir: Cast.toString(args.DIRECTION), | |
| keyframeLength: Cast.toNumber(args.LENGTH) | |
| }); | |
| } | |
| addJSONKeyframe(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const parsedKeyframe = this._parseKeyframeOrKeyframes(args.JSON); | |
| if (Array.isArray(parsedKeyframe)) { | |
| for (const keyframe of parsedKeyframe) { | |
| this.addKeyframe(animationName, keyframe); | |
| } | |
| } else { | |
| this.addKeyframe(animationName, parsedKeyframe); | |
| } | |
| } | |
| setStateKeyframe(args, util) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const index = Cast.toNumber(args.IDX) - 1; | |
| const state = getStateOfSprite(util.target); | |
| this.setKeyframe(animationName, { | |
| ...state, | |
| easingMode: Cast.toString(args.EASING), | |
| easingDir: Cast.toString(args.DIRECTION), | |
| keyframeLength: Cast.toNumber(args.LENGTH) | |
| }, index); | |
| } | |
| setJSONKeyframe(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const index = Cast.toNumber(args.IDX) - 1; | |
| const parsedKeyframe = this._parseKeyframeOrKeyframes(args.JSON); | |
| if (Array.isArray(parsedKeyframe)) { | |
| return; | |
| } else { | |
| this.setKeyframe(animationName, parsedKeyframe, index); | |
| } | |
| } | |
| deleteKeyframe(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const idx = Cast.toNumber(args.IDX); | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| this.animations[animationName].keyframes.splice(idx - 1, 1); | |
| } | |
| deleteAllKeyframes(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| this.animations[animationName].keyframes = []; | |
| } | |
| getKeyframe(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const idx = Cast.toNumber(args.IDX) - 1; | |
| if (!(animationName in this.animations)) { | |
| return '{}'; | |
| } | |
| const animation = this.animations[animationName]; | |
| const keyframe = animation.keyframes[idx]; | |
| if (!keyframe) return '{}'; | |
| return JSON.stringify(keyframe); | |
| } | |
| getKeyframeCount(args) { | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return '{}'; | |
| } | |
| const animation = this.animations[animationName]; | |
| return animation.keyframes.length; | |
| } | |
| goToKeyframe(args, util) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const idx = Cast.toNumber(args.IDX) - 1; | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const animation = this.animations[animationName]; | |
| const keyframe = animation.keyframes[idx]; | |
| if (!keyframe) return; | |
| // start animating | |
| const spriteTarget = util.target; | |
| const currentState = getStateOfSprite(spriteTarget); | |
| const startTime = this.now(); | |
| const endTime = this.now() + (keyframe.keyframeLength * 1000); // 2.65s should be 2650ms | |
| if (endTime <= startTime) { | |
| // this frame is instant | |
| setStateOfSprite(spriteTarget, keyframe); | |
| return; | |
| } | |
| // this will run each step | |
| let finishedAnim = false; | |
| const frameHandler = () => { | |
| const currentTime = this.now(); | |
| if (currentTime >= endTime) { | |
| this.runtime.off('RUNTIME_STEP_START', frameHandler); | |
| setStateOfSprite(spriteTarget, keyframe); | |
| finishedAnim = true; | |
| return; | |
| } | |
| const progress = (currentTime - startTime) / (endTime - startTime); | |
| this._progressAnimation(spriteTarget, currentState, keyframe, keyframe.easingMode, keyframe.easingDir, progress); | |
| }; | |
| frameHandler(); | |
| this.runtime.once('PROJECT_STOP_ALL', () => { | |
| if (!finishedAnim) { | |
| // finishedAnim is only true if we already removed it | |
| this.runtime.off('RUNTIME_STEP_START', frameHandler); | |
| } | |
| }); | |
| this.runtime.on('RUNTIME_STEP_START', frameHandler); | |
| } | |
| snapToKeyframe(args, util) { | |
| const animationName = Cast.toString(args.ANIM); | |
| const idx = Cast.toNumber(args.IDX) - 1; | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const animation = this.animations[animationName]; | |
| const keyframe = animation.keyframes[idx]; | |
| if (!keyframe) return; | |
| setStateOfSprite(util.target, keyframe); | |
| } | |
| // MULTIPLE ANIMATIONS CAN PLAY AT ONCE ON THE SAME SPRITE! remember this | |
| playAnimation(args, util) { | |
| const spriteTarget = util.target; | |
| const id = spriteTarget.id; | |
| const animationName = Cast.toString(args.ANIM); | |
| const isRelative = args.OFFSET !== 'snapped'; | |
| const isForwards = args.FORWARDS !== 'reset'; | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const animation = this.animations[animationName]; | |
| const firstKeyframe = animation.keyframes[0]; | |
| // check if we are unpausing | |
| let existingAnimationState = this.progressingTargetData[id]; | |
| if (this.progressingTargets.includes(id) && existingAnimationState && existingAnimationState[animationName]) { | |
| // we are playing this animation already? | |
| const animationState = existingAnimationState[animationName]; | |
| if (animationState.paused) { | |
| animationState.paused = false; | |
| return; | |
| } | |
| if (!animationState.forceStop) { | |
| return; // this animation isnt stopped, still actively playing | |
| } else { | |
| // force an animation update to fully cancel the animation | |
| // console.log('before', performance.now()); | |
| this.runtime.emit('ANIMATIONS_FORCE_SPECIFIC_STEP', id, animationName); | |
| // console.log('after', performance.now()); | |
| } | |
| } | |
| // we can start initializing our animation, but first check if we can skip a lot of work here | |
| if (!firstKeyframe) { | |
| return; | |
| } | |
| // there are a couple cases where we can do nothing or do little to nothing | |
| // relative mode basically ignores the first keyframe, we only care about things after | |
| // if we are relative, if we are ignoring the first keyframe and the second keyframe doesnt exist, we can just do nothing | |
| // forwards mode entails we want to stay in the state that the last keyframe put us in, the name comes from what CSS calls it | |
| // if we are relative and we arent going forwards, then nothing should happen (second keyframe doesnt exist and we ignored the first) | |
| // if we arent relative and we arent going forwards, then nothing should happen (we shouldnt be in the state of the first keyframe) | |
| const secondKeyframe = animation.keyframes[1]; | |
| if (!secondKeyframe) { | |
| if (isForwards && !isRelative) { | |
| // we really should only do this if we arent relative & we should stay in this state when the animation ends | |
| setStateOfSprite(spriteTarget, firstKeyframe); | |
| } | |
| // we are relative OR we shouldnt stay in the state of the last keyframe | |
| return; | |
| } | |
| // initialize for animation | |
| if (!this.progressingTargets.includes(id)) { | |
| // we are playing any animation, so we need to say we are animating atm | |
| this.progressingTargets.push(id); | |
| } | |
| if (!existingAnimationState) { | |
| // we are playing any animation, initialize data | |
| const data = Object.create(null); | |
| this.progressingTargetData[id] = data; | |
| existingAnimationState = this.progressingTargetData[id]; | |
| } | |
| // set our data | |
| existingAnimationState[animationName] = {}; | |
| const animationState = existingAnimationState[animationName]; | |
| animationState.forceStop = false; | |
| animationState.paused = false; | |
| animationState.projectPaused = false; | |
| // we can start animating now | |
| // some of our math needs to allow our offset if we are in relative mode | |
| // there are some exceptions to relative mode: | |
| // - tintColor should only be the current state's color until a keyframe changes it from the first | |
| // - some effects should act like multipliers and others should add to each keyframes effects | |
| // - rotation mode shoould only be the current state's rotation mode on the first keyframe | |
| // - costume should stay the same until the animation changes the costume from the first keyframe | |
| const finalAnimation = Clone.simple(animation); | |
| // patchy fix, but it makes the animation actually be timed properly | |
| finalAnimation.keyframes[0].keyframeLength = 0.001; // 1ms | |
| const initialState = getStateOfSprite(spriteTarget); | |
| const fakeEffects = { tintColor: 0xffffff + 1 }; | |
| if (isRelative) { | |
| // update the keyframes of the animation | |
| let initialCostume = firstKeyframe.currentCostume ?? initialState.currentCostume; | |
| let initialRotation = firstKeyframe.rotationStyle ?? initialState.rotationStyle; | |
| let initialTintColor = (firstKeyframe.effects || fakeEffects).tintColor ?? fakeEffects.tintColor; | |
| let shouldUpdateCostume = false; | |
| let shouldUpdateRotationStyle = false; | |
| let shouldUpdateTintColor = false; | |
| for (const keyframe of finalAnimation.keyframes) { | |
| // offset based on initial position | |
| keyframe.x -= firstKeyframe.x; | |
| keyframe.y -= firstKeyframe.y; | |
| keyframe.size /= firstKeyframe.size / 100; | |
| keyframe.stretch = [keyframe.stretch[0] / (firstKeyframe.stretch[0] / 100), keyframe.stretch[1] / (firstKeyframe.stretch[1] / 100)]; | |
| keyframe.transform = [keyframe.transform[0] - firstKeyframe.transform[0], keyframe.transform[1] - firstKeyframe.transform[1]]; | |
| keyframe.direction -= firstKeyframe.direction - 90; | |
| // change regulars | |
| keyframe.x += initialState.x; | |
| keyframe.y += initialState.y; | |
| keyframe.size *= initialState.size / 100; | |
| keyframe.stretch = [keyframe.stretch[0] * (initialState.stretch[0] / 100), keyframe.stretch[1] * (initialState.stretch[1] / 100)]; | |
| keyframe.transform = [keyframe.transform[0] + initialState.transform[0], keyframe.transform[1] + initialState.transform[1]]; | |
| keyframe.direction += initialState.direction - 90; | |
| // exceptions | |
| if (!shouldUpdateCostume) { | |
| shouldUpdateCostume = initialCostume !== keyframe.currentCostume; | |
| } | |
| if (!shouldUpdateRotationStyle) { | |
| shouldUpdateRotationStyle = initialRotation !== keyframe.rotationStyle; | |
| } | |
| if (!shouldUpdateTintColor) { | |
| shouldUpdateTintColor = initialTintColor !== (keyframe.effects || fakeEffects).tintColor; | |
| } | |
| // handle exceptions | |
| if (!shouldUpdateCostume) { | |
| keyframe.currentCostume = initialState.currentCostume; | |
| } | |
| if (!shouldUpdateRotationStyle) { | |
| keyframe.rotationStyle = initialState.rotationStyle; | |
| } | |
| if (!shouldUpdateTintColor) { | |
| if (!keyframe.effects) keyframe.effects = {}; | |
| keyframe.effects.tintColor = initialState.effects.tintColor; | |
| } | |
| for (const effect in keyframe.effects) { | |
| if (effect === 'tintColor') continue; | |
| const value = keyframe.effects[effect]; | |
| const initValue = initialState.effects[effect]; | |
| switch (effect) { | |
| case 'ghost': | |
| // 0 for invis, 1 for visible | |
| const newGhost = (1 - (value / 100)) * (1 - (initValue / 100)); | |
| keyframe.effects[effect] = (1 - newGhost) * 100; | |
| break; | |
| default: | |
| keyframe.effects[effect] += initialState.effects[effect]; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| if (!isRelative) { | |
| setStateOfSprite(spriteTarget, firstKeyframe); | |
| } | |
| // play animation | |
| const stopAllHandler = () => { | |
| animationState.forceStop = true; | |
| this.runtime.emit('ANIMATIONS_FORCE_SPECIFIC_STEP', id, animationName); | |
| } | |
| const forceSpecificStepHandler = (targetId, targetAnimationName) => { | |
| if (targetId !== id) return; | |
| if (targetAnimationName !== animationName) return; | |
| // yep he's talking to us | |
| // console.log('forced step', targetId, targetAnimationName, animationState.forceStop); | |
| // console.log('during', performance.now()); | |
| frameHandler(); | |
| }; | |
| const animationEnded = (forceStop) => { | |
| if (!isForwards) { | |
| setStateOfSprite(spriteTarget, initialState); | |
| } else if (!forceStop) { | |
| const lastKeyframe = finalAnimation.keyframes[finalAnimation.keyframes.length - 1]; | |
| setStateOfSprite(spriteTarget, lastKeyframe); | |
| } | |
| this.runtime.off('RUNTIME_STEP_START', frameHandler); | |
| this.runtime.off('ANIMATIONS_FORCE_STEP', frameHandler); | |
| this.runtime.off('ANIMATIONS_FORCE_SPECIFIC_STEP', forceSpecificStepHandler); | |
| this.runtime.off('PROJECT_STOP_ALL', stopAllHandler); | |
| // remove our registered data | |
| const totalSpriteData = this.progressingTargetData[id]; | |
| if (totalSpriteData) { | |
| if (totalSpriteData[animationName]) { | |
| delete totalSpriteData[animationName]; | |
| } | |
| const totalAnimationsPlaying = Object.keys(totalSpriteData); | |
| if (totalAnimationsPlaying.length <= 0) { | |
| delete this.progressingTargetData[id]; | |
| if (this.progressingTargets.includes(id)) { | |
| const idx = this.progressingTargets.indexOf(id); | |
| this.progressingTargets.splice(idx, 1); | |
| } | |
| } | |
| } | |
| }; | |
| let startTime = this.now(); | |
| // calculate length | |
| let animationLength = 0; | |
| let keyframeStartTimes = []; | |
| let keyframeEndTimes = []; | |
| let _lastKeyframeTime = 0; | |
| for (const keyframe of finalAnimation.keyframes) { | |
| animationLength += keyframe.keyframeLength * 1000; | |
| keyframeStartTimes.push(startTime + _lastKeyframeTime); | |
| keyframeEndTimes.push(startTime + (keyframe.keyframeLength * 1000) + _lastKeyframeTime); | |
| _lastKeyframeTime += keyframe.keyframeLength * 1000; | |
| } | |
| // get timings & info | |
| let currentKeyframe = 0; // updates at the end of a frame | |
| let currentState = getStateOfSprite(spriteTarget); // updates at the end of a frame | |
| const lastKeyframe = finalAnimation.keyframes.length - 1; | |
| let endTime = this.now() + animationLength; | |
| let isPaused = false; | |
| let pauseStartTime = 0; | |
| const frameHandler = () => { | |
| const currentTime = this.now(); | |
| if (animationState.forceStop) { | |
| // prematurely end the animation | |
| animationEnded(true); | |
| return; | |
| } | |
| if (animationState.paused || animationState.projectPaused) { | |
| isPaused = true; | |
| if (pauseStartTime === 0) { | |
| pauseStartTime = this.now(); | |
| } | |
| } | |
| if (isPaused) { | |
| // check if still paused & handle if not | |
| if (!animationState.paused && !animationState.projectPaused) { | |
| isPaused = false; | |
| const pauseTime = this.now() - pauseStartTime; // amount of time we were paused for | |
| startTime += pauseTime; | |
| endTime += pauseTime; | |
| keyframeStartTimes = keyframeStartTimes.map(time => time + pauseTime); | |
| keyframeEndTimes = keyframeEndTimes.map(time => time + pauseTime); | |
| pauseStartTime = 0; | |
| } | |
| if (isPaused) { | |
| return; | |
| } | |
| } | |
| if (currentTime >= endTime) { | |
| animationEnded(); | |
| return; | |
| } | |
| const keyframe = finalAnimation.keyframes[currentKeyframe]; | |
| const keyframeStart = keyframeStartTimes[currentKeyframe]; | |
| const keyframeEnd = keyframeEndTimes[currentKeyframe]; | |
| // const animationProgress = (currentTime - startTime) / (endTime - startTime); | |
| const keyframeProgress = (currentTime - keyframeStart) / (keyframeEnd - keyframeStart); | |
| if (keyframeProgress > 1) { | |
| if (currentKeyframe + 1 > lastKeyframe) { | |
| return animationEnded(); | |
| } | |
| setStateOfSprite(spriteTarget, keyframe); | |
| currentState = getStateOfSprite(spriteTarget); | |
| currentKeyframe += 1; | |
| // wait another step to continue the next frame | |
| return; | |
| } | |
| this._progressAnimation(spriteTarget, currentState, keyframe, keyframe.easingMode, keyframe.easingDir, keyframeProgress); | |
| }; | |
| frameHandler(); | |
| this.runtime.once('PROJECT_STOP_ALL', stopAllHandler); | |
| this.runtime.on('RUNTIME_STEP_START', frameHandler); | |
| this.runtime.on('ANIMATIONS_FORCE_STEP', frameHandler); | |
| this.runtime.on('ANIMATIONS_FORCE_SPECIFIC_STEP', forceSpecificStepHandler); | |
| } | |
| pauseAnimation(args, util) { | |
| const id = util.target.id; | |
| if (!this.progressingTargets.includes(id)) return; // we arent doing ANY animation | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const info = this.progressingTargetData[id]; | |
| if (!info) return; | |
| if (!(animationName in info)) { | |
| return; | |
| } | |
| info[animationName].paused = true; | |
| } | |
| unpauseAnimation(args, util) { | |
| const id = util.target.id; | |
| if (!this.progressingTargets.includes(id)) return; // we arent doing ANY animation | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const info = this.progressingTargetData[id]; | |
| if (!info) return; | |
| if (!(animationName in info)) { | |
| return; | |
| } | |
| info[animationName].paused = false; | |
| } | |
| stopAnimation(args, util) { | |
| const id = util.target.id; | |
| if (!this.progressingTargets.includes(id)) return; // we arent doing ANY animation | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return; | |
| } | |
| const info = this.progressingTargetData[id]; | |
| if (!info) return; | |
| if (!(animationName in info)) { | |
| return; | |
| } | |
| info[animationName].forceStop = true; | |
| this.runtime.emit('ANIMATIONS_FORCE_SPECIFIC_STEP', id, animationName); | |
| } | |
| isPausedAnimation(args, util) { // HIDDEN FROM PALETTE | |
| const id = util.target.id; | |
| if (!this.progressingTargets.includes(id)) return false; // we arent doing ANY animation | |
| const animationName = Cast.toString(args.ANIM); | |
| if (!(animationName in this.animations)) { | |
| return false; | |
| } | |
| const info = this.progressingTargetData[id]; | |
| if (!info) return; | |
| if (!(animationName in info)) { | |
| return false; | |
| } | |
| return info[animationName].paused; | |
| } | |
| isPropertyAnimation(args, util) { | |
| const id = util.target.id; | |
| if (!this.progressingTargets.includes(id)) return false; // we arent doing ANY animation (we arent paused OR playing) | |
| const animationName = Cast.toString(args.ANIM); | |
| const animationDataProp = Cast.toString(args.ANIMPROP); | |
| if (!(animationName in this.animations)) { | |
| return false; // (we arent paused OR playing) | |
| } | |
| const info = this.progressingTargetData[id]; | |
| if (!info) return false; // (we arent paused OR playing) | |
| if (!(animationName in info)) { | |
| return false; // (we arent paused OR playing) | |
| } | |
| if (animationDataProp === 'paused') { | |
| return info[animationName].paused; | |
| } | |
| return true; // data exists, therefore we are playing the animation currently | |
| } | |
| } | |
| module.exports = AnimationExtension; |