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;