Spaces:
Build error
Build 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; |