class AnimationControllerState { constructor(controller, data = 0) { this.controller = controller; this.uuid = guid(); for (let key in AnimationControllerState.properties) { AnimationControllerState.properties[key].reset(this); } this.folded = false; this.fold = { animations: false, particles: true, sounds: true, on_entry: true, on_exit: true, transitions: true, }; this.muted = { sound: false, particle: false, } this.playing_sounds = []; if (data) { this.extend(data); } this.controller.states.safePush(this); } extend(data = 0) { for (let key in AnimationControllerState.properties) { if (AnimationControllerState.properties[key].type == 'array') continue; AnimationControllerState.properties[key].merge(this, data) } if (data.animations instanceof Array) { this.animations.empty(); data.animations.forEach(a => { let animation; if (typeof a == 'object' && typeof a.uuid == 'string' && a.uuid.length == 36) { // Internal animation = { uuid: a.uuid || guid(), key: a.key || '', animation: a.animation || '',// UUID blend_value: a.blend_value || '' }; } else if (typeof a == 'object' || typeof a == 'string') { // Bedrock let key = typeof a == 'object' ? Object.keys(a)[0] : a; let anim_match = Animation.all.find(anim => anim.getShortName() == key); animation = { uuid: guid(), key: key || '', animation: anim_match ? anim_match.uuid : '',// UUID blend_value: (typeof a == 'object' && a[key]) || '' }; } this.animations.push(animation); }) } if (data.transitions instanceof Array) { this.transitions.empty(); data.transitions.forEach(a => { let transition; if (typeof a == 'object' && typeof a.uuid == 'string' && a.uuid.length == 36) { // Internal transition = { uuid: a.uuid || guid(), target: a.target || '', condition: a.condition || '' }; } else if (typeof a == 'object') { // Bedrock let key = Object.keys(a)[0]; let state_match = this.controller.states.find(state => state !== this && state.name == key); transition = { uuid: guid(), target: state_match ? state_match.uuid : '', condition: a[key] || '' }; if (!state_match) { setTimeout(() => { // Delay to after loading controller so that all states can be found let state_match = this.controller.states.find(state => state !== this && state.name == key); if (state_match) transition.target = state_match.uuid; }, 0); } } this.transitions.push(transition); }) } if (data.particles instanceof Array) { this.particles.empty(); data.particles.forEach(particle => { this.particles.push(JSON.parse(JSON.stringify(particle))); }) } if (data.sounds instanceof Array) { this.sounds.empty(); data.sounds.forEach(sound => { this.sounds.push(JSON.parse(JSON.stringify(sound))); }) } // From Bedrock if (data.particle_effects instanceof Array) { this.particles.empty(); data.particle_effects.forEach(effect => { let particle = { uuid: guid(), effect: effect.effect || '', locator: effect.locator || '', bind_to_actor: effect.bind_to_actor !== false, pre_effect_script: effect.pre_effect_script || '', file: '', }; this.particles.push(particle); }) } if (data.sound_effects instanceof Array) { this.sounds.empty(); data.sound_effects.forEach(effect => { let sound = { uuid: guid(), effect: effect.effect || '', file: '', }; this.sounds.push(sound); }) } } getUndoCopy() { var copy = { uuid: this.uuid, name: this.name, } for (let key in AnimationControllerState.properties) { AnimationControllerState.properties[key].copy(this, copy) } copy.animations = JSON.parse(JSON.stringify(copy.animations)); copy.transitions = JSON.parse(JSON.stringify(copy.transitions)); copy.particles = JSON.parse(JSON.stringify(copy.particles)); copy.sounds = JSON.parse(JSON.stringify(copy.sounds)); return copy; } compileForBedrock() { let object = {}; if (this.animations.length) { object.animations = this.animations.map(animation => { return animation.blend_value.trim() ? new oneLiner({[animation.key]: animation.blend_value.trim()}) : animation.key; }) } ['on_entry', 'on_exit'].forEach(type => { if (!this[type] || this[type] == '\\n') return; let lines = this[type].split('\n'); lines = lines.filter(script => !!script.replace(/[\n\s;.]+/g, '')); lines = lines.map(line => (line.match(/;\s*$/) || line.startsWith('/')) ? line : (line+';')); object[type] = lines; }) if (this.particles.length) { object.particle_effects = this.particles.map(particle => { return { effect: particle.effect || '', locator: particle.locator || undefined, bind_to_actor: particle.bind_to_actor ? undefined : false, pre_effect_script: particle.pre_effect_script || undefined } }) } if (this.sounds.length) { object.sound_effects = this.sounds.map(sound => { return new oneLiner({ effect: sound.effect || '' }); }) } if (this.transitions.length) { object.transitions = this.transitions.map(transition => { let state = this.controller.states.find(s => s.uuid == transition.target); return new oneLiner({[state ? state.name : 'missing_state']: transition.condition}) }) } if (this.blend_transition) object.blend_transition = this.blend_transition; if (this.blend_via_shortest_path) object.blend_via_shortest_path = this.blend_via_shortest_path; Blockbench.dispatchEvent('compile_bedrock_animation_controller_state', {state: this, json: object}); return object; } select(force) { if (this.controller.selected_state !== this || force) { this.controller.last_state = this.controller.selected_state; this.controller.transition_timestamp = Date.now(); this.start_timestamp = Date.now(); this.controller.selected_state = this; if (this.controller.last_state) this.controller.last_state.unselect(); Animator.MolangParser.parse(this.on_entry); this.playEffects() Blockbench.dispatchEvent('select_animation_controller_state', {state: this}); } return this; } unselect() { Animator.MolangParser.parse(this.on_exit); this.playing_sounds.forEach(media => { if (!media.paused) { media.pause(); } }) this.playing_sounds.empty(); } playEffects() { if (!this.muted.sound) { this.sounds.forEach(sound => { if (sound.file && !sound.cooldown) { var media = new Audio(sound.file); media.volume = Math.clamp(settings.volume.value/100, 0, 1); media.play().catch(() => {}); this.playing_sounds.push(media); media.onended = () => { this.playing_sounds.remove(media); } sound.cooldown = true; setTimeout(() => { delete sound.cooldown; }, 400) } }) } if (!this.muted.particle) { this.particles.forEach((particle) => { let particle_effect = particle.file && Animator.particle_effects[particle.file] if (particle_effect) { let emitter = particle_effect.emitters[particle.uuid]; if (!emitter) { emitter = particle_effect.emitters[particle.uuid] = new Wintersky.Emitter(WinterskyScene, particle_effect.config); let old_variable_handler = emitter.Molang.variableHandler; emitter.Molang.variableHandler = (key, params) => { let curve_result = old_variable_handler.call(emitter, key, params); if (curve_result !== undefined) return curve_result; return Animator.MolangParser.variableHandler(key); } } emitter.Molang.parse(particle.pre_effect_script, Animator.MolangParser.global_variables); let locator = particle.locator && Locator.all.find(l => l.name == particle.locator) if (locator) { locator.mesh.add(emitter.local_space); emitter.parent_mode = 'locator'; } else { emitter.parent_mode = 'entity'; } emitter.stopLoop().playLoop(); } }) } } scrollTo() { let node = document.querySelector(`.controller_state[uuid="${this.uuid}"]`); if (node) node.scrollIntoView({behavior: 'smooth', block: 'nearest'}) return this; } rename() { Blockbench.textPrompt('generic.rename', this.name, value => { Undo.initEdit({animation_controller_state: this}); this.name = value; this.createUniqueName(); Undo.finishEdit('Rename animation controller state'); }) return this; } remove(undo) { if (undo) { Undo.initEdit({animation_controllers: [this.controller]}); } this.controller.states.forEach(state => { state.transitions.forEach(transition => { if (transition.target == this.uuid) transition.target = ''; }) }) this.controller.states.remove(this); if (this.controller.selected_state == this) this.controller.selected_state = null; if (undo) { Undo.finishEdit('Remove animation controller state'); } } createUniqueName() { let scope = this; let others = this.controller.states; let name = this.name.replace(/\d+$/, ''); function check(n) { for (let 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 (let num = 2; num < 8e3; num++) { if (check(name+num)) { scope.name = name+num; return scope.name; } } return false; } addAnimation(animation) { Undo.initEdit({animation_controller_state: this}); let anim_link = { uuid: guid(), key: animation ? animation.getShortName() : '', animation: animation ? animation.uuid : '', blend_value: '' }; this.animations.push(anim_link); this.fold.animations = false; Blockbench.dispatchEvent('add_animation_controller_animation', {state: this}); Undo.finishEdit('Add animation to animation controller state'); } addTransition(target = '') { Undo.initEdit({animation_controller_state: this}); let transition = { uuid: guid(), target, // UUID condition: '' }; this.transitions.push(transition); this.fold.transitions = false; Blockbench.dispatchEvent('add_animation_controller_transition', {state: this}); Undo.finishEdit('Add transition to animation controller state'); Vue.nextTick(() => { let node = document.querySelector(`.controller_state[uuid="${this.uuid}"] .controller_transition:last-child pre`); if (node) { $(node).trigger('focus'); } }) } addParticle(options = 0) { Undo.initEdit({animation_controller_state: this}); let particle = { uuid: guid(), effect: options.effect || '', bind_to_actor: true, locator: '', pre_effect_script: '', file: '', }; this.particles.push(particle); this.fold.particles = false; Blockbench.dispatchEvent('add_animation_controller_particle', {state: this}); Undo.finishEdit('Add particle to animation controller state'); Vue.nextTick(() => { let node = document.querySelector(`.controller_state[uuid="${this.uuid}"] .controller_particle input[type="text"]`); if (node) { $(node).trigger('focus'); } }) } addSound(options = 0) { Undo.initEdit({animation_controller_state: this}); let sound = { uuid: guid(), effect: options.effect || '', file: options.file || '', }; this.sounds.push(sound); this.fold.sounds = false; Blockbench.dispatchEvent('add_animation_controller_sound', {state: this}); Undo.finishEdit('Add sound to animation controller state'); Vue.nextTick(() => { let node = document.querySelector(`.controller_state[uuid="${this.uuid}"] .controller_sound input[type="text"]`); if (node) { $(node).trigger('focus'); } }) } openMenu(event) { AnimationControllerState.prototype.menu.open(event, this); } getStateTime() { return this.start_timestamp ? (Date.now() - this.start_timestamp) / 1000 : 0; } } new Property(AnimationControllerState, 'string', 'name', {default: 'default'}); new Property(AnimationControllerState, 'array', 'animations'); new Property(AnimationControllerState, 'array', 'transitions'); new Property(AnimationControllerState, 'array', 'sounds'); new Property(AnimationControllerState, 'array', 'particles'); new Property(AnimationControllerState, 'string', 'on_entry'); new Property(AnimationControllerState, 'string', 'on_exit'); new Property(AnimationControllerState, 'number', 'blend_transition'); new Property(AnimationControllerState, 'boolean', 'blend_via_shortest_path'); AnimationControllerState.prototype.menu = new Menu([ { id: 'set_as_initial_state', name: 'Initial State', icon: (state) => (state.uuid == AnimationController.selected?.initial_state ? 'radio_button_checked' : 'radio_button_unchecked'), click(state) { if (!AnimationController.selected) return; Undo.initEdit({animation_controllers: [AnimationController.selected]}); AnimationController.selected.initial_state = state.uuid; Undo.finishEdit('Change animation controller initial state'); } }, new MenuSeparator('manage'), 'duplicate', 'rename', 'delete', ]); class AnimationController extends AnimationItem { constructor(data) { super(data); this.name = ''; this.uuid = guid() this.playing = false; this.selected = false; this.states = []; this.selected_state = null; for (let key in AnimationController.properties) { AnimationController.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 (let key in AnimationController.properties) { AnimationController.properties[key].merge(this, data) } Merge.string(this, data, 'name') if (data.states instanceof Array) { let old_states = this.states.splice(0, this.states.length); data.states.forEach(template => { let state = old_states.find(state2 => state2.name === template.name || (template.uuid && state2.uuid === template.uuid)); if (state) { state.extend(template); if (template.uuid) state.uuid = template.uuid; this.states.push(state); } else { state = new AnimationControllerState(this, template); if (template.uuid) state.uuid = template.uuid; } }) } else if (typeof data.states === 'object') { for (let name in data.states) { let state = this.states.find(state2 => state2.name === name); if (state) { state.extend(data.states[name]); } else { state = new AnimationControllerState(this, data.states[name]); } state.name = name; } } if (typeof data.initial_state == 'string') { if (data.initial_state.length == 36) { this.initial_state = data.initial_state; } else { this.initial_state = this.states.find(s => s.name == data.initial_state)?.uuid || ''; } } if (typeof data.selected_state == 'string') { let state = this.states.find(s => s.uuid == data.selected_state); if (state) this.selected_state = state; } if (data.states) { this.states.forEach(state => state.createUniqueName()); } return this; } getUndoCopy(options = 0, save) { var copy = { uuid: this.uuid, type: 'animation_controller', name: this.name, selected: this.selected, selected_state: this.selected_state ? this.selected_state.uuid : null } for (let key in AnimationController.properties) { AnimationController.properties[key].copy(this, copy); } copy.states = this.states.map(state => { return state.getUndoCopy(); }) return copy; } compileForBedrock() { let object = {}; if (!this.states.length) return object; let initial_state = this.states.find(s => s.uuid == this.initial_state) || this.states[0]; if (initial_state.name !== 'default') object.initial_state = initial_state.name; object.states = {}; this.states.forEach(state => { object.states[state.name] = state.compileForBedrock(); }) Blockbench.dispatchEvent('compile_bedrock_animation_controller', {animation_controller: this, json: object}); return object; } save() { if (isApp && !this.path) { Blockbench.export({ resource_id: 'animation_controller', type: 'JSON Animation Controller', extensions: ['json'], name: (Project.geometry_name||'model')+'.animation_controllers', startpath: this.path, custom_writer: (content, path) => { if (!path) return this.path = path; this.save(); } }) return; } let content = { format_version: '1.19.0', animation_controllers: { [this.name]: this.compileForBedrock() } } 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.animation_controllers !== '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.animation_controllers[this.name]; content = data; if (this.saved_name && this.saved_name !== this.name) delete content.animation_controllers[this.saved_name]; content.animation_controllers[this.name] = animation; // Improve JSON formatting for (let key in data.animation_controllers) { let controller = data.animation_controllers[key]; if (typeof controller.states == 'object') { for (let state_name in controller.states) { let state = controller.states[state_name]; if (state.animations instanceof Array) { state.animations.forEach((a, i) => { if (typeof a == 'object') state.animations[i] = new oneLiner(a); }) } if (state.transitions instanceof Array) { state.transitions.forEach((t, i) => { if (typeof t == 'object') state.transitions[i] = new oneLiner(t); }) } } } } // Sort let file_keys = Object.keys(content.animation_controllers); 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_animation_controllers = {}; file_keys.forEach(key => { sorted_animation_controllers[key] = content.animation_controllers[key]; }) content.animation_controllers = sorted_animation_controllers; } } } // 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() { Prop.active_panel = 'animations'; if (this == AnimationController.selected) return; if (Timeline.playing) Timeline.pause() AnimationItem.all.forEach((a) => { a.selected = false; if (a.playing == true) a.playing = false; }) Panels.animation_controllers.inside_vue.controller = this; this.selected = true; if (this.playing == false) this.playing = true; AnimationItem.selected = this; if (Modes.animate) { Animator.preview(); updateInterface(); } return this; } createUniqueName(arr) { var scope = this; var others = AnimationController.all.slice(); if (arr && arr.length) { arr.forEach(g => { others.safePush(g) }) } others = others.filter(a => a.path == this.path); 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({animation_controllers: [scope]}); scope.name = name; scope.createUniqueName(); Undo.finishEdit('Rename animation controller'); } }) return this; } updatePreview() { let mode = BarItems.animation_controller_preview_mode.value; if (mode == 'paused') return; Animator.preview(); // Transitions if (mode == 'play' && this.selected_state) { for (let transition of this.selected_state.transitions) { let match = Animator.MolangParser.parse(transition.condition); let target_state = match && this.states.find(s => s.uuid == transition.target); if (match && target_state) { target_state.select(); break; } } } } togglePlayingState(state) { if (!this.selected) { this.playing = state !== undefined ? state : !this.playing; Animator.preview(); } else { } return this.playing; } showContextMenu(event) { this.select(); this.menu.open(event, this); return this; } add(undo) { if (undo) { Undo.initEdit({animation_controllers: []}) } if (!AnimationController.all.includes(this)) { AnimationController.all.push(this) } this.createUniqueName(); if (undo) { this.select() Undo.finishEdit('Add animation controller', {animation_controllers: [this]}) } return this; } remove(undo, remove_from_file = true) { if (undo) { Undo.initEdit({animation_controllers: [this]}) } AnimationController.all.remove(this) if (undo) { Undo.finishEdit('Remove animation controller', {animation_controllers: []}) 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.animation_controllers && json.animation_controllers[this.name]) { delete json.animation_controllers[this.name]; Blockbench.writeFile(this.path, {content: compileJSON(json)}); Undo.history.last().before.animation_controllers[this.uuid].saved = false } } }) } } Blockbench.dispatchEvent('remove_animation', {animation_controllers: [this]}) if (Animation.selected === this) { Animation.selected = null; Timeline.clear(); Animator.preview(); } return this; } 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 } }, onConfirm: form_data => { dialog.hide().delete(); if ( form_data.name != this.name || (isApp && form_data.path != this.path) ) { Undo.initEdit({animation_controllers: [this]}); this.name = form_data.name; if (isApp) this.path = form_data.path; this.createUniqueName(); Blockbench.dispatchEvent('edit_animation_controller_properties', {animation_controllers: [this]}) Undo.finishEdit('Edit animation controller properties'); } }, onCancel() { dialog.hide().delete(); } }) dialog.show(); } } Object.defineProperty(AnimationController, 'all', { get() { return Project.animation_controllers || []; }, set(arr) { Project.animation_controllers.replace(arr); } }) AnimationController.selected = null; AnimationController.prototype.menu = new Menu([ new MenuSeparator('copypaste'), 'copy', 'paste', 'duplicate', new MenuSeparator('manage'), { 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(controller) { Undo.initEdit({animation_controllers: [controller]}) controller.remove(false, false); Undo.finishEdit('Unload animation controller', {animation_controllers: []}) } }, 'delete', new MenuSeparator('properties'), {name: 'menu.animation.properties', icon: 'cable', click(animation) { animation.propertiesDialog(); }} ]) AnimationController.prototype.file_menu = Animation.prototype.file_menu; new Property(AnimationController, 'boolean', 'saved', {default: true}) new Property(AnimationController, 'string', 'path', {condition: () => isApp}) new Property(AnimationController, 'string', 'initial_state', {default: ''}) AnimationController.presets = [ { name: 'Default', states: { default: {} } }, { name: 'Simple Action', states: { default: { transitions: [{active: 'query.foo'}] }, active: { transitions: [{default: '!query.foo'}] } } }, { name: 'Walking', states: { idle: { transitions: [ {walking: 'q.ground_speed > 1.0'} ], blend_transition: 0.2 }, walking: { transitions: [ {idle: 'q.ground_speed < 0.5'} ], blend_transition: 0.2 } } }, { name: 'Open & Close', initial_state: 'default', states: { default: { transitions: [ {closed: '!q.is_ignited'}, {opened: 'q.is_ignited'} ] }, closed: { transitions: [ {open: 'q.is_ignited'} ] }, open: { transitions: [ {opened: 'query.any_animation_finished'}, {close: '!q.is_ignited'} ], blend_transition: 0.1 }, opened: { transitions: [ {close: '!q.is_ignited'} ], blend_transition: 0.1 }, close: { transitions: [ {closed: 'query.all_animations_finished'} ] } } } ]; Blockbench.on('finish_edit', event => { if (!Format.animation_controllers) return; if (event.aspects.animation_controllers) { event.aspects.animation_controllers.forEach(controller => { if (Undo.current_save && Undo.current_save.aspects.animation_controllers instanceof Array && Undo.current_save.aspects.animation_controllers.includes(controller)) { controller.saved = false; } }) } if (event.aspects.animation_controller_state && AnimationController.selected) { AnimationController.selected.saved = false; } }) Interface.definePanels(() => { let panel = new Panel('animation_controllers', { icon: 'cable', condition: {modes: ['animate'], features: ['animation_controllers'], method: () => AnimationController.selected}, default_position: { slot: 'bottom', float_position: [100, 400], float_size: [600, 300], height: 260, }, growable: true, onResize() { if (this.inside_vue) this.inside_vue.updateConnectionWrapperOffset(); }, component: { name: 'panel-animation-controllers', components: {VuePrismEditor}, data() {return { controller: null, presets: AnimationController.presets, zoom: 1, connection_wrapper_offset: 0, connecting: false, pickwhip: { start_x: 0, start_y: 0, length: 0, angle: 0, } }}, methods: { loadPreset(preset) { this.controller.extend({ states: preset.states, initial_state: preset.initial_state }); this.updateConnectionWrapperOffset(); }, toggleStateSection(state, section) { state.fold[section] = !state.fold[section]; }, openAnimationMenu(state, animation, target) { let apply = anim => { animation.key = anim.getShortName(); animation.animation = anim.uuid; } let list = [ { name: 'generic.unset', icon: animation.animation == '' ? 'radio_button_checked' : 'radio_button_unchecked', click: () => { animation.key = ''; animation.animation = ''; } }, '_' ]; AnimationItem.all.forEach((anim, i) => { if (anim == this.controller) return; if (i && anim instanceof AnimationController) list.push('_'); list.push({ name: anim.name, icon: animation.animation == anim.uuid ? 'radio_button_checked' : (state.animations.find(a => a.animation == anim.uuid) ? 'task_alt' : 'radio_button_unchecked'), click: () => apply(anim) }) }) list.push( '_', {id: 'remove', name: 'generic.remove', icon: 'clear', click() { Undo.initEdit({animation_controller_state: state}); state.animations.remove(animation); Undo.finishEdit('Remove animation from controller state'); }} ); let menu = new Menu('controller_state_animations', list, {searchable: list.length > 7}); menu.open(target); }, openTransitionMenu(state, transition, event) { let list = []; this.controller.states.forEach(state2 => { if (state2 == state) return; list.push({ name: state2.name, icon: transition.target == state2.uuid ? 'radio_button_checked' : (state.transitions.find(t => t.target == state2.uuid) ? 'task_alt' : 'radio_button_unchecked'), click: () => { transition.target = state2.uuid; } }) }) list.push( '_', {id: 'remove', name: 'generic.remove', icon: 'clear', click() { Undo.initEdit({animation_controller_state: state}); state.transitions.remove(transition); Undo.finishEdit('Remove transition from controller state'); }} ); let menu = new Menu('controller_state_transitions', list, {searchable: list.length > 9}); menu.open(event.target); }, openParticleMenu(state, particle, event) { if (getFocusedTextInput()) return; new Menu('controller_state_particle', [ { name: 'generic.remove', icon: 'clear', click() { Undo.initEdit({animation_controller_state: state}); state.particles.remove(particle); Undo.finishEdit('Remove particle from controller state'); } } ]).open(event); event.stopPropagation(); }, openSoundMenu(state, sound, event) { if (getFocusedTextInput()) return; new Menu('controller_state_sound', [ { name: 'generic.remove', icon: 'clear', click() { Undo.initEdit({animation_controller_state: state}); state.sounds.remove(sound); Undo.finishEdit('Remove sound from controller state'); } } ]).open(event); event.stopPropagation(); }, addAnimationButton(state, event) { state.select(); let list = [ { name: 'generic.unset', icon: 'call_to_action', click: () => { state.addAnimation(); } } ]; AnimationItem.all.forEach((anim, i) => { if (anim == this.controller) return; if (i && anim instanceof AnimationController) list.push('_'); list.push({ name: anim.name, icon: anim instanceof AnimationController ? 'cable' : 'movie', click: () => { state.addAnimation(anim); } }) }) let menu = new Menu('controller_state_animations', list, {searchable: list.length > 7}); menu.open(event.target); }, addTransitionButton(state, event) { state.select(); let list = []; this.controller.states.forEach(state2 => { if (state2 == state || state.transitions.find(t => t.target == state2.uuid)) return; list.push({ name: state2.name, icon: 'login', click: () => { state.addTransition(state2.uuid); } }) }) if (!list.length) { list.push({ name: 'generic.unset', icon: 'remove', click: () => { state.addTransition(); } }) } let menu = new Menu('controller_state_transitions', list, {searchable: list.length > 9}); menu.open(event.target); }, addParticleButton(state, event) { state.addParticle(); }, addSoundButton(state, event) { state.addSound(); }, addState() { BarItems.add_animation_controller_state.trigger(); }, sortStates(event) { let max_index = this.controller.states.length - 1; Undo.initEdit({animation_controllers: [this.controller]}); var item = this.controller.states.splice(Math.min(event.oldIndex, max_index), 1)[0]; this.controller.states.splice(Math.min(event.newIndex, max_index), 0, item); Undo.finishEdit('Reorder animation controller states'); }, getStateName(uuid) { let state = this.controller.states.find(s => s.uuid == uuid); return state ? state.name : ''; }, onMouseWheel(event) { if (event.ctrlOrCmd) { let delta = (event.deltaY < 0) ? 0.1 : -0.1; this.zoom = Math.clamp(this.zoom + delta, 0.3, 1); this.updateConnectionWrapperOffset(); } }, updateConnectionWrapperOffset() { Vue.nextTick(() => { let first = document.querySelector('#animation_controllers_wrapper .controller_state'); this.connection_wrapper_offset = first ? first.offsetLeft : 0; }) }, deselect(event) { if (this.controller && event.target?.id == 'animation_controllers_wrapper' || event.target?.parentElement?.id == 'animation_controllers_wrapper') { this.controller.selected_state = null; } }, sortAnimation(state, event) { Undo.initEdit({animation_controller_state: state}); var item = state.animations.splice(event.oldIndex, 1)[0]; state.animations.splice(event.newIndex, 0, item); Undo.finishEdit('Reorder animations in controller state'); }, sortTransition(state, event) { Undo.initEdit({animation_controller_state: state}); var item = state.transitions.splice(event.oldIndex, 1)[0]; state.transitions.splice(event.newIndex, 0, item); Undo.finishEdit('Reorder transitions in controller state'); }, selectConnection(connection, event) { let state = this.controller.states[connection.state_index]; let target = this.controller.states[connection.target_index]; if (state == this.controller.selected_state || event.ctrlOrCmd || Pressing.overrides.ctrl) { target.select().scrollTo(); } else { state.select().scrollTo(); } }, selectConnectionDeep(connection, event) { let state = this.controller.states[connection.state_index]; let target = this.controller.states[connection.target_index]; if (event.ctrlOrCmd || Pressing.overrides.ctrl) { target.select().scrollTo(); } else { state.select().scrollTo(); state.fold.transitions = false; Vue.nextTick(() => { let node = document.querySelector(`.controller_transition[uuid="${connection.uuid}"] pre`); if (node) { $(node).trigger('focus'); document.execCommand('selectAll'); } }) } }, connectionContextMenu(connection, event) { let state = this.controller.states[connection.state_index]; new Menu('controller_connection_context', [ { name: 'animation_controllers.state.edit_transition', icon: 'edit', click() { state.select().scrollTo(); state.fold.transitions = false; Vue.nextTick(() => { let node = document.querySelector(`.controller_transition[uuid="${connection.uuid}"] pre`); if (node) { $(node).trigger('focus'); document.execCommand('selectAll'); } }) } }, { name: 'generic.remove', icon: 'clear', click() { let transition = state.transitions.find(t => t.uuid == connection.uuid); Undo.initEdit({animation_controller_state: state}); state.transitions.remove(transition); Undo.finishEdit('Remove animation controller transition'); } }, ]).open(event) }, dragConnection(state, event) { state.select(); convertTouchEvent(event); event.preventDefault(); event.stopPropagation(); let anchor = document.getElementById('animation_controllers_pickwhip_anchor'); let anchor_offset = $(anchor).offset(); let scope = this; let {controller, pickwhip} = this; this.connecting = true; pickwhip.start_x = event.clientX - anchor_offset.left; pickwhip.start_y = event.clientY - anchor_offset.top; pickwhip.length = 0; function move(e2) { convertTouchEvent(e2); // Visually update connection line let anchor_offset = $(anchor).offset(); let pos2_x = e2.clientX - anchor_offset.left; let pos2_y = e2.clientY - anchor_offset.top; pickwhip.length = Math.sqrt(Math.pow(pos2_x - pickwhip.start_x, 2) + Math.pow(pos2_y - pickwhip.start_y, 2)) pickwhip.angle = Math.radToDeg(Math.atan2(pos2_y - pickwhip.start_y, pos2_x - pickwhip.start_x)); scope.connecting = pickwhip.length >= 5; } function stop(e2) { convertTouchEvent(e2); removeEventListeners(document, 'mousemove touchmove', move); removeEventListeners(document, 'mouseup touchend', stop); scope.connecting = false; if (Math.abs(event.clientX - e2.clientX) < 20) return; // find selected state let target; if (!Blockbench.isTouch) { let target_uuid = document.querySelector('.controller_state:hover')?.attributes.uuid.value; target = target_uuid && controller.states.find(s => s.uuid == target_uuid); } else { target = controller.states.find(state => { let rect = document.querySelector(`.controller_state[uuid="${state.uuid}"]`)?.getBoundingClientRect(); return e2.clientX > rect.left && e2.clientY > rect.top && e2.clientX < rect.right && e2.clientY < rect.bottom; }) } if (target == state || !target) return; // Create connection state.fold.transitions = false; state.addTransition(target.uuid); } addEventListeners(document, 'mousemove touchmove', move); addEventListeners(document, 'mouseup touchend', stop); }, changeParticleFile(state, particle_entry) { Blockbench.import({ resource_id: 'animation_particle', extensions: ['json'], type: 'Bedrock Particle', startpath: particle_entry.file }, (files) => { let {path} = files[0]; Undo.initEdit({animation_controller_state: state}); particle_entry.file = path; let effect = Animator.loadParticleEmitter(path, files[0].content); if (!particle_entry.effect) particle_entry.effect = files[0].name.toLowerCase().split('.')[0].replace(/[^a-z0-9._]+/g, ''); delete effect.config.preview_texture; Undo.finishEdit('Change animation controller particle file'); if (!isApp) { Blockbench.showMessageBox({ title: 'message.import_particle_texture.import', message: 'message.import_particle_texture.message', buttons: ['dialog.cancel'], commands: { import: 'message.import_particle_texture.import' } }, result => { if (result != 'import') return; Blockbench.import({ extensions: ['png'], type: 'Particle Texture', readtype: 'image', startpath: effect.config.preview_texture || path }, function(files) { effect.config.preview_texture = isApp ? files[0].path : files[0].content; effect.config.updateTexture(); }) }) } else if (effect.config.texture.image.src.startsWith('data:')) { Blockbench.import({ extensions: ['png'], type: 'Particle Texture', readtype: 'image', startpath: effect.config.preview_texture || path }, (files) => { effect.config.preview_texture = isApp ? files[0].path : files[0].content; effect.config.updateTexture(); }) } }) }, changeSoundFile(state, sound_entry) { Blockbench.import({ resource_id: 'animation_audio', extensions: ['ogg', 'wav', 'mp3'], type: 'Audio File', startpath: sound_entry.file }, (files) => { let path = isApp ? files[0].path : URL.createObjectURL(files[0].browser_file); Undo.initEdit({animation_controller_state: state}); sound_entry.file = path; if (!sound_entry.effect) sound_entry.effect = files[0].name.toLowerCase().replace(/\.[a-z]+$/, '').replace(/[^a-z0-9._]+/g, ''); Undo.finishEdit('Change animation controller audio file') }) }, updateLocatorSuggestionList() { Locator.updateAutocompleteList(); }, autocomplete(text, position) { let test = Animator.autocompleteMolang(text, position, 'controller'); return test; } }, computed: { connections() { let connections = { forward: [], backward: [], colors: {}, outgoing_plugs: {}, max_layer_top: 0, max_layer_bottom: 0 }; if (!this.controller) return connections; let {states, selected_state} = this.controller; // Count incoming plugs let incoming_plugs = {}; let total_incoming_plugs = {}; states.forEach((state, state_index) => { total_incoming_plugs[state.uuid] = {top: 0, bottom: 0}; states.forEach((origin_state, origin_state_index) => { if (state === origin_state) return; origin_state.transitions.forEach(t => { if (t.target === state.uuid) { state_index < origin_state_index ? total_incoming_plugs[state.uuid].top++ : total_incoming_plugs[state.uuid].bottom++; } }); }); }); let plug_gap = 16; states.forEach((state, state_index) => { let transitions_forward = state.transitions.filter(t => { return state_index < states.findIndex(s => s.uuid == t.target) }); let transitions_backward = state.transitions.filter(t => !transitions_forward.includes(t)); connections.outgoing_plugs[state.uuid] = { top: transitions_backward.length, bottom: transitions_forward.length } //transitions_forward.sort((a, b) => states.findIndex(s => s.uuid == a.target) - states.findIndex(s => s.uuid == b.target)); //transitions_backward.sort((a, b) => states.findIndex(s => s.uuid == b.target) - states.findIndex(s => s.uuid == a.target)); state.transitions.forEach(transition => { let target_index = states.findIndex(s => s.uuid == transition.target); let target = states[target_index]; if (!target) return; let selected = state == this.controller.selected_state || !this.controller.selected_state; let relevant = target == this.controller.selected_state; let back = target_index < state_index; let range = back ? [target_index, state_index] : [state_index, target_index]; let connection_list = back ? connections.backward : connections.forward; let color = markerColors[(connections.forward.length + connections.backward.length) % markerColors.length].standard; if (!incoming_plugs[target.uuid]) incoming_plugs[target.uuid] = {top: 0, bottom: 0}; let layer = 0; for (let i = range[0]; i < range[1]; i++) { while (connection_list.find(c => c.layer == layer && i >= c.range[0] && i < c.range[1])) { layer++; } } let same_level_transitions = back ? transitions_backward : transitions_forward; let start_plug_offset = (same_level_transitions.indexOf(transition) - (same_level_transitions.length-1)/2) * Math.min(plug_gap, 200 / same_level_transitions.length); let con = { state_index, target_index, layer, range, selected, relevant, color, uuid: transition.uuid, condition: transition.condition } if (back) {// Top let incoming_gap_width = Math.min(plug_gap, 260 / total_incoming_plugs[target.uuid].top); connections.max_layer_top = Math.max(connections.max_layer_top, layer); con.end_x = state_index * 312 + 150 + start_plug_offset; con.start_x = target_index * 312 + (300 - 25 - incoming_plugs[target.uuid].top * incoming_gap_width); incoming_plugs[target.uuid].top++; } else {// Bottom let incoming_gap_width = Math.min(plug_gap, 260 / total_incoming_plugs[target.uuid].bottom); connections.max_layer_bottom = Math.max(connections.max_layer_bottom, layer); con.start_x = state_index * 312 + 150 - start_plug_offset; con.end_x = target_index * 312 + 25 + incoming_plugs[target.uuid].bottom * incoming_gap_width; incoming_plugs[target.uuid].bottom++; } connection_list.push(con); connections.colors[transition.uuid] = color; }) }) connections.forward.sort((a, b) => b.layer - a.layer); connections.backward.sort((a, b) => b.layer - a.layer); return connections; } }, watch: { controller() { this.updateConnectionWrapperOffset(); }, 'controller.states'() { this.updateConnectionWrapperOffset(); } }, template: `

${tl('animation_controllers.select_preset')}

` } }) let molang_edit_value; let class_name = 'animation_controller_text_input'; function isTarget(target) { return target?.classList?.contains(class_name) || target?.parentElement?.parentElement?.classList?.contains(class_name); } document.addEventListener('focus', event => { if (isTarget(event.target)) { molang_edit_value = event.target.value || event.target.innerText; let state_uuid = document.querySelector('.controller_state:focus-within')?.attributes.uuid?.value; let state = state_uuid && AnimationController.selected?.states.find(s => s.uuid == state_uuid); if (state) { Undo.initEdit({animation_controller_state: state}); } } }, true) document.addEventListener('focusout', event => { if (isTarget(event.target)) { let val = event.target.value || event.target.innerText; if (val != molang_edit_value) { Undo.finishEdit('Edit animation controller molang'); } else { Undo.cancelEdit(); } } }) }) Object.defineProperty(AnimationItem, 'all', { get() { return [...Animation.all, ...AnimationController.all] }, set() { console.warn('AnimationItem.all is read-only') } }) Object.defineProperty(AnimationItem, 'selected', { get() { return Animation.selected || AnimationController.selected }, set(item) { if (item instanceof Animation) { Animation.selected = item; AnimationController.selected = null; } else { Animation.selected = null; AnimationController.selected = item; } } }) BARS.defineActions(function() { new Action('add_animation_controller', { icon: 'post_add', category: 'animation', condition: {modes: ['animate'], features: ['animation_controllers']}, click() { let controller = new AnimationController({ name: 'controller.animation.' + (Project.geometry_name||'model') + '.new', saved: false }).add(true).propertiesDialog(); Blockbench.dispatchEvent('add_animation_controller', {animation_controller: controller}) } }) new Action('add_animation_controller_state', { icon: 'add_box', category: 'animation', condition: {modes: ['animate'], features: ['animation_controllers'], method: () => AnimationController.selected}, click() { Undo.initEdit({animation_controllers: [AnimationController.selected]}) let state = new AnimationControllerState(AnimationController.selected, {}).select().scrollTo().rename(); Blockbench.dispatchEvent('select_animation_controller_state', {animation_controller: AnimationController.selected, state}); Undo.finishEdit('Add animation controller state') } }) new BarSelect('animation_controller_preview_mode', { options: { paused: {name: true, icon: 'pause'}, manual: {name: true, icon: 'back_hand'}, play: {name: true, icon: 'play_arrow'}, }, icon_mode: true, value: 'manual', category: 'animation', keybind: new Keybind({key: 32}), condition: {modes: ['animate'], selected: {animation_controller: true}} }) })