blockbench/js/animations/animation.js
JannisX11 8a3c7bd28b Fix #1547 Scale keyframes incorrectly set to uniform
Fix #1549 Image adjusting sliders make translucent pixels opaque
Fix an issue with custom animation channels
Fix colors in Wintersky particles, update tinycolor to 1.4.2
Fix vertex snap not working on cubes
2022-09-02 23:31:32 +02:00

2201 lines
69 KiB
JavaScript

class Animation {
constructor(data) {
this.name = '';
this.uuid = guid()
this.loop = 'once';
this.playing = false;
this.override = false;
this.selected = false;
this.length = 0;
this.snapping = Math.clamp(settings.animation_snap.value, 10, 500);
this.animators = {};
this.markers = [];
for (var key in Animation.properties) {
Animation.properties[key].reset(this);
}
if (typeof data === 'object') {
this.extend(data);
if (isApp && Format.animation_files && data.saved_name) {
this.saved_name = data.saved_name;
}
}
}
extend(data) {
for (var key in Animation.properties) {
Animation.properties[key].merge(this, data)
}
Merge.string(this, data, 'name')
Merge.string(this, data, 'loop', val => ['once', 'loop', 'hold'].includes(val))
Merge.boolean(this, data, 'override')
Merge.number(this, data, 'length')
Merge.number(this, data, 'snapping')
this.snapping = Math.clamp(this.snapping, 10, 500);
if (typeof data.length == 'number') {
this.setLength(this.length)
}
if (data.bones && !data.animators) {
data.animators = data.bones;
}
if (data.animators instanceof Object) {
for (var key in data.animators) {
let animator_blueprint = data.animators[key];
// Update to 3.7
if (animator_blueprint instanceof Array) {
animator_blueprint = {
keyframes: animator_blueprint
}
}
var kfs = animator_blueprint.keyframes;
var animator;
if (!this.animators[key]) {
if (key == 'effects') {
animator = this.animators[key] = new EffectAnimator(this);
} else if (animator_blueprint.type && animator_blueprint.type !== 'bone') {
let uuid = isUUID(key) && key;
let element;
if (!uuid) {
let lowercase_name = key.toLowerCase();
element = Outliner.elements.find(element => element.constructor.animator && element.name.toLowerCase() == lowercase_name)
uuid = element ? element.uuid : guid();
}
if (!element) element = Outliner.elements.find(element => element.constructor.animator && element.uuid == uuid);
animator = this.animators[uuid] = new element.constructor.animator(uuid, this, animator_blueprint.name)
} else {
let uuid = isUUID(key) && key;
if (!uuid) {
let lowercase_bone_name = key.toLowerCase();
let group_match = Group.all.find(group => group.name.toLowerCase() == lowercase_bone_name)
uuid = group_match ? group_match.uuid : guid();
}
animator = this.animators[uuid] = new BoneAnimator(uuid, this, animator_blueprint.name)
}
} else {
animator = this.animators[key];
for (let channel in animator.channels) {
animator[channel].empty()
}
}
if (kfs && animator) {
kfs.forEach(kf_data => {
animator.addKeyframe(kf_data, kf_data.uuid);
})
}
}
}
if (data.markers instanceof Array) {
data.markers.forEach(marker => {
if (!this.markers.find(m2 => Math.epsilon(m2.time, marker.time, 0.001))) {
this.markers.push(new TimelineMarker(marker));
}
})
}
return this;
}
getUndoCopy(options, save) {
var copy = {
uuid: this.uuid,
name: this.name,
loop: this.loop,
override: this.override,
length: this.length,
snapping: this.snapping,
selected: this.selected,
}
for (var key in Animation.properties) {
Animation.properties[key].copy(this, copy)
}
if (this.markers.length) {
copy.markers = this.markers.map(marker => marker.getUndoCopy());
}
if (Object.keys(this.animators).length) {
copy.animators = {}
for (var uuid in this.animators) {
let ba = this.animators[uuid]
var kfs = ba.keyframes
if (kfs && kfs.length) {
let ba_copy = copy.animators[uuid] = {
name: ba.name,
type: ba.type,
keyframes: []
}
kfs.forEach(kf => {
ba_copy.keyframes.push(kf.getUndoCopy(true));
})
}
}
}
return copy;
}
compileBedrockAnimation() {
let ani_tag = {};
if (this.loop == 'hold') {
ani_tag.loop = 'hold_on_last_frame';
} else if (this.loop == 'loop' || this.getMaxLength() == 0) {
ani_tag.loop = true;
}
if (this.length) ani_tag.animation_length = this.length;
if (this.override) ani_tag.override_previous_animation = true;
if (this.anim_time_update) ani_tag.anim_time_update = this.anim_time_update.replace(/\n/g, '');
if (this.blend_weight) ani_tag.blend_weight = this.blend_weight.replace(/\n/g, '');
if (this.start_delay) ani_tag.start_delay = this.start_delay.replace(/\n/g, '');
if (this.loop_delay && ani_tag.loop) ani_tag.loop_delay = this.loop_delay.replace(/\n/g, '');
ani_tag.bones = {};
for (var uuid in this.animators) {
var animator = this.animators[uuid];
if (!animator.keyframes.length) continue;
if (animator instanceof EffectAnimator) {
animator.sound.sort((kf1, kf2) => (kf1.time - kf2.time)).forEach(kf => {
if (!ani_tag.sound_effects) ani_tag.sound_effects = {};
ani_tag.sound_effects[kf.getTimecodeString()] = kf.compileBedrockKeyframe();
})
animator.particle.sort((kf1, kf2) => (kf1.time - kf2.time)).forEach(kf => {
if (!ani_tag.particle_effects) ani_tag.particle_effects = {};
ani_tag.particle_effects[kf.getTimecodeString()] = kf.compileBedrockKeyframe();
})
animator.timeline.sort((kf1, kf2) => (kf1.time - kf2.time)).forEach(kf => {
if (!ani_tag.timeline) ani_tag.timeline = {};
ani_tag.timeline[kf.getTimecodeString()] = kf.compileBedrockKeyframe()
})
} else if (animator.type == 'bone') {
var group = animator.getGroup();
var bone_tag = ani_tag.bones[group ? group.name : animator.name] = {};
var channels = {};
//Saving Keyframes
animator.keyframes.forEach(function(kf) {
if (!channels[kf.channel]) {
channels[kf.channel] = {};
}
let timecode = kf.getTimecodeString();
channels[kf.channel][timecode] = kf.compileBedrockKeyframe()
})
// Sorting + compressing keyframes
for (var channel in Animator.possible_channels) {
if (channels[channel]) {
let timecodes = Object.keys(channels[channel])
if (timecodes.length === 1 && animator[channel][0].data_points.length == 1 && animator[channel][0].interpolation != 'catmullrom') {
bone_tag[channel] = channels[channel][timecodes[0]]
if (channel == 'scale' &&
channels[channel][timecodes[0]] instanceof Array &&
channels[channel][timecodes[0]].allEqual(channels[channel][timecodes[0]][0])
) {
bone_tag[channel] = channels[channel][timecodes[0]][0];
}
} else {
timecodes.sort((a, b) => parseFloat(a) - parseFloat(b)).forEach((timecode) => {
if (!bone_tag[channel]) {
bone_tag[channel] = {}
}
bone_tag[channel][timecode] = channels[channel][timecode];
})
}
}
}
}
}
// Inverse Kinematics
let ik_samples = this.sampleIK();
let sample_rate = settings.animation_sample_rate.value;
for (let uuid in ik_samples) {
let group = OutlinerNode.uuids[uuid];
var bone_tag = ani_tag.bones[group ? group.name : animator.name] = {};
bone_tag.rotation = {};
ik_samples[uuid].forEach((rotation, i) => {
let timecode = trimFloatNumber(Timeline.snapTime(i / sample_rate, this)).toString();
if (!timecode.includes('.')) {
timecode += '.0';
}
bone_tag.rotation[timecode] = rotation.array;
})
}
if (Object.keys(ani_tag.bones).length == 0) {
delete ani_tag.bones;
}
return ani_tag;
}
sampleIK(sample_rate = settings.animation_sample_rate.value) {
let interval = 1 / Math.clamp(sample_rate, 1, 144);
let last_time = Timeline.time;
let samples = {};
if (!NullObject.all.find(null_object => null_object.ik_target && this.getBoneAnimator(null_object).position.length)) return samples;
Timeline.time = 0;
while (Timeline.time <= this.length && Timeline.time <= 200) {
// Bones
Animator.showDefaultPose(true);
Group.all.forEach(node => {
Animator.resetLastValues();
Animator.animations.forEach(animation => {
let multiplier = animation.blend_weight ? Math.clamp(Animator.MolangParser.parse(animation.blend_weight), 0, Infinity) : 1;
if (animation.playing) {
animation.getBoneAnimator(node).displayFrame(multiplier);
}
})
})
Outliner.elements.forEach(node => {
if (!node.constructor.animator) return;
Animator.resetLastValues();
let multiplier = this.blend_weight ? Math.clamp(Animator.MolangParser.parse(this.blend_weight), 0, Infinity) : 1;
let animator = this.getBoneAnimator(node);
animator.displayPosition(animator.interpolate('position'), multiplier);
let bone_frame_rotation = animator.displayIK(true);
for (let uuid in bone_frame_rotation) {
if (!samples[uuid]) samples[uuid] = [];
samples[uuid].push(bone_frame_rotation[uuid]);
}
})
Animator.resetLastValues();
Timeline.time += interval;
}
Timeline.time = last_time;
if (Modes.animate && this.selected) {
Animator.preview();
} else {
Canvas.updateAllBones()
}
return samples;
}
save() {
if (isApp && !this.path) {
Blockbench.export({
resource_id: 'animation',
type: 'JSON Animation',
extensions: ['json'],
name: (Project.geometry_name||'model')+'.animation',
startpath: this.path,
custom_writer: (content, path) => {
if (!path) return
this.path = path;
this.save();
}
})
return;
}
let content = {
format_version: '1.8.0',
animations: {
[this.name]: this.compileBedrockAnimation()
}
}
if (isApp && this.path) {
if (fs.existsSync(this.path)) {
//overwrite path
let data;
try {
data = fs.readFileSync(this.path, 'utf-8');
data = autoParseJSON(data, false);
if (typeof data.animations !== 'object') {
throw 'Incompatible format'
}
} catch (err) {
data = null;
var answer = electron.dialog.showMessageBoxSync(currentwindow, {
type: 'warning',
buttons: [
tl('message.bedrock_overwrite_error.overwrite'),
tl('dialog.cancel')
],
title: 'Blockbench',
message: tl('message.bedrock_overwrite_error.message'),
detail: err+'',
noLink: false
})
if (answer === 1) {
return;
}
}
if (data) {
let animation = content.animations[this.name];
content = data;
if (this.saved_name && this.saved_name !== this.name) delete content.animations[this.saved_name];
content.animations[this.name] = animation;
// Sort
let file_keys = Object.keys(content.animations);
let anim_keys = Animation.all.filter(anim => anim.path == this.path).map(anim => anim.name);
let changes = false;
let index = 0;
anim_keys.forEach(key => {
let key_index = file_keys.indexOf(key);
if (key_index == -1) {
//Skip
} else if (key_index < index) {
file_keys.splice(key_index, 1);
file_keys.splice(index, 0, key);
changes = true;
} else {
index = key_index;
}
})
if (changes) {
let sorted_animations = {};
file_keys.forEach(key => {
sorted_animations[key] = content.animations[key];
})
content.animations = sorted_animations;
}
}
}
// Write
Blockbench.writeFile(this.path, {content: compileJSON(content)}, (real_path) => {
this.saved = true;
this.saved_name = this.name;
this.path = real_path;
});
} else {
// Web Download
Blockbench.export({
resource_id: 'animation',
type: 'JSON Animation',
extensions: ['json'],
name: (Project.geometry_name||'model')+'.animation',
startpath: this.path,
content: compileJSON(content),
}, (real_path) => {
this.path == real_path;
this.saved = true;
})
}
return this;
}
select() {
var scope = this;
Prop.active_panel = 'animations';
if (this == Animation.selected) return;
var selected_bone = Group.selected;
Animator.animations.forEach(function(a) {
a.selected = a.playing = false;
})
Timeline.clear();
Timeline.vue._data.markers = this.markers;
Timeline.vue._data.animation_length = this.length;
this.selected = true;
this.playing = true;
Animation.selected = this;
unselectAll();
BarItems.slider_animation_length.update();
Group.all.forEach(group => {
scope.getBoneAnimator(group);
})
Outliner.elements.forEach(element => {
if (!element.constructor.animator) return;
scope.getBoneAnimator(element);
})
if (selected_bone) {
selected_bone.select();
}
if (Modes.animate) {
Animator.preview();
}
return this;
}
setLength(len = this.length) {
this.length = 0;
this.length = limitNumber(len, this.getMaxLength(), 1e4);
if (Animation.selected == this) {
Timeline.vue._data.animation_length = this.length;
BarItems.slider_animation_length.update()
}
}
get time() {
return (this.length && this.loop === 'loop') ? ((Timeline.time - 0.001) % this.length) + 0.001 : Timeline.time;
}
createUniqueName(arr) {
var scope = this;
var others = Animator.animations;
if (arr && arr.length) {
arr.forEach(g => {
others.safePush(g)
})
}
var name = this.name.replace(/\d+$/, '');
function check(n) {
for (var i = 0; i < others.length; i++) {
if (others[i] !== scope && others[i].name == n) return false;
}
return true;
}
if (check(this.name)) {
return this.name;
}
for (var num = 2; num < 8e3; num++) {
if (check(name+num)) {
scope.name = name+num;
return scope.name;
}
}
return false;
}
rename() {
var scope = this;
Blockbench.textPrompt('generic.rename', this.name, function(name) {
if (name && name !== scope.name) {
Undo.initEdit({animations: [scope]});
scope.name = name;
scope.createUniqueName();
Undo.finishEdit('Rename animation');
}
})
return this;
}
togglePlayingState(state) {
if (!this.selected) {
this.playing = state !== undefined ? state : !this.playing;
Animator.preview();
} else {
Timeline.start();
}
return this.playing;
}
showContextMenu(event) {
this.select();
this.menu.open(event, this);
return this;
}
getBoneAnimator(group) {
if (!group && Group.selected) {
group = Group.selected;
} else if (!group && (Outliner.selected[0] && Outliner.selected[0].constructor.animator)) {
group = Outliner.selected[0];
} else if (!group) {
return;
}
var uuid = group.uuid
if (!this.animators[uuid]) {
let match;
for (let uuid2 in this.animators) {
let animator = this.animators[uuid2];
if (
animator instanceof BoneAnimator &&
animator._name && animator._name.toLowerCase() === group.name.toLowerCase() &&
!animator.group
) {
match = animator;
match.uuid = group.uuid;
delete this.animators[uuid2];
break;
}
}
this.animators[uuid] = match || new group.constructor.animator(uuid, this);
}
return this.animators[uuid];
}
add(undo) {
if (undo) {
Undo.initEdit({animations: []})
}
if (!Animator.animations.includes(this)) {
Animator.animations.push(this)
}
if (undo) {
this.select()
Undo.finishEdit('Add animation', {animations: [this]})
}
return this;
}
remove(undo, remove_from_file = true) {
if (undo) {
Undo.initEdit({animations: [this]})
}
Animator.animations.remove(this)
if (undo) {
Undo.finishEdit('Remove animation', {animations: []})
if (isApp && remove_from_file && this.path && fs.existsSync(this.path)) {
Blockbench.showMessageBox({
translateKey: 'delete_animation',
icon: 'movie',
buttons: ['generic.delete', 'dialog.cancel'],
confirm: 0,
cancel: 1,
}, (result) => {
if (result == 0) {
let content = fs.readFileSync(this.path, 'utf-8');
let json = autoParseJSON(content, false);
if (json && json.animations && json.animations[this.name]) {
delete json.animations[this.name];
Blockbench.writeFile(this.path, {content: compileJSON(json)});
Undo.history.last().before.animations[this.uuid].saved = false
}
}
})
}
}
Blockbench.dispatchEvent('remove_animation', {animations: [this]})
if (Animation.selected === this) {
Animation.selected = null;
Timeline.clear();
Animator.preview();
}
return this;
}
getMaxLength() {
var len = this.length||0
for (var uuid in this.animators) {
var bone = this.animators[uuid]
var keyframes = bone.keyframes;
if (keyframes.find(kf => kf.interpolation == 'catmullrom')) {
keyframes = keyframes.slice().sort((a, b) => a.time - b.time);
}
keyframes.forEach((kf, i) => {
if (kf.interpolation == 'catmullrom' && i == keyframes.length-1) return;
len = Math.max(len, keyframes[i].time);
})
}
return len
}
setLoop(value, undo) {
if ((value == 'once' || value == 'loop' || value == 'hold') && value !== this.loop) {
if (undo) Undo.initEdit({animations: [this]})
this.loop = value;
if (undo) Undo.finishEdit('Change animation loop mode')
}
}
calculateSnappingFromKeyframes() {
let timecodes = [];
for (var key in this.animators) {
let animator = this.animators[key];
animator.keyframes.forEach(kf => {
timecodes.safePush(kf.time);
})
}
if (timecodes.length > 1) {
for (var i = 10; i <= 100; i++) {
let works = true;
for (var timecode of timecodes) {
let factor = (timecode * i) % 1;
if (factor > 0.01 && factor < 0.99) {
works = false;
break;
}
}
if (works) {
this.snapping = i;
return this.snapping;
}
}
}
}
propertiesDialog() {
let dialog = new Dialog({
id: 'animation_properties',
title: this.name,
width: 660,
part_order: ['form', 'component'],
form: {
name: {label: 'generic.name', value: this.name},
path: {
label: 'menu.animation.file',
value: this.path,
type: 'file',
extensions: ['json'],
filetype: 'JSON Animation',
condition: Animation.properties.path.condition
},
loop: {
label: 'menu.animation.loop',
type: 'select',
value: this.loop,
options: {
once: 'menu.animation.loop.once',
hold: 'menu.animation.loop.hold',
loop: 'menu.animation.loop.loop',
},
},
override: {label: 'menu.animation.override', type: 'checkbox', value: this.override},
snapping: {label: 'menu.animation.snapping', type: 'number', value: this.snapping, step: 1, min: 10, max: 500},
},
component: {
components: {VuePrismEditor},
data: {
anim_time_update: this.anim_time_update,
blend_weight: this.blend_weight,
start_delay: this.start_delay,
loop_delay: this.loop_delay,
loop_mode: this.loop
},
template:
`<div id="animation_properties_vue">
<div class="dialog_bar form_bar">
<label class="name_space_left">${tl('menu.animation.anim_time_update')}</label>
<vue-prism-editor class="molang_input dark_bordered" v-model="anim_time_update" language="molang" :line-numbers="false" />
</div>
<div class="dialog_bar form_bar">
<label class="name_space_left">${tl('menu.animation.blend_weight')}</label>
<vue-prism-editor class="molang_input dark_bordered" v-model="blend_weight" language="molang" :line-numbers="false" />
</div>
<div class="dialog_bar form_bar">
<label class="name_space_left">${tl('menu.animation.start_delay')}</label>
<vue-prism-editor class="molang_input dark_bordered" v-model="start_delay" language="molang" :line-numbers="false" />
</div>
<div class="dialog_bar form_bar" v-if="loop_mode == 'loop'">
<label class="name_space_left">${tl('menu.animation.loop_delay')}</label>
<vue-prism-editor class="molang_input dark_bordered" v-model="loop_delay" language="molang" :line-numbers="false" />
</div>
</div>`
},
onFormChange(form) {
this.component.data.loop_mode = form.loop;
},
onConfirm: form_data => {
dialog.hide().delete();
if (
form_data.loop != this.loop
|| form_data.name != this.name
|| (isApp && form_data.path != this.path)
|| form_data.loop != this.loop
|| form_data.override != this.override
|| form_data.snapping != this.snapping
|| dialog.component.data.anim_time_update != this.anim_time_update
|| dialog.component.data.blend_weight != this.blend_weight
|| dialog.component.data.start_delay != this.start_delay
|| dialog.component.data.loop_delay != this.loop_delay
) {
Undo.initEdit({animations: [this]});
this.extend({
loop: form_data.loop,
name: form_data.name,
override: form_data.override,
snapping: form_data.snapping,
anim_time_update: dialog.component.data.anim_time_update.trim().replace(/\n/g, ''),
blend_weight: dialog.component.data.blend_weight.trim().replace(/\n/g, ''),
start_delay: dialog.component.data.start_delay.trim().replace(/\n/g, ''),
loop_delay: dialog.component.data.loop_delay.trim().replace(/\n/g, ''),
})
this.createUniqueName();
if (isApp) this.path = form_data.path;
Blockbench.dispatchEvent('edit_animation_properties', {animation: this})
Undo.finishEdit('Edit animation properties');
}
},
onCancel() {
dialog.hide().delete();
}
})
dialog.show();
}
}
Object.defineProperty(Animation, 'all', {
get() {
return Project.animations || [];
},
set(arr) {
Project.animations.replace(arr);
}
})
Animation.selected = null;
Animation.prototype.menu = new Menu([
'copy',
'paste',
'duplicate',
'_',
{name: 'menu.animation.loop', icon: 'loop', children: [
{name: 'menu.animation.loop.once', icon: animation => (animation.loop == 'once' ? 'radio_button_checked' : 'radio_button_unchecked'), click(animation) {animation.setLoop('once', true)}},
{name: 'menu.animation.loop.hold', icon: animation => (animation.loop == 'hold' ? 'radio_button_checked' : 'radio_button_unchecked'), click(animation) {animation.setLoop('hold', true)}},
{name: 'menu.animation.loop.loop', icon: animation => (animation.loop == 'loop' ? 'radio_button_checked' : 'radio_button_unchecked'), click(animation) {animation.setLoop('loop', true)}},
]},
'_',
{
name: 'menu.animation.save',
id: 'save',
icon: 'save',
condition: () => Format.animation_files,
click(animation) {
animation.save();
}
},
{
name: 'menu.animation.open_location',
id: 'open_location',
icon: 'folder',
condition(animation) {return isApp && Format.animation_files && animation.path && fs.existsSync(animation.path)},
click(animation) {
shell.showItemInFolder(animation.path);
}
},
'rename',
{
id: 'unload',
name: 'menu.animation.unload',
icon: 'remove',
condition: () => Format.animation_files,
click(animation) {
Undo.initEdit({animations: [animation]})
animation.remove(false, false);
Undo.finishEdit('Unload animation', {animations: []})
}
},
'delete',
'_',
{name: 'menu.animation.properties', icon: 'list', click(animation) {
animation.propertiesDialog();
}}
])
Animation.prototype.file_menu = new Menu([
{name: 'menu.animation_file.unload', icon: 'clear_all', click(id) {
let animations_to_remove = [];
Animation.all.forEach(animation => {
if (animation.path == id && animation.saved) {
animations_to_remove.push(animation);
}
})
if (!animations_to_remove.length) return;
Undo.initEdit({animations: animations_to_remove})
animations_to_remove.forEach(animation => {
animation.remove(false, false);
})
Undo.finishEdit('Unload animation file', {animations: []})
}},
{name: 'menu.animation_file.import_remaining', icon: 'playlist_add', click(id) {
Blockbench.read([id], {}, files => {
Animator.importFile(files[0]);
})
}}
])
new Property(Animation, 'boolean', 'saved', {default: true, condition: () => Format.animation_files})
new Property(Animation, 'string', 'path', {condition: () => isApp && Format.animation_files})
new Property(Animation, 'molang', 'anim_time_update', {default: ''});
new Property(Animation, 'molang', 'blend_weight', {default: ''});
new Property(Animation, 'molang', 'start_delay', {default: ''});
new Property(Animation, 'molang', 'loop_delay', {default: ''});
Blockbench.on('finish_edit', event => {
if (!Format.animation_files) return;
if (event.aspects.animations && event.aspects.animations.length) {
event.aspects.animations.forEach(animation => {
if (Undo.current_save && Undo.current_save.aspects.animations instanceof Array && Undo.current_save.aspects.animations.includes(animation)) {
animation.saved = false;
}
})
}
if (event.aspects.keyframes && event.aspects.keyframes instanceof Array && Animation.selected) {
Animation.selected.saved = false;
}
})
const WinterskyScene = new Wintersky.Scene({
fetchTexture: isApp && function(config) {
if (config.preview_texture) {
return config.preview_texture;
}
if (config.file_path && config.particle_texture_path) {
let path_arr = config.file_path.split(PathModule.sep);
let particle_index = path_arr.indexOf('particles')
path_arr.splice(particle_index)
let filePath = PathModule.join(path_arr.join(PathModule.sep), config.particle_texture_path.replace(/\.png$/, '')+'.png')
if (fs.existsSync(filePath)) {
config.preview_texture = filePath;
return filePath;
}
}
}
});
WinterskyScene.global_options.scale = 16;
WinterskyScene.global_options.loop_mode = 'auto';
WinterskyScene.global_options.parent_mode = 'entity';
Prism.languages.molang['function-name'] = /\b(?!\d)(math\.\w+|button)(?=[\t ]*\()/i;
const Animator = {
get possible_channels() {
let obj = {};
Object.assign(obj, BoneAnimator.prototype.channels, EffectAnimator.prototype.channels);
return obj;
},
open: false,
get animations() {return Animation.all},
get selected() {return Animation.selected},
MolangParser: new Molang(),
motion_trail: new THREE.Object3D(),
motion_trail_lock: false,
_last_values: {},
resetLastValues() {
for (let channel in BoneAnimator.prototype.channels) {
if (BoneAnimator.prototype.channels[channel].transform) Animator._last_values[channel] = [0, 0, 0];
}
},
join() {
if (isApp && (Format.id == 'bedrock' || Format.id == 'bedrock_old') && !Project.BedrockEntityManager.initialized_animations) {
Project.BedrockEntityManager.initAnimations();
}
Animator.open = true;
Canvas.updateAllBones()
scene.add(WinterskyScene.space);
WinterskyScene.global_options.tick_rate = settings.particle_tick_rate.value;
if (settings.motion_trails.value) scene.add(Animator.motion_trail);
Animator.motion_trail.no_export = true;
if (!Animator.timeline_node) {
Animator.timeline_node = Panels.timeline.node;
}
updateInterface()
if (Panels.element) {
Toolbars.element_origin.toPlace('bone_origin')
}
if (!Timeline.is_setup) {
Timeline.setup()
}
if (Canvas.outlines.children.length) {
Canvas.outlines.children.empty()
Canvas.updateAllPositions()
}
if (Animation.all.length && !Animation.all.includes(Animation.selected)) {
Animation.all[0].select();
} else if (!Animation.all.length) {
Timeline.selected.empty();
}
if (Group.selected) {
Group.selected.select();
}
BarItems.slider_animation_length.update();
Animator.preview();
},
leave() {
Timeline.pause()
Animator.open = false;
scene.remove(WinterskyScene.space);
scene.remove(Animator.motion_trail);
Animator.resetParticles(true);
three_grid.position.z = three_grid.position.x;
Canvas.ground_plane.position.z = Canvas.ground_plane.position.x;
if (Panels.element) {
let anchor = Panels.element.node.querySelector('#element_origin_toolbar_anchor');
if (anchor) anchor.before(Toolbars.element_origin.node);
}
if (Project) Project.model_3d.scale.set(1, 1, 1);
Canvas.updateAllBones()
},
showDefaultPose(no_matrix_update) {
[...Group.all, ...Outliner.elements].forEach(node => {
if (!node.constructor.animator) return;
var mesh = node.mesh;
if (mesh.fix_rotation) mesh.rotation.copy(mesh.fix_rotation);
if (mesh.fix_position) mesh.position.copy(mesh.fix_position);
mesh.scale.x = mesh.scale.y = mesh.scale.z = 1;
})
if (!no_matrix_update) scene.updateMatrixWorld()
},
resetParticles(optimized) {
for (var path in Animator.particle_effects) {
let {emitters} = Animator.particle_effects[path];
for (var uuid in emitters) {
let emitter = emitters[uuid];
if (emitter.local_space.parent) emitter.local_space.parent.remove(emitter.local_space);
if (emitter.global_space.parent) emitter.global_space.parent.remove(emitter.global_space);
}
}
},
showMotionTrail(target) {
if (!target) {
target = Project.motion_trail_lock && OutlinerNode.uuids[Project.motion_trail_lock];
if (!target) {
target = Group.selected || ((Outliner.selected[0] && Outliner.selected[0].constructor.animator) ? Outliner.selected[0] : null);
}
}
if (!target) return;
let animation = Animation.selected;
let currentTime = Timeline.time;
let step = Timeline.getStep();
let max_time = Math.max(Timeline.time, animation.getMaxLength());
if (!max_time) max_time = 1;
let start_time = 0;
if (max_time > 20) {
start_time = Math.clamp(currentTime - 8, 0, Infinity);
max_time = Math.min(max_time, currentTime + 8);
}
let geometry = new THREE.BufferGeometry();
let bone_stack = [];
let iterate = g => {
bone_stack.push(g);
if (g.parent instanceof Group) iterate(g.parent);
}
iterate(target)
let keyframes = {};
let keyframe_source = Group.selected || ((Outliner.selected[0] && Outliner.selected[0].constructor.animator) ? Outliner.selected[0] : null);
if (keyframe_source) {
let ba = Animation.selected.getBoneAnimator(keyframe_source);
let channel = target == Group.selected ? ba.position : (ba[Toolbox.selected.animation_channel] || ba.position)
channel.forEach(kf => {
keyframes[Math.round(kf.time / step)] = kf;
})
}
function displayTime(time) {
Timeline.time = time;
let multiplier = animation.blend_weight ? Math.clamp(Animator.MolangParser.parse(animation.blend_weight), 0, Infinity) : 1;
bone_stack.forEach(node => {
let mesh = node.mesh;
let ba = animation.getBoneAnimator(node)
if (mesh.fix_rotation) mesh.rotation.copy(mesh.fix_rotation)
if (mesh.fix_position) mesh.position.copy(mesh.fix_position)
if (node instanceof NullObject) {
if (!ba.muted.position) ba.displayPosition(ba.interpolate('position'), multiplier);
} else {
mesh.scale.x = mesh.scale.y = mesh.scale.z = 1;
ba.displayFrame(multiplier);
}
})
target.mesh.updateWorldMatrix(true, false)
}
let line_positions = [];
let point_positions = [];
let keyframe_positions = []
let keyframeUUIDs = []
let i = 0;
for (var time = start_time; time <= max_time; time += step) {
displayTime(time);
let position = target instanceof Group
? THREE.fastWorldPosition(target.mesh, new THREE.Vector3())
: target.getWorldCenter(true);
position = position.toArray();
line_positions.push(...position);
let keyframe = keyframes[i];
if (keyframe) {
keyframe_positions.push(...position);
keyframeUUIDs.push(keyframe.uuid);
} else {
point_positions.push(...position);
}
i++;
}
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(line_positions), 3));
Timeline.time = currentTime;
Animator.preview();
var line = new THREE.Line(geometry, Canvas.outlineMaterial);
line.no_export = true;
Animator.motion_trail.children.forEachReverse(child => {
Animator.motion_trail.remove(child);
})
Animator.motion_trail.add(line);
let point_material = new THREE.PointsMaterial({size: 4, sizeAttenuation: false, color: Canvas.outlineMaterial.color})
let point_geometry = new THREE.BufferGeometry();
point_geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(point_positions), 3));
let points = new THREE.Points(point_geometry, point_material);
Animator.motion_trail.add(points);
let keyframe_material = new THREE.PointsMaterial({size: 10, sizeAttenuation: false, color: Canvas.outlineMaterial.color})
let keyframe_geometry = new THREE.BufferGeometry();
keyframe_geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(keyframe_positions), 3));
let keyframe_points = new THREE.Points(keyframe_geometry, keyframe_material);
keyframe_points.isKeyframe = true;
keyframe_points.keyframeUUIDs = keyframeUUIDs;
Animator.motion_trail.add(keyframe_points);
},
preview(in_loop) {
// Bones
Animator.showDefaultPose(true);
[...Group.all, ...Outliner.elements].forEach(node => {
if (!node.constructor.animator) return;
Animator.resetLastValues();
Animator.animations.forEach(animation => {
let multiplier = animation.blend_weight ? Math.clamp(Animator.MolangParser.parse(animation.blend_weight), 0, Infinity) : 1;
if (animation.playing) {
animation.getBoneAnimator(node).displayFrame(multiplier)
}
})
})
Animator.resetLastValues();
scene.updateMatrixWorld();
// Shift ground
if (Canvas.ground_plane.visible && Animation.selected && Animation.selected.anim_time_update.includes('modified_distance_moved')) {
let value = Animator.MolangParser.parse(Animation.selected.anim_time_update, {'query.modified_distance_moved': Timeline.time});
value = (Timeline.time / value) * Timeline.time * 3;
value = (value % 64) || 0;
Canvas.ground_plane.position.z = Canvas.ground_plane.position.x + value;
three_grid.position.z = three_grid.position.x + value;
} else {
three_grid.position.z = three_grid.position.x;
Canvas.ground_plane.position.z = Canvas.ground_plane.position.x;
}
// Effects
Animator.resetParticles(true);
Animator.animations.forEach(animation => {
if (animation.playing) {
if (animation.animators.effects) {
animation.animators.effects.displayFrame(in_loop);
}
}
})
if (Interface.Panels.variable_placeholders.inside_vue.text.match(/^\s*preview\.texture\s*=/mi)) {
let tex_index = Animator.MolangParser.variableHandler('preview.texture');
let texture = Texture.all[tex_index % Texture.all.length];
if (texture) texture.select();
}
if (Project) Project.model_3d.scale.set(1, 1, 1);
if (Interface.Panels.variable_placeholders.inside_vue.text.match(/^\s*preview\.scale\s*=/mi)) {
let scale = Animator.MolangParser.variableHandler('preview.scale');
Project.model_3d.scale.x = Project.model_3d.scale.y = Project.model_3d.scale.z = scale;
}
if (Interface.Panels.variable_placeholders.inside_vue.text.match(/^\s*preview\.scalex\s*=/mi)) {
let scale = Animator.MolangParser.variableHandler('preview.scalex');
Project.model_3d.scale.x = scale;
}
if (Interface.Panels.variable_placeholders.inside_vue.text.match(/^\s*preview\.scaley\s*=/mi)) {
let scale = Animator.MolangParser.variableHandler('preview.scaley');
Project.model_3d.scale.y = scale;
}
if (Interface.Panels.variable_placeholders.inside_vue.text.match(/^\s*preview\.scalez\s*=/mi)) {
let scale = Animator.MolangParser.variableHandler('preview.scalez');
Project.model_3d.scale.z = scale;
}
if (Group.selected || (Outliner.selected[0] && Outliner.selected[0].constructor.animator)) {
Transformer.updateSelection()
}
Blockbench.dispatchEvent('display_animation_frame')
},
particle_effects: {},
loadParticleEmitter(path, content) {
let json_content = autoParseJSON(content);
if (!json_content || !json_content.particle_effect) return;
if (Animator.particle_effects[path]) {
Animator.particle_effects[path].config
.reset()
.set('file_path', path)
.setFromJSON(json_content, {path});
for (var uuid in Animator.particle_effects[path].emitters) {
let emitter = Animator.particle_effects[path].emitters[uuid];
emitter.updateConfig();
}
} else {
Animator.particle_effects[path] = {
config: new Wintersky.Config(WinterskyScene, json_content, {path}),
emitters: {}
};
if (isApp) {
let timeout;
this.watcher = fs.watch(path, (eventType) => {
if (eventType == 'change') {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
Blockbench.read(path, {errorbox: false}, (files) => {
Animator.loadParticleEmitter(path, files[0].content);
})
}, 60)
}
})
}
}
return Animator.particle_effects[path];
},
loadFile(file, animation_filter) {
var json = file.json || autoParseJSON(file.content);
let path = file.path;
let new_animations = [];
function multilinify(string) {
return typeof string == 'string'
? string.replace(/;(?!$)/, ';\n')
: string
}
if (json && typeof json.animations === 'object') {
for (var ani_name in json.animations) {
if (animation_filter && !animation_filter.includes(ani_name)) continue;
//Animation
var a = json.animations[ani_name]
var animation = new Animation({
name: ani_name,
saved_name: ani_name,
path,
loop: a.loop && (a.loop == 'hold_on_last_frame' ? 'hold' : 'loop'),
override: a.override_previous_animation,
anim_time_update: multilinify(a.anim_time_update),
blend_weight: multilinify(a.blend_weight),
start_delay: multilinify(a.start_delay),
loop_delay: multilinify(a.loop_delay),
length: a.animation_length
}).add()
//Bones
if (a.bones) {
let existing_variables = [
'query.anim_time',
'query.life_time',
'query.delta_time',
'query.camera_rotation',
'query.rotation_to_camera',
'query.distance_from_camera',
'query.lod_index',
'query.camera_distance_range_lerp',
];
function processPlaceholderVariables(text) {
if (typeof text !== 'string') return;
text = text.replace(/v\./, 'variable.').replace(/q\./, 'query.').replace(/t\./, 'temp.').replace(/c\./, 'context.').toLowerCase();
let matches = text.match(/(query|variable|context|temp)\.\w+/gi);
if (!matches) return;
matches.forEach(match => {
let panel_vue = Interface.Panels.variable_placeholders.inside_vue;
if (existing_variables.includes(match)) return;
if (panel_vue.text.split('\n').find(line => line.substr(0, match.length) == match)) return;
let [space, name] = match.split(/\./);
if (panel_vue.text != '' && panel_vue.text.substr(-1) !== '\n') panel_vue.text += '\n';
if (name == 'modified_distance_moved') {
panel_vue.text += `${match} = time * 8`;
} else if (name.match(/is_|has_|can_|blocking/)) {
panel_vue.text += `${match} = toggle('${name}')`;
} else {
panel_vue.text += `${match} = slider('${name}')`;
}
})
}
function getKeyframeDataPoints(source) {
if (source instanceof Array) {
source.forEach(processPlaceholderVariables);
return [{
x: source[0],
y: source[1],
z: source[2],
}]
} else if (['number', 'string'].includes(typeof source)) {
processPlaceholderVariables(source);
return [{
x: source, y: source, z: source
}]
} else if (typeof source == 'object') {
let points = [];
if (source.pre) {
points.push(getKeyframeDataPoints(source.pre)[0])
}
if (source.post && !(source.pre instanceof Array && source.post instanceof Array && source.post.equals(source.pre))) {
points.push(getKeyframeDataPoints(source.post)[0])
}
return points;
}
}
for (var bone_name in a.bones) {
var b = a.bones[bone_name]
let lowercase_bone_name = bone_name.toLowerCase();
var group = Group.all.find(group => group.name.toLowerCase() == lowercase_bone_name)
let uuid = group ? group.uuid : guid();
var ba = new BoneAnimator(uuid, animation, bone_name);
animation.animators[uuid] = ba;
//Channels
for (var channel in b) {
if (BoneAnimator.prototype.channels[channel]) {
if (typeof b[channel] === 'string' || typeof b[channel] === 'number' || b[channel] instanceof Array) {
ba.addKeyframe({
time: 0,
channel,
uniform: !(b[channel] instanceof Array),
data_points: getKeyframeDataPoints(b[channel]),
})
} else if (typeof b[channel] === 'object' && b[channel].post) {
ba.addKeyframe({
time: 0,
channel,
interpolation: b[channel].lerp_mode,
uniform: !(b[channel].post instanceof Array),
data_points: getKeyframeDataPoints(b[channel]),
});
} else if (typeof b[channel] === 'object') {
for (var timestamp in b[channel]) {
ba.addKeyframe({
time: parseFloat(timestamp),
channel,
interpolation: b[channel][timestamp].lerp_mode,
uniform: !(b[channel][timestamp] instanceof Array),
data_points: getKeyframeDataPoints(b[channel][timestamp]),
});
}
}
}
// Set step interpolation
let sorted_keyframes = ba[channel].slice().sort((a, b) => a.time - b.time);
let last_kf_was_step = false;
sorted_keyframes.forEach((kf, i) => {
let next = sorted_keyframes[i+1];
if (next && next.data_points.length == 2 && kf.getArray(1).equals(next.getArray(0))) {
next.data_points.splice(0, 1);
kf.interpolation = 'step';
last_kf_was_step = true;
} else if (!next && last_kf_was_step) {
kf.interpolation = 'step';
}
})
}
}
}
if (a.sound_effects) {
if (!animation.animators.effects) {
animation.animators.effects = new EffectAnimator(animation);
}
for (var timestamp in a.sound_effects) {
var sounds = a.sound_effects[timestamp];
if (sounds instanceof Array === false) sounds = [sounds];
animation.animators.effects.addKeyframe({
channel: 'sound',
time: parseFloat(timestamp),
data_points: sounds
})
}
}
if (a.particle_effects) {
if (!animation.animators.effects) {
animation.animators.effects = new EffectAnimator(animation);
}
for (var timestamp in a.particle_effects) {
var particles = a.particle_effects[timestamp];
if (particles instanceof Array === false) particles = [particles];
particles.forEach(particle => {
if (particle) particle.script = particle.pre_effect_script;
})
animation.animators.effects.addKeyframe({
channel: 'particle',
time: parseFloat(timestamp),
data_points: particles
})
}
}
if (a.timeline) {
if (!animation.animators.effects) {
animation.animators.effects = new EffectAnimator(animation);
}
for (var timestamp in a.timeline) {
var entry = a.timeline[timestamp];
var script = entry instanceof Array ? entry.join('\n') : entry;
if (typeof script == 'string') {
let panel_vue = Interface.Panels.variable_placeholders.inside_vue;
let tex_variables = script.match(/(v|variable)\.texture\w*\s*=/);
if (tex_variables && !panel_vue.text.includes('preview.texture =')) {
if (panel_vue.text != '' && panel_vue.text.substr(-1) !== '\n') panel_vue.text += '\n';
panel_vue.text += `preview.texture = ${tex_variables[0].replace(/\s*=$/, '')}`
}
}
animation.animators.effects.addKeyframe({
channel: 'timeline',
time: parseFloat(timestamp),
data_points: [{script}]
})
}
}
animation.calculateSnappingFromKeyframes();
if (!Animation.selected && Animator.open) {
animation.select()
}
new_animations.push(animation)
}
}
return new_animations
},
buildFile(path_filter, name_filter) {
var animations = {}
Animator.animations.forEach(function(a) {
if ((typeof path_filter != 'string' || a.path == path_filter || (!a.path && !path_filter)) && (!name_filter || !name_filter.length || name_filter.includes(a.name))) {
let ani_tag = a.compileBedrockAnimation();
animations[a.name] = ani_tag;
}
})
return {
format_version: '1.8.0',
animations: animations
}
},
importFile(file) {
let form = {};
let json = autoParseJSON(file.content)
let keys = [];
for (var key in json.animations) {
// Test if already loaded
if (isApp && file.path) {
let is_already_loaded = false
for (var anim of Animation.all) {
if (anim.path == file.path && anim.name == key) {
is_already_loaded = true;
break;
}
}
if (is_already_loaded) {console.log(`${key} already exists`);continue;}
}
form[key.hashCode()] = {label: key, type: 'checkbox', value: true, nocolon: true};
keys.push(key);
}
file.json = json;
if (keys.length == 0) {
Blockbench.showQuickMessage('message.no_animation_to_import');
} else if (keys.length == 1) {
Undo.initEdit({animations: []})
let new_animations = Animator.loadFile(file, keys);
Undo.finishEdit('Import animations', {animations: new_animations})
} else {
return new Promise(resolve => {
let dialog = new Dialog({
id: 'animation_import',
title: 'dialog.animation_import.title',
form,
onConfirm(form_result) {
this.hide();
let names = [];
for (var key of keys) {
if (form_result[key.hashCode()]) {
names.push(key);
}
}
Undo.initEdit({animations: []})
let new_animations = Animator.loadFile(file, names);
Undo.finishEdit('Import animations', {animations: new_animations})
resolve();
},
onCancel() {
resolve();
}
});
form.select_all_none = {
type: 'buttons',
buttons: ['generic.select_all', 'generic.select_none'],
click(index) {
let values = {};
keys.forEach(key => values[key.hashCode()] = (index == 0));
dialog.setFormValues(values);
}
}
dialog.show();
});
}
},
exportAnimationFile(path) {
let filter_path = path || '';
if (isApp && !path) {
path = Project.export_path
var exp = new RegExp(osfs.replace('\\', '\\\\')+'models'+osfs.replace('\\', '\\\\'))
var m_index = path.search(exp)
if (m_index > 3) {
path = path.substr(0, m_index) + osfs + 'animations' + osfs + pathToName(Project.export_path, true)
}
path = path.replace(/(\.geo)?\.json$/, '.animation.json')
}
if (isApp && path && fs.existsSync(path)) {
Animator.animations.forEach(function(a) {
if (a.path == filter_path && !a.saved) {
a.save();
}
})
} else {
let content = Animator.buildFile(filter_path, true);
Blockbench.export({
resource_id: 'animation',
type: 'JSON Animation',
extensions: ['json'],
name: (Project.geometry_name||'model')+'.animation',
startpath: path,
content: autoStringify(content),
custom_writer: isApp && ((content, new_path, cb) => {
if (new_path && fs.existsSync(new_path)) {
Animator.animations.forEach(function(a) {
if (a.path == filter_path && !a.saved) {
a.path = new_path;
a.save();
}
})
} else {
Blockbench.writeFile(new_path, {content})
cb(new_path);
}
})
}, new_path => {
Animator.animations.forEach(function(a) {
if (a.path == filter_path) {
a.path = new_path;
a.saved = true;
}
})
})
}
}
}
Canvas.gizmos.push(Animator.motion_trail);
Blockbench.on('reset_project', () => {
for (let path in Animator.particle_effects) {
let effect = Animator.particle_effects[path];
if (isApp && effect.watcher) {
effect.watcher.close()
}
for (let uuid in effect.emitters) {
effect.emitters[uuid].delete();
delete effect.emitters[uuid];
}
delete Animator.particle_effects[path];
}
})
Clipbench.setAnimation = function() {
if (!Animation.selected) return;
Clipbench.animation = Animation.selected.getUndoCopy();
if (isApp) {
clipboard.writeHTML(JSON.stringify({type: 'animation', content: Clipbench.animation}));
}
}
Clipbench.pasteAnimation = function() {
if (isApp) {
var raw = clipboard.readHTML()
try {
var data = JSON.parse(raw)
if (data.type === 'animation' && data.content) {
Clipbench.animation = data.content
}
} catch (err) {}
}
if (!Clipbench.animation) return;
let animations = [];
Undo.initEdit({animations});
let animation = new Animation(Clipbench.animation).add(false);
animation.createUniqueName();
animation.select().propertiesDialog();
animations.push(animation);
Undo.finishEdit('Paste animation')
}
Animator.MolangParser.global_variables = {
'true': 1,
'false': 0,
get 'query.delta_time'() {
let time = (Date.now() - Timeline.last_frame_timecode + 1) / 1000;
if (time < 0) time += 1;
return Math.clamp(time, 0, 0.1);
},
get 'query.anim_time'() {
return Animation.selected ? Animation.selected.time : Timeline.time;
},
get 'query.life_time'() {
return Timeline.time;
},
'query.camera_rotation'(axis) {
let val = cameraTargetToRotation(Preview.selected.camera.position.toArray(), Preview.selected.controls.target.toArray())[axis ? 0 : 1];
if (axis == 0) val *= -1;
return val;
},
'query.rotation_to_camera'(axis) {
let val = cameraTargetToRotation([0, 0, 0], Preview.selected.camera.position.toArray())[axis ? 0 : 1] ;
if (axis == 0) val *= -1;
return val;
},
get 'query.distance_from_camera'() {
return Preview.selected.camera.position.length() / 16;
},
'query.lod_index'(indices) {
indices.sort((a, b) => a - b);
let distance = Preview.selected.camera.position.length() / 16;
let index = indices.length;
indices.forEachReverse((val, i) => {
if (distance < val) index = i;
})
return index;
},
'query.camera_distance_range_lerp'(a, b) {
let distance = Preview.selected.camera.position.length() / 16;
return Math.clamp(Math.getLerp(a, b, distance), 0, 1);
},
get 'time'() {
return Timeline.time;
}
}
Animator.MolangParser.variableHandler = function (variable) {
var inputs = Interface.Panels.variable_placeholders.inside_vue.text.split('\n');
var i = 0;
while (i < inputs.length) {
let key, val;
[key, val] = inputs[i].split(/=\s*(.+)/);
key = key.replace(/[\s;]/g, '');
key = key.replace(/^v\./, 'variable.').replace(/^q\./, 'query.').replace(/^t\./, 'temp.').replace(/^c\./, 'context.');
if (key === variable && val !== undefined) {
val = val.trim();
if (val.match(/^(slider|toggle)\(/)) {
let [type, content] = val.substring(0, val.length - 1).split(/\(/);
let [id] = content.split(/\(|, */);
id = id.replace(/['"]/g, '');
let button = Interface.Panels.variable_placeholders.inside_vue.buttons.find(b => b.id === id && b.type == type);
return button ? parseFloat(button.value) : 0;
} else {
return val[0] == `'` ? val : Animator.MolangParser.parse(val);
}
}
i++;
}
}
Blockbench.addDragHandler('animation', {
extensions: ['animation.json'],
readtype: 'text',
condition: {modes: ['animate']},
}, async function(files) {
for (let file of files) {
await Animator.importFile(file);
}
})
BARS.defineActions(function() {
new NumSlider('slider_animation_length', {
category: 'animation',
condition: () => Animator.open && Animation.selected,
getInterval(event) {
if ((event && event.shiftKey) || Pressing.overrides.shift) return 1;
return Timeline.getStep()
},
get: function() {
return Animation.selected.length
},
change: function(modify) {
Animation.selected.setLength(limitNumber(modify(Animation.selected.length), 0, 1e4))
},
onBefore: function() {
Undo.initEdit({animations: [Animation.selected]});
},
onAfter: function() {
Undo.finishEdit('Change animation length')
}
})
new Action('set_animation_end', {
icon: 'keyboard_tab',
category: 'animation',
condition: {modes: ['animate'], method: () => Animation.selected},
keybind: new Keybind({ctrl: true, key: 35}),
click: function () {
Undo.initEdit({animations: [Animation.selected]});
Animation.selected.setLength(Timeline.time);
Undo.finishEdit('Set animation length');
}
})
new Action('add_animation', {
icon: 'fa-plus-circle',
category: 'animation',
condition: {modes: ['animate']},
click: function () {
new Animation({
name: 'animation.' + (Project.geometry_name||'model') + '.new'
}).add(true).propertiesDialog()
}
})
new Action('load_animation_file', {
icon: 'fa-file-video',
category: 'animation',
condition: {modes: ['animate'], method: () => Format.animation_files},
click: function () {
var path = Project.export_path
if (isApp) {
var exp = new RegExp(osfs.replace('\\', '\\\\')+'models'+osfs.replace('\\', '\\\\'))
var m_index = path.search(exp)
if (m_index > 3) {
path = path.substr(0, m_index) + osfs + 'animations' + osfs + pathToName(Project.export_path).replace(/\.geo/, '.animation')
}
}
Blockbench.import({
resource_id: 'animation',
extensions: ['json'],
type: 'JSON Animation',
multiple: true,
startpath: path
}, async function(files) {
for (let file of files) {
await Animator.importFile(file);
}
})
}
})
new Action('export_animation_file', {
icon: 'movie',
category: 'animation',
click: function () {
let form = {};
let keys = [];
let animations = Animation.all.slice()
if (Format.animation_files) animations.sort((a1, a2) => a1.path.hashCode() - a2.path.hashCode())
animations.forEach(animation => {
let key = animation.name;
keys.push(key)
form[key.hashCode()] = {label: key, type: 'checkbox', value: true};
})
let dialog = new Dialog({
id: 'animation_export',
title: 'dialog.animation_export.title',
form,
onConfirm(form_result) {
dialog.hide();
keys = keys.filter(key => form_result[key.hashCode()])
let content = Animator.buildFile(null, keys)
Blockbench.export({
resource_id: 'animation',
type: 'JSON Animation',
extensions: ['json'],
name: (Project.geometry_name||'model')+'.animation',
content: autoStringify(content),
})
}
})
dialog.show();
}
})
new Action('save_all_animations', {
icon: 'save',
category: 'animation',
condition: () => Format.animation_files,
click: function () {
let paths = [];
Animation.all.forEach(animation => {
paths.safePush(animation.path);
})
paths.forEach(path => {
Animator.exportAnimationFile(path);
})
}
})
// Motion Trail
new Toggle('lock_motion_trail', {
icon: 'lock_open',
category: 'animation',
condition: () => Animator.open && (Group.selected || (Outliner.selected[0] && Outliner.selected[0].constructor.animator)),
onChange(value) {
if (value && (Group.selected || (Outliner.selected[0] && Outliner.selected[0].constructor.animator))) {
Project.motion_trail_lock = Group.selected ? Group.selected.uuid : Outliner.selected[0].uuid;
} else {
Project.motion_trail_lock = false;
Animator.showMotionTrail();
}
}
})
new Action('bake_animation_into_model', {
icon: 'directions_run',
category: 'animation',
condition: {modes: ['animate']},
click: function () {
let elements = Outliner.elements;
Undo.initEdit({elements, outliner: true});
let animatable_elements = Outliner.elements.filter(el => el.constructor.animator);
[...Group.all, ...animatable_elements].forEach(node => {
let offset_rotation = [0, 0, 0];
let offset_position = [0, 0, 0];
Animator.animations.forEach(animation => {
if (animation.playing) {
let animator = animation.getBoneAnimator(node);
let multiplier = animation.blend_weight ? Math.clamp(Animator.MolangParser.parse(animation.blend_weight), 0, Infinity) : 1;
if (node instanceof Group) {
let rotation = animator.interpolate('rotation');
let position = animator.interpolate('position');
if (rotation instanceof Array) offset_rotation.V3_add(rotation.map(v => v * multiplier));
if (position instanceof Array) offset_position.V3_add(position.map(v => v * multiplier));
}
}
})
// Rotation
if (node.rotatable) {
node.rotation[0] -= offset_rotation[0];
node.rotation[1] -= offset_rotation[1];
node.rotation[2] += offset_rotation[2];
}
// Position
function offset(node) {
if (node instanceof Group) {
node.origin.V3_add(offset_position);
node.children.forEach(offset);
} else {
if (node.from) node.from.V3_add(offset_position);
if (node.to) node.to.V3_add(offset_position);
if (node.origin && node.origin !== node.from) node.origin.V3_add(offset_position);
}
}
offset(node);
});
Modes.options.edit.select()
Undo.finishEdit('Bake animation into model')
}
})
})
Interface.definePanels(function() {
function eventTargetToAnim(target) {
let target_node = target;
let i = 0;
while (target_node && target_node.classList && !target_node.classList.contains('animation')) {
if (i < 3 && target_node) {
target_node = target_node.parentNode;
i++;
} else {
return [];
}
}
return [Animation.all.find(anim => anim.uuid == target_node.attributes.anim_id.value), target_node];
}
function getOrder(loc, obj) {
if (!obj) {
return;
} else {
if (loc < 16) return -1;
return 1;
}
return 0;
}
new Panel('animations', {
icon: 'movie',
growable: true,
condition: {modes: ['animate']},
default_position: {
slot: 'left_bar',
float_position: [0, 0],
float_size: [300, 400],
height: 400
},
toolbars: {
head: Toolbars.animations
},
component: {
name: 'panel-animations',
data() { return {
animations: Animation.all,
files_folded: {},
animation_files_enabled: true
}},
methods: {
openMenu(event) {
Interface.Panels.animations.menu.show(event)
},
toggle(key) {
this.files_folded[key] = !this.files_folded[key];
this.$forceUpdate();
},
saveFile(path) {
Animator.exportAnimationFile(path)
},
addAnimation(path) {
let other_animation = Animation.all.find(a => a.path == path)
new Animation({
name: other_animation && other_animation.name.replace(/\w+$/, 'new'),
path
}).add(true).propertiesDialog()
},
showFileContextMenu(event, id) {
Animation.prototype.file_menu.open(event, id);
},
dragAnimation(e1) {
if (getFocusedTextInput()) return;
if (e1.button == 1 || e1.button == 2) return;
convertTouchEvent(e1);
let [anim] = eventTargetToAnim(e1.target);
if (!anim || anim.locked) {
function off(e2) {
removeEventListeners(document, 'mouseup touchend', off);
}
addEventListeners(document, 'mouseup touchend', off);
return;
};
let active = false;
let helper;
let timeout;
let drop_target, drop_target_node, order;
let last_event = e1;
function move(e2) {
convertTouchEvent(e2);
let offset = [
e2.clientX - e1.clientX,
e2.clientY - e1.clientY,
]
if (!active) {
let distance = Math.sqrt(Math.pow(offset[0], 2) + Math.pow(offset[1], 2))
if (Blockbench.isTouch) {
if (distance > 20 && timeout) {
clearTimeout(timeout);
timeout = null;
} else {
document.getElementById('animations_list').scrollTop += last_event.clientY - e2.clientY;
}
} else if (distance > 6) {
active = true;
}
} else {
if (e2) e2.preventDefault();
if (open_menu) open_menu.hide();
if (!helper) {
helper = document.createElement('div');
helper.id = 'animation_drag_helper';
let icon = document.createElement('i'); icon.className = 'material-icons'; icon.innerText = 'movie'; helper.append(icon);
let span = document.createElement('span'); span.innerText = anim.name; helper.append(span);
document.body.append(helper);
}
helper.style.left = `${e2.clientX}px`;
helper.style.top = `${e2.clientY}px`;
// drag
$('.drag_hover').removeClass('drag_hover');
$('.animation[order]').attr('order', null);
let target = document.elementFromPoint(e2.clientX, e2.clientY);
[drop_target, drop_target_node] = eventTargetToAnim(target);
if (drop_target) {
var location = e2.clientY - $(drop_target_node).offset().top;
order = getOrder(location, drop_target)
drop_target_node.setAttribute('order', order)
drop_target_node.classList.add('drag_hover');
}
}
last_event = e2;
}
function off(e2) {
if (helper) helper.remove();
removeEventListeners(document, 'mousemove touchmove', move);
removeEventListeners(document, 'mouseup touchend', off);
$('.drag_hover').removeClass('drag_hover');
$('.animation[order]').attr('order', null);
if (Blockbench.isTouch) clearTimeout(timeout);
if (active && !open_menu) {
convertTouchEvent(e2);
let target = document.elementFromPoint(e2.clientX, e2.clientY);
[target_anim] = eventTargetToAnim(target);
if (!target_anim || target_anim == anim ) return;
let index = Animation.all.indexOf(target_anim);
if (Animation.all.indexOf(anim) < index) index--;
if (order == 1) index++;
if (Animation.all[index] == anim && anim.path == target_anim.path) return;
Undo.initEdit({animations: [anim]});
anim.path = target_anim.path;
Animation.all.remove(anim);
Animation.all.splice(index, 0, anim);
Undo.finishEdit('Reorder animations');
}
}
if (Blockbench.isTouch) {
timeout = setTimeout(() => {
active = true;
move(e1);
}, 320)
}
addEventListeners(document, 'mousemove touchmove', move, {passive: false});
addEventListeners(document, 'mouseup touchend', off, {passive: false});
}
},
computed: {
files() {
if (!this.animation_files_enabled) {
return {
'': {
animations: this.animations,
name: '',
hide_head: true
}
}
}
let files = {};
this.animations.forEach(animation => {
let key = animation.path || '';
if (!files[key]) files[key] = {
animations: [],
name: animation.path ? pathToName(animation.path, true) : 'Unsaved',
saved: true
};
if (!animation.saved) files[key].saved = false;
files[key].animations.push(animation);
})
return files;
},
common_namespace() {
if (!this.animations.length) {
return '';
} else if (this.animations.length == 1) {
let match = this.animations[0].name.match(/^.*[.:]/);
return match ? match[0] : '';
} else {
let name = this.animations[0].name;
if (name.search(/[.:]/) == -1) return '';
for (var anim of this.animations) {
if (anim == this.animations[0]) continue;
let segments = anim.name.split(/[.:]/);
let length = 0;
for (var segment of segments) {
if (segment == name.substr(length, segment.length)) {
length += segment.length + 1;
} else {
break;
}
}
name = name.substr(0, length);
if (name.length < 8) return '';
}
return name;
}
}
},
template: `
<ul
id="animations_list"
class="list mobile_scrollbar"
@mousedown="dragAnimation($event)"
@touchstart="dragAnimation($event)"
@contextmenu.stop.prevent="openMenu($event)"
>
<li v-for="(file, key) in files" :key="key" class="animation_file" @contextmenu.prevent.stop="showFileContextMenu($event, key)">
<div class="animation_file_head" v-if="!file.hide_head" v-on:click.stop="toggle(key)">
<i v-on:click.stop="toggle(key)" class="icon-open-state fa" :class=\'{"fa-angle-right": files_folded[key], "fa-angle-down": !files_folded[key]}\'></i>
<label :title="key">{{ file.name }}</label>
<div class="in_list_button" v-if="animation_files_enabled && !file.saved" v-on:click.stop="saveFile(key, file)">
<i class="material-icons">save</i>
</div>
<div class="in_list_button" v-on:click.stop="addAnimation(key)">
<i class="material-icons">add</i>
</div>
</div>
<ul v-if="!files_folded[key]" :class="{indented: !file.hide_head}">
<li
v-for="animation in file.animations"
v-bind:class="{ selected: animation.selected }"
v-bind:anim_id="animation.uuid"
class="animation"
v-on:click.stop="animation.select()"
v-on:dblclick.stop="animation.propertiesDialog()"
:key="animation.uuid"
@contextmenu.prevent.stop="animation.showContextMenu($event)"
>
<i class="material-icons">movie</i>
<label :title="animation.name">
{{ common_namespace ? animation.name.split(common_namespace).join('') : animation.name }}
<span v-if="common_namespace"> - {{ animation.name }}</span>
</label>
<div v-if="animation_files_enabled" class="in_list_button" v-bind:class="{unclickable: animation.saved}" v-on:click.stop="animation.save()">
<i v-if="animation.saved" class="material-icons">check_circle</i>
<i v-else class="material-icons">save</i>
</div>
<div class="in_list_button" v-on:click.stop="animation.togglePlayingState()">
<i v-if="animation.playing" class="fa_big far fa-play-circle"></i>
<i v-else class="fa_big far fa-circle"></i>
</div>
</li>
</ul>
</li>
</ul>
`
},
menu: new Menu([
'add_animation',
'load_animation_file',
'paste',
'save_all_animations',
])
})
new Panel('variable_placeholders', {
icon: 'fas.fa-stream',
condition: {modes: ['animate']},
growable: true,
default_position: {
slot: 'left_bar',
float_position: [0, 0],
float_size: [300, 400],
height: 400
},
toolbars: {
},
component: {
name: 'panel-placeholders',
components: {VuePrismEditor},
data() { return {
text: '',
buttons: []
}},
methods: {
updateButtons() {
let old_values = {};
this.buttons.forEach(b => old_values[b.id] = b.value);
this.buttons.empty();
let text = this.text.toLowerCase();
let matches = text.matchAll(/(slider|toggle)\(.+\)/g);
for (let match of matches) {
let [type, content] = match[0].substring(0, match[0].length - 1).split(/\(/);
let [id, ...args] = content.split(/\(|, */);
id = id.replace(/['"]/g, '');
if (this.buttons.find(b => b.id == id)) return;
let variable = text.substring(0, match.index).match(/[\w.-]+ *= *$/);
variable = variable ? variable[0].replace(/[ =]+/g, '').replace(/^v\./, 'variable.').replace(/^q\./, 'query.').replace(/^t\./, 'temp.').replace(/^c\./, 'context.') : undefined;
if (type == 'slider') {
this.buttons.push({
type,
id,
value: old_values[id] || 0,
variable,
step: args[0],
min: args[1],
max: args[2]
})
} else {
this.buttons.push({
type,
id,
value: old_values[id] || 0,
variable,
})
}
}
},
changeButtonValue(button, event) {
if (button.type == 'toggle') {
button.value = event.target.checked ? 1 : 0;
}
if (button.variable) {
delete Animator.MolangParser.variables[button.variable];
}
Animator.preview();
},
slideButton(button, e1) {
convertTouchEvent(e1);
let last_event = e1;
let started = false;
let move_calls = 0;
let last_val = 0;
let total = 0;
let clientX = e1.clientX;
function start() {
started = true;
if (!e1.touches && last_event == e1 && e1.target.requestPointerLock) e1.target.requestPointerLock();
}
function move(e2) {
convertTouchEvent(e2);
if (!started && Math.abs(e2.clientX - e1.clientX) > 5) {
start()
}
if (started) {
if (e1.touches) {
clientX = e2.clientX;
} else {
let limit = move_calls <= 2 ? 1 : 100;
clientX += Math.clamp(e2.movementX, -limit, limit);
}
let val = Math.round((clientX - e1.clientX) / 45);
let difference = (val - last_val);
if (!difference) return;
difference *= canvasGridSize(e2.shiftKey || Pressing.overrides.shift, e2.ctrlOrCmd || Pressing.overrides.ctrl);
button.value = Math.roundTo((parseFloat(button.value) || 0) + difference, 4);
last_val = val;
last_event = e2;
total += difference;
move_calls++;
Animator.preview()
Blockbench.setStatusBarText(trimFloatNumber(total));
}
}
function off(e2) {
if (document.exitPointerLock) document.exitPointerLock()
removeEventListeners(document, 'mousemove touchmove', move);
removeEventListeners(document, 'mouseup touchend', off);
}
addEventListeners(document, 'mouseup touchend', off);
addEventListeners(document, 'mousemove touchmove', move);
}
},
watch: {
text(text) {
if (Project && typeof text == 'string') {
Project.variable_placeholders = text;
this.updateButtons();
Project.variable_placeholder_buttons.replace(this.buttons);
}
}
},
template: `
<div style="flex-grow: 1; display: flex; flex-direction: column;">
<ul id="placeholder_buttons">
<li v-for="button in buttons" :key="button.id" :class="{placeholder_slider: button.type == 'slider'}">
<input v-if="button.type == 'toggle'" type="checkbox" class="tab_target" :value="button.value == 1" @change="changeButtonValue(button, $event)" :id="'placeholder_button_'+button.id">
<input v-else type="number" class="dark_bordered tab_target" :step="button.step" :min="button.min" :max="button.max" v-model="button.value" @input="changeButtonValue(button, $event)">
<label :for="'placeholder_button_'+button.id" @mousedown="slideButton(button, $event)" @touchstart="slideButton(button, $event)">{{ button.id }}</label>
</li>
</ul>
<p>${tl('panel.variable_placeholders.info')}</p>
<vue-prism-editor
id="var_placeholder_area"
class="molang_input dark_bordered tab_target"
v-model="text"
language="molang"
:line-numbers="false"
style="flex-grow: 1;"
onkeyup="Animator.preview()"
/>
</div>
`
}
})
})