soiz1's picture
Upload 811 files
30c32c8 verified
raw
history blame
49.9 kB
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;