├── .gitattributes ├── LICENSE ├── README.md ├── plugins.json └── plugins └── armor_stand_animator.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 DoubleFelix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Armor Stand Animator 2 | Provides an interface for [Blockbench](http://blockbench.net/) to animate armor stands which is converted to a data pack. 3 | 4 | ## Usage 5 | First, install the [plugin](https://www.blockbench.net/plugins/armor_stand_animator) via the Blockbench plugins store. Then, add both the "Create Armor Stand Model" and "Export Armor Stand Animation" actions to your toolbar. 6 | 7 | Next, create a new Blockbench project. You can use whatever format you like (Bedrock works best in my experience). Since you get 360 degrees of freedom in each axis, you can use the Generic Model format if you like. 8 | 9 | In any case, click the "Create Armor Stand Model" action to create a new armor stand. This will also import a basic texture for you. Then, in the "Animate" menu, create a new animation. Name it whatever you like, but make sure the snapping value is set to 20. If it's not 20, any keyframe time will be rounded up, which can create undesirable results. You can set the Loop Mode or the Start Delay if you wish. Note that "Hold On Last Frame" and "Play Once" do the same thing in the final data pack. Now, it's time to animate your armor stand. You can move the base `armor_stand` bone around to change the position of the armor stand (it teleports relative to its current position), and you can modify the rotation of any bone except you cannot modify the X or Z axis of the `armor_stand` bone. Changing the Y axis will edit the Rotation of the armor stand. To make this process easier, I highly recommend installing the [**Bakery**](https://www.blockbench.net/plugins/bakery) plugin. It turns the interpolated preview into real keyframes. Without these keyframes, the final animation will "skip" directly to the next keyframe. Simply select a starting keyframe, select an ending keyframe, and then use the "Bake Animations" action in the toolbar. 10 | 11 | Now that you have a finished animation, use the "Export Armor Stand Animation" action. You can edit the following parameters: 12 | - `Block/Unit Ratio` - This describes the ratio between Blockbench units and minecraft blocks. By default, 1 Blockbench unit is 1/16th of a minecraft block. 13 | - `Time Scale` - This describes the scale your animation will play at. Setting this to `2` will make your animation play in half speed. 14 | - `Pack Name` - This should follow normal namespacing conventions. This controls the namespace name of the exported animation. 15 | - `Entity Tag` - This controls the tag that the armor stand entity uses. Set this to something unique, or else the animation will start modifying things you didn't want it to. 16 | 17 | The plugin exports a namespace file, so just unzip the file into your data pack's `data` folder, and you're good to go. Make sure you run `function init` before starting to create the necessary objectives. This only needs to be run once. To summon the armor stand, run `function :create`. To start the animation, run `execute as @e[tag=] run function :start`. This should work for multiple entities at once, and it's optimized to have a minimal impact on TPS. Note that TPS may be affected with several animations running at once. (Editing the rotation of limbs uses a `data merge` command, which can be quite taxing with quantity). 18 | 19 | --- 20 | 21 | Found a bug? Create an [issue](https://github.com/DoubleF3lix/Armor-Stand-Animator/issues/new) on the GitHub repository. 22 | 23 | Special thanks to vdvman1 for helping me optimize the data pack, and to many others in the [Minecraft Commands Discord](https://discord.gg/QAFXFtZ) for feedback. -------------------------------------------------------------------------------- /plugins.json: -------------------------------------------------------------------------------- 1 | { 2 | "armor_stand_animator": { 3 | "title": "Armor Stand Animator", 4 | "author": "DoubleFelix", 5 | "description": "Provides an interface to animate armor stands which is converted to a data pack", 6 | "about": "To start, create the armor stand model by using the \"Create Armor Stand Model\" action in the toolbar. Then, create your animation in the animate tab as normal. Make sure the snapping value for keyframes is set to 20 for best results. When you're finished, use the \"Export Armor Stand Animation\" action in the toolbar. Change the settings as needed, then click \"Confirm\". Animations can be run with \"/execute as @e[tag=entity_tag] run function animation_pack:start\". The armor stand for an animation can be summoned with \"/function animation_pack:create\". Supports looping, time scale, block/blockbench unit scale, and start delays.", 7 | "tags": ["Minecraft: Java Edition"], 8 | "icon": "fa-forward", 9 | "version": "1.1.0", 10 | "min_version": "4.0.0", 11 | "variant": "both" 12 | }, 13 | "animator": { 14 | "title": "Java Item Model Animator", 15 | "author": "Command master", 16 | "description": "Takes two java item models and outputs a zip with a resourcepack and a datapack to make a clear transaction between them in the players hand (the plugin can also work for other animations but the datapack have to be coded manually).", 17 | "about": "The animation is activated using \"/scoreboard players set @s animation 0\".\nTo use click Filter -> Save starting model the save the first model, and then click on File -> Export -> Export animation to download the ZIP file of the animation", 18 | "tags": ["Minecraft: Java Edition"], 19 | "icon": "compare", 20 | "variant": "both" 21 | }, 22 | "geenium_bedrock_entity_helper": { 23 | "title": "Bedrock Entity Model Presets", 24 | "author": "Geenium", 25 | "description": "Loads Bedrock vanilla entity models up to version 1.13", 26 | "tags": ["Minecraft: Bedrock Edition"], 27 | "icon": "pets", 28 | "variant": "both" 29 | }, 30 | "missing_texture_highlighter": { 31 | "title": "Missing Texture Highlighter", 32 | "icon": "flash_on", 33 | "author": "JannisX11", 34 | "description": "Highlights missing textures by flashing them.", 35 | "variant": "both" 36 | }, 37 | "double_sided_cubes": { 38 | "title": "Double Sided Cube Generator", 39 | "author": "SnaveSutit", 40 | "icon": "flip_to_back", 41 | "description": "Creates inverted duplicates of the selected cube(s) to allow double-sided rendering in Minecraft: Java Edition.", 42 | "tags": ["Minecraft: Java Edition"], 43 | "version": "1.0.0", 44 | "variant": "both" 45 | }, 46 | "optimize": { 47 | "title": "Optimize", 48 | "author": "Krozi", 49 | "description": "Hide concealed faces for better performance!", 50 | "icon": "border_outer", 51 | "min_version": "2.0.2", 52 | "variant": "both" 53 | }, 54 | "blockmodels-exporter": { 55 | "title": "Export to BlockModels.com", 56 | "author": "TheDestruc7i0n", 57 | "description": "Export models from Blockbench directly to BlockModels.com", 58 | "icon": "web", 59 | "variant": "both" 60 | }, 61 | "plaster": { 62 | "title": "Plaster", 63 | "author": "JannisX11", 64 | "description": "Fixes texture bleeding (small white or colored lines around the edges of your model) by slightly shrinking UV maps", 65 | "icon": "healing", 66 | "min_version": "3.0.0", 67 | "variant": "both" 68 | }, 69 | "shape_generator": { 70 | "title": "Shape Generator", 71 | "author": "dragonmaster95", 72 | "description": "Generates shapes.", 73 | "icon": "pages", 74 | "min_version": "3.0.2", 75 | "variant": "both" 76 | }, 77 | "outline_creator": { 78 | "title": "Outline Creator", 79 | "author": "Wither", 80 | "description": "Creates stylistic outlines for cubes using negative scale values.", 81 | "about": "To use the plugin, select an element you want to create an outline for, go to the Filter tab and click on the Create Outline option.", 82 | "icon": "crop_square", 83 | "min_version": "3.0.0", 84 | "variant": "both" 85 | }, 86 | "vox_importer": { 87 | "title": "Voxel Importer", 88 | "author": "JannisX11", 89 | "description": "Import voxel (.vox) files", 90 | "icon": "view_module", 91 | "variant": "both" 92 | }, 93 | "texture_editor": { 94 | "title": "Texture Editor", 95 | "author": "JannisX11", 96 | "description": "Adds basic image manipulation functions - like contrast and saturation - to textures", 97 | "about": "To edit a texture, right click it and enter the Texture Editor menu. Select what you want to edit from the menu.", 98 | "icon": "photo_filter", 99 | "min_version": "2.0.0", 100 | "variant": "both" 101 | }, 102 | "player_statue_generator": { 103 | "title": "Player Statue Generator", 104 | "author": "Wither, dragonmaster95 and 3XH6R", 105 | "description": "Generates player shaped models.", 106 | "tags": ["Minecraft"], 107 | "icon": "accessibility", 108 | "min_version": "3.0.0", 109 | "variant": "both" 110 | }, 111 | "clone_brush": { 112 | "title": "Clone Brush", 113 | "author": "JannisX11", 114 | "icon": "account_balance_wallet", 115 | "description": "Clone Cubes", 116 | "min_version": "3.0.0", 117 | "variant": "both" 118 | }, 119 | "mod_utils": { 120 | "title": "Mod Utils", 121 | "author": "JTK222 (Maintainer) & Wither (For the Techne importer)", 122 | "icon": "fa-cubes", 123 | "description": "Allows importing Tabula files, and exporting VoxelShapes", 124 | "tags": ["Minecraft: Java Edition"], 125 | "version": "1.6.0", 126 | "variant": "desktop" 127 | }, 128 | "structure_importer": { 129 | "title": "Structure Importer", 130 | "icon": "account_balance", 131 | "author": "JannisX11 & Krozi", 132 | "description": "Import structure files", 133 | "version": "2.0.1", 134 | "variant": "both" 135 | }, 136 | "seat_position": { 137 | "title": "Seat Position", 138 | "icon": "event_seat", 139 | "author": "JannisX11", 140 | "description": "Preview seat positions for custom Bedrock entities", 141 | "tags": ["Minecraft: Bedrock Edition"], 142 | "min_version": "3.0.0", 143 | "variant": "both" 144 | }, 145 | "resource_pack_exporter": { 146 | "title": "Resource Pack Exporter", 147 | "icon": "archive", 148 | "author": "Wither", 149 | "description": "Exports your model as a ready-to-use Minecraft resource pack", 150 | "tags": ["Minecraft: Java Edition"], 151 | "variant": "both", 152 | "min_version": "3.0.0" 153 | }, 154 | "ambient_occlusion": { 155 | "title": "Ambient Occlusion", 156 | "icon": "gradient", 157 | "author": "JannisX11", 158 | "description": "Adds a screen space ambient occlusion shader", 159 | "about": "Ambient Occlusion is enabled by default. You can adjust the intensity or disable it in the settings.", 160 | "variant": "both", 161 | "min_version": "3.4.0" 162 | }, 163 | "guessing_game": { 164 | "title": "The Guessing Game", 165 | "icon": "casino", 166 | "author": "JannisX11", 167 | "description": "Play the guessing game in Blockbench with your friends!", 168 | "variant": "both", 169 | "min_version": "3.1.0" 170 | }, 171 | "csmodel": { 172 | "title": "CraftStudio Model Format", 173 | "icon": "star", 174 | "author": "JannisX11", 175 | "description": "Allows to import and export CraftStudio Models (.csmodel).", 176 | "about": "To **import** a model from CraftStudio, go to the CraftStudio project settings and export a cspack file. Open this file in an archive manager (like 7zip) and extract the model file. Import the file into Blockbench using the import menu. \nTo **export** a file, export a .csmodel file from Blockbench and drop it into an existing .cspack file into the Models folder. Make sure it is using the same file name as the old model in the pack. Import the .cspack into CraftStudio and select the models you want to import.", 177 | "tags": ["CraftStudio"], 178 | "variant": "both", 179 | "min_version": "3.2.0" 180 | }, 181 | "screencast_keys": { 182 | "title": "Screencast Keys", 183 | "icon": "keyboard", 184 | "author": "JannisX11", 185 | "description": "Displays the key combinations you press on screen. Useful for tutorial videos.", 186 | "variant": "both", 187 | "min_version": "3.2.1" 188 | }, 189 | "discord-rpc": { 190 | "title": "Discord RPC", 191 | "icon": "announcement", 192 | "author": "strajabot, Kastle, & simplyme", 193 | "description": "Show a rich presence status in Discord.", 194 | "variant": "desktop", 195 | "min_version": "3.2.0" 196 | }, 197 | "cem_template_loader": { 198 | "title": "CEM Template Loader", 199 | "icon": "keyboard_capslock", 200 | "author": "Ewan Howell", 201 | "description": "Load template entity models for use with OptiFine CEM.", 202 | "about": "CEM Template Loader helps you create custom entitiy models for use in OptiFine. To use, head to the \"Filter\" tab and select \"CEM Template Loader\". From here, select the model that you would like to edit, and load it. When editing entity models, make sure not move any pivot points of main folders, or create any new main folders. After editing your model, export it as an OptiFine JEM to the folder \"assets/minecraft/optifine/cem\". If a texture is used in the model, it must be saved in this same location.", 203 | "tags": ["Minecraft: Java Edition", "OptiFine", "Templates"], 204 | "variant": "both", 205 | "min_version": "3.9.2" 206 | }, 207 | "threecore_exporter": { 208 | "title": "ThreeCore Exporter", 209 | "author": "Lucas, Spyeedy", 210 | "icon": "looks_3", 211 | "description": "Let's you export your models in the json entity model format for the ThreeCore mod!", 212 | "version": "1.0.3", 213 | "variant": "both", 214 | "min_version": "3.7.5" 215 | }, 216 | "startup_tips": { 217 | "title": "Startup Tips", 218 | "author": "TheOtterlord", 219 | "icon": "info", 220 | "description": "This plugin provides helpful tips for those unfamiliar with Blockbench", 221 | "about": "The Startup Tips plugin provides helpful tips about Blockbench on startup. These tips include tips about model creation, external resources, the editor, and more", 222 | "version": "1.1.0", 223 | "variant": "both" 224 | }, 225 | "animation_utils": { 226 | "title": "GeckoLib Animation Utils", 227 | "author": "Eliot Lash, Gecko, McHorse", 228 | "icon": "movie_filter", 229 | "description": "Create animated blocks, items, entity, and armor using the GeckoLib library and plugin. https://geckolib.com", 230 | "tags": ["Minecraft: Java Edition"], 231 | "version": "3.0.0", 232 | "min_version": "3.7.0", 233 | "await_loading": true, 234 | "variant": "both" 235 | }, 236 | "modded_entity_fabric": { 237 | "title": "Fabric Modded Entity", 238 | "author": "Eliot Lash", 239 | "icon": "icon-format_java", 240 | "description": "Plugin for exporting Modded Entities using Fabric/Yarn Sourcemap", 241 | "tags": ["Minecraft: Java Edition"], 242 | "version": "0.2.1", 243 | "min_version": "3.6.6", 244 | "variant": "both" 245 | }, 246 | "multi-layer": { 247 | "title": "Multi-Layer", 248 | "icon": "layers", 249 | "author": "aidancbrady", 250 | "description": "Allows exporting in Forge's multi-layer model format.", 251 | "tags": ["Minecraft: Java Edition"], 252 | "version": "1.0", 253 | "variant": "both" 254 | }, 255 | "light_tracer_uploader": { 256 | "title": "Light Tracer Uploader", 257 | "icon": "fas.fa-feather", 258 | "author": "JannisX11", 259 | "description": "Upload models to Light Tracer to share them or to create renders", 260 | "version": "0.0.1", 261 | "variant": "both" 262 | }, 263 | "code_view": { 264 | "title": "Code View", 265 | "icon": "developer_mode", 266 | "author": "wither", 267 | "description": "View the model you are currently editing in the raw format", 268 | "version": "1.0.1", 269 | "variant": "both" 270 | }, 271 | "only": { 272 | "title": "Only", 273 | "icon": "fa-glasses", 274 | "author": "JannisX11", 275 | "description": "Hide everything except for the selected cubes", 276 | "version": "0.0.1", 277 | "variant": "both" 278 | }, 279 | "datagen_export": { 280 | "title": "Datagen Export", 281 | "icon": "code", 282 | "author": "itsmeow", 283 | "description": "Allows exporting to BlockStateProvider datagen code", 284 | "version": "1.0.0", 285 | "variant": "both" 286 | }, 287 | "duplicate_renamer": { 288 | "title": "Duplicate Bone Renamer", 289 | "icon": "fa-font", 290 | "author": "Gecko", 291 | "description": "This plugin renames duplicate bones so they work in bedrock and GeckoLib models", 292 | "version": "1.0.0", 293 | "variant": "both" 294 | }, 295 | "texture_stitcher": { 296 | "title": "Texture Stitcher", 297 | "icon": "fa-compress-arrows-alt", 298 | "author": "McHorse", 299 | "description": "Adds a menu item to textures editor that stitches multiple textures into one", 300 | "version": "1.0.0", 301 | "variant": "both" 302 | }, 303 | "arcaniax_block_exporter": { 304 | "title": "Bedrock Block Exporter", 305 | "icon": "icon-format_block", 306 | "author": "Arcaniax", 307 | "description": "Helps making new Bedrock blocks (requires experimental mode)", 308 | "tags": ["Minecraft: Bedrock Edition"], 309 | "variant": "desktop", 310 | "version": "0.1.1" 311 | }, 312 | "bakery": { 313 | "title": "Bakery", 314 | "icon": "storefront", 315 | "author": "JannisX11", 316 | "description": "Bakes complex animations into simple linear keyframes", 317 | "version": "1.0.0", 318 | "min_version": "3.7.0", 319 | "variant": "both" 320 | }, 321 | "bedrock_pivot_fix": { 322 | "title": "Bedrock Pivot Fix 2", 323 | "icon": "gps_fixed", 324 | "author": "JannisX11", 325 | "description": "Rotated cubes are broken in custom-block models in Minecraft: Bedrock Edition. Use this plugin to fix them.", 326 | "about": "After installing, use **Filter > Fix Bedrock Pivots** to fix your current model.", 327 | "tags": ["Minecraft: Bedrock Edition"], 328 | "version": "2.0.0", 329 | "min_version": "3.0.0", 330 | "variant": "both" 331 | }, 332 | "simplify": { 333 | "title": "Simplify Models", 334 | "icon": "build", 335 | "author": "Ryan Garrett", 336 | "description": "Simplifies the cubes in a model. For example if a block was 0.99 pixels wide, then it would change it to 1.", 337 | "version": "0.2.0", 338 | "variant": "both" 339 | }, 340 | "animation_sliders": { 341 | "title": "Animation Sliders", 342 | "icon": "fas.fa-bezier-curve", 343 | "author": "JannisX11", 344 | "description": "Adds multiple sliders to tweak keyframes", 345 | "about": "Adds sliders and other tools to modify keyframes:\n\nYou can add the sliders and tools to any of your toolbars by clicking the three dots on the right side and selecting **Customize**. Search for the slider you want to add and click to add it.\n\n- **Tween Keyframes:** Amplify the values of the selected keyframes\n- **Amplify Keyframes:** Amplify the values of the selected keyframes\n- **Ease Keyframes:** Create a curve with the selected keyframes between the adjacent keyframes\n- **Keyframe Slider Axis:** Select which axis the keyframe sliders affect\n- **Create Keyframe Column:** Key all channels in the timeline at the current timecode, if they already have keyframes\n- **Select Keyframe Column:** Select all keyframes in the timeline along a column below the playhead", 346 | "tags": ["Animation"], 347 | "version": "0.1.0", 348 | "min_version": "3.7.0", 349 | "variant": "both" 350 | }, 351 | "mimodel_format": { 352 | "title": "Mine-imator Model Exporter", 353 | "icon": "fas.fa-box-open", 354 | "author": "JannisX11", 355 | "description": "Export .mimodel files for Mine-imator and Modelbench", 356 | "tags": ["Exporter"], 357 | "version": "1.0.1", 358 | "min_version": "3.7.0", 359 | "variant": "both" 360 | }, 361 | "minecraft_entity_wizard": { 362 | "title": "Minecraft Entity Wizard", 363 | "author": "JannisX11 & Mojang Studios", 364 | "icon": "fas.fa-hat-wizard", 365 | "description": "Create entities for Minecraft: Bedrock Edition! Start with the looks and behavior of a vanilla entity, and turn it into your own creation!", 366 | "tags": ["Minecraft: Bedrock Edition"], 367 | "version": "1.0.1", 368 | "min_version": "3.7.0", 369 | "variant": "both" 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /plugins/armor_stand_animator.js: -------------------------------------------------------------------------------- 1 | // TODO Make into custom model format, allow multiple armor stands in one animation 2 | 3 | // Credit to Misode for this (I did some, you can view the wizardry he pulled over here (https://github.com/misode/vscode-nbt/blob/master/src/common/Snbt.ts)) 4 | window.SNBT = (function () { 5 | function SNBT() {} 6 | SNBT.stringify = function (type, data) { 7 | switch (type) { 8 | case "compound": return Object.keys(data).length === 0 ? "{}": 9 | "{" + Object.entries(data).map(function (_a) { 10 | let key = _a[0], value = _a[1]; 11 | return (key + ": " + SNBT.stringify(value.type, value.value)); 12 | }).join(',') + "}"; 13 | case "list": return data.value.length === 0 ? "[]": 14 | SNBT.isCompact(data.type) ? 15 | "[" + SNBT.stringifyEntries(data.type, data.value, ", ") + "]" : 16 | "[" + SNBT.stringifyEntries(data.type, data.value, ',') + "]"; 17 | case "floatList": return "[" + SNBT.stringifyEntries("float", data, ", ") + "]"; 18 | case "string": return "\"" + data.replace(/(\\|")/g, "\\$1") + "\""; 19 | case "byte": return data + "b"; 20 | case "double": return data + "d"; 21 | case "float": return data + "f"; 22 | case "short": return data + "s"; 23 | case "int": return "" + data; 24 | case "long": return SNBT.stringifyLong(data) + "L"; 25 | default: return "null"; 26 | } 27 | }; 28 | SNBT.stringifyLong = function (value) { 29 | SNBT.dataView.setInt32(0, Number(value[0])); 30 | SNBT.dataView.setInt32(4, Number(value[1])); 31 | return "" + SNBT.dataView.getBigInt64(0); 32 | }; 33 | SNBT.stringifyEntries = function (type, values, join) { 34 | return values.map(function (v) { 35 | return ("" + SNBT.stringify(type, v)); 36 | }).join(join); 37 | }; 38 | SNBT.isCompact = function (type) { 39 | return type === 'byte' || type === 'double' || type === 'float' || type === 'short' || type === 'int' || type === 'long'; 40 | }; 41 | SNBT.bytes = new Uint8Array(8); 42 | SNBT.dataView = new DataView(SNBT.bytes.buffer); 43 | return SNBT; 44 | }()); 45 | 46 | function roundKeyframeTime(time) { 47 | return Number((Math.ceil(time.toFixed(2) / 0.05) * 0.05).toFixed(2)); 48 | } 49 | 50 | function getNextKey(dict, currentKey) { 51 | let keys = Object.keys(dict); 52 | return keys[keys.indexOf(String(currentKey)) + 1]; 53 | } 54 | 55 | function getRootBoneNameDifferentiator(boneName, numberCheck) { 56 | // Try to find an existing armor stand root bone 57 | let primaryArmorStandBone = Outliner.root.find(q => (q.name === (numberCheck === 0 ? boneName : `${boneName}${numberCheck}`))); 58 | // If it didn't exist, return the name (and the differentiator if it wasn't 0) as a string 59 | if (!primaryArmorStandBone) { 60 | return numberCheck === 0 ? boneName : `${boneName}${numberCheck}`; 61 | } else { 62 | // Otherwise, check the next number 63 | return getRootBoneNameDifferentiator(boneName, numberCheck + 1); 64 | } 65 | } 66 | 67 | function generatePackFromAnimation(animationContents, animationName, configData, loopMode) { 68 | let zip = new JSZip(); 69 | 70 | // Load in config data and filter invalid characters 71 | let packName = configData.packName.replace(/[^a-z0-9_.]/g, ""); 72 | animationName = animationName.replace(/[^a-zA-Z0-9_.]/g, '_'); 73 | 74 | let timerObjectiveName = `${animationName}.timer`; 75 | let isPausedObjectiveName = `${animationName}.is_paused`; 76 | 77 | 78 | // Set the working DIR depending on whether or not we're generating a pack namespace or a full pack 79 | let workingDir; 80 | if (configData.generationMode === "namespace") { 81 | workingDir = `${packName}/functions`; 82 | } else if (configData.generationMode === "data_pack") { 83 | // Generate the extra files needed for it to be a pack 84 | zip.file(`pack.mcmeta`, 85 | `{ 86 | "pack": { 87 | "pack_format": 7, 88 | "description": "Armor Stand Animation: ${packName}" 89 | } 90 | }` 91 | ); 92 | 93 | zip.file(`data/minecraft/tags/functions/load.json`, 94 | `{ 95 | "values": [ 96 | "${packName}:init" 97 | ] 98 | }` 99 | ); 100 | 101 | workingDir = `data/${packName}/functions`; 102 | } 103 | 104 | // If playback control is enabled (pause/play), generate each function 105 | if (configData.playbackControl) { 106 | // Just invert the timer score and mark it as paused 107 | zip.file(`${workingDir}/pause.mcfunction`, `scoreboard players operation @s ${timerObjectiveName} *= #-1 ${isPausedObjectiveName}\nscoreboard players set @s ${isPausedObjectiveName} 1`); 108 | 109 | // Re-invert the timer score, and begin the search at the correct frame 110 | resumeCommands = `scoreboard players operation @s ${timerObjectiveName} *= #-1 ${isPausedObjectiveName}`; 111 | Object.keys(animationContents).slice().reverse().forEach( 112 | item => resumeCommands += `\nexecute if score @s ${isPausedObjectiveName} matches 1 if score @s ${timerObjectiveName} matches ${item} run function ${packName}:search/${item}` 113 | ); 114 | 115 | zip.file(`${workingDir}/resume.mcfunction`, resumeCommands); 116 | } 117 | 118 | // Stop the animation by setting it into an invalid frame number 119 | zip.file(`${workingDir}/stop.mcfunction`, `scoreboard players set @s ${timerObjectiveName} -2147483646`); 120 | 121 | // Add the necessary timer objective, plus is paused objective and -1 constant if playback control is enabled 122 | initCommands = `scoreboard objectives add ${timerObjectiveName} dummy`; 123 | if (configData.playbackControl) { initCommands += `\nscoreboard players set #-1 ${isPausedObjectiveName} -1\nscoreboard objectives add ${isPausedObjectiveName} dummy`; } 124 | zip.file(`${workingDir}/init.mcfunction`, initCommands); 125 | 126 | // Armor stand creation function 127 | zip.file(`${workingDir}/create.mcfunction`, `summon minecraft:armor_stand ~ ~ ~ {Tags:["${configData.entityTag}"], NoBasePlate:1b, ShowArms:1b, Pose:{LeftArm:[0f, 0f, 0f], RightArm:[0f, 0f, 0f], LeftLeg:[0f, 0f, 0f], RightLeg:[0f, 0f, 0f]}}`); 128 | 129 | // This is needed since we need to create the start function if this is the first frame, and it can't be hardcoded since the start frame doesn't have to be frame 1 130 | let isFirstFrame = true; 131 | 132 | // Iterate through each keyframe in the data 133 | for ([keyframeTime, keyframeContents] of Object.entries(animationContents)) { 134 | // Used to calculate how long we should wait 135 | let nextKeyframeTime = getNextKey(animationContents, keyframeTime); 136 | let differenceBetweenNextAndCurrentKeyframe = Number(nextKeyframeTime) - Number(keyframeTime); 137 | 138 | // Generate commands for this keyframe 139 | let commands = []; 140 | // Set to true if the armor stand moves that frame. Marks if we need to include "at @s" in the execute command 141 | let includePositionalContext = false; 142 | if ("pos" in keyframeContents) { 143 | includePositionalContext = true; 144 | commands.push(`teleport @s ~${keyframeContents.pos.map(q => q * configData.blockUnitScale).join(" ~")}`); 145 | } 146 | if ("rot" in keyframeContents) { 147 | let outputNbt = {}; 148 | let boneNameToKey = { 149 | head: "Head", 150 | leftArm: "LeftArm", 151 | rightArm: "RightArm", 152 | body: "Body", 153 | leftLeg: "LeftLeg", 154 | rightLeg: "RightLeg" 155 | }; 156 | // For every limb and its rotation, generate the NBT 157 | for ([boneName, boneRotation] of Object.entries(keyframeContents.rot)) { 158 | if (boneName !== "main") { 159 | outputNbt.Pose ??= {type: "compound", value: {}}; 160 | outputNbt.Pose.value[boneNameToKey[boneName]] = {type: "floatList", value: boneRotation}; 161 | } else { 162 | outputNbt.Rotation = {type: "floatList", value: boneRotation}; 163 | } 164 | 165 | } 166 | commands.push(`data merge entity @s ${window.SNBT.stringify("compound", outputNbt)}`); 167 | } 168 | 169 | // Make sure there are some commands to add 170 | if (commands.length === 0) { continue; } 171 | 172 | // Create the start function 173 | if (isFirstFrame) { 174 | zip.file(`${workingDir}/start.mcfunction`, `data merge entity @s {Pose:{Head:[0f, 0f, 0f], LeftArm:[0f, 0f, 0f], RightArm:[0f, 0f, 0f], LeftLeg:[0f, 0f, 0f], RightLeg:[0f, 0f, 0f]}}\nscoreboard players set @s ${timerObjectiveName} ${keyframeTime}\nschedule function ${packName}:search/${keyframeTime} ${differenceBetweenNextAndCurrentKeyframe}t append`); 175 | isFirstFrame = false; 176 | } 177 | 178 | // Generates the functions needed for each frame (2 functions per frame, scheduling each other) 179 | searchCommands = `execute as @e[tag=${configData.entityTag},scores={${timerObjectiveName}=${keyframeTime}}]${includePositionalContext ? " at @s" : ""} run function ${packName}:frames/${keyframeTime}`; 180 | if (configData.playbackControl) { searchCommands += `\nscoreboard players set @s ${isPausedObjectiveName} 0`; } 181 | zip.file(`${workingDir}/search/${keyframeTime}.mcfunction`, searchCommands); 182 | 183 | // This check is needed because otherwise the last frame will have something like "scoreboard players set ....timer undefined " and "schedule function ... NaNt append". It also lets us add the loop code. 184 | if (nextKeyframeTime) { 185 | zip.file(`${workingDir}/frames/${keyframeTime}.mcfunction`, `${commands.join("\n")}\nscoreboard players set @s ${timerObjectiveName} ${nextKeyframeTime}\nschedule function ${packName}:search/${nextKeyframeTime} ${differenceBetweenNextAndCurrentKeyframe}t append`); 186 | } else { 187 | let extraCommands = []; 188 | if (loopMode === "loop") { 189 | extraCommands.push(`scoreboard players set @s ${timerObjectiveName} -2147483648`, `schedule function ${packName}:delay_for_loop 1t`); 190 | zip.file(`${workingDir}/delay_for_loop.mcfunction`, `scoreboard players set @e[tag=${configData.entityTag},scores={${timerObjectiveName}=-2147483648}] instrument1.timer 0\nfunction ${packName}:search/0`); 191 | } 192 | // "once" and "hold" loop modes are ignored since the animation stops on its own 193 | zip.file(`${workingDir}/frames/${keyframeTime}.mcfunction`, `${commands.join("\n")}\n${extraCommands.join("\n")}`); 194 | } 195 | } 196 | 197 | // Export the completed data pack 198 | zip.generateAsync({type: "blob"}).then(content => { 199 | Blockbench.export({ 200 | startpath: Project.export_path, 201 | type: "Zip Archive", 202 | extensions: ["zip"], 203 | name: animationName, 204 | content: content, 205 | savetype: "zip" 206 | }); 207 | }); 208 | } 209 | 210 | (function() { 211 | Plugin.register("armor_stand_animator", { 212 | title: "Armor Stand Animator", 213 | author: "DoubleFelix", 214 | description: "Provides an interface to animate armor stands which is converted to a data pack", 215 | tags: ["Minecraft: Java Edition"], 216 | icon: "fa-forward", 217 | version: "1.1.0", 218 | variant: "both", 219 | onload() { 220 | // Both actions are globals 221 | createArmorStandAction = new Action("create_armor_stand", { 222 | name: "Create Armor Stand Model", 223 | description: "Creates an armor stand model, complete with bones and textures", 224 | icon: "person_add", 225 | click: function() { 226 | new Dialog("createArmorStandOptions", { 227 | title: "Create Armor Stand Model", 228 | form: { 229 | includeBasePlate: {type: "checkbox", label: "Include Base Plate", value: false}, 230 | armorStandName: {type: "text", label: "Armor Stand Name", value: getRootBoneNameDifferentiator("armor_stand", 0)}, 231 | }, 232 | onConfirm: function(formData) { 233 | // Set UV width and height 234 | Project.texture_width = 48; 235 | Project.texture_height = 25; 236 | 237 | // Returns undefined if it couldn't find the texture 238 | let armorStandTexture = Texture.all.find(q => (q.name === "armor_stand")); 239 | // If a texture with that name does not exist, create it 240 | if (!armorStandTexture) { 241 | armorStandTexture = new Texture({ 242 | name: "armor_stand", 243 | mode: "bitmap", 244 | source: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAZCAMAAAE3eG3dAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAABCUExURQAAAMKdYriUX5+ETcagZ7SQWn5iN6+PVZZ0QWdQLKKCUamJWHNeOYeHh4CAgKioqLCwsJ2dnaOjo3h4eJKSkgAAAEcA5fkAAAAWdFJOU////////////////////////////wAB0sDkAAAACXBIWXMAAA7AAAAOwAFq1okJAAACBklEQVQoU32T7ZakIAxECYi0M6PS9OT9X3VvBe3t/bM5GsHKRyWB5G7ZPTXPOXvKPvZu6XyOcXawXMAeS8kVk70PtzR6H+dIDeSW5D++HwePW0tmvo/B42aEcC+5hHEqTZ8pqWWzXGvJxqa6y30nQFGAHgK1BKsRomh8nqfSjwG7BS/3xVazUBGXYJYL64WCCC59IUEm2JSNN1QmoYg1qO0EP8+pKT03qDRRp/Dez6ndWyuZ8tWa/Rz9HFPjgfHMgShtxL/kL12ohorfAIXs1/ofoQ7VZIUSKAm9XACbBlhqrlatVfYTkLoLu+p0/zKAb6WLEV2P5t5yItLDPca9T33VAYpLp9H9NTUexnSEulMyHZk6WlJSgaTPYTF86SBv6i45GFpXq6R1sPDQHBX6PDlTGi1Yf3YdvGWp0a4pUb6GiRfybuotnBLRus+XbNfME15zNZFb0uOxbtu2wmuWxTuVLWWxBbVc3Z5CzwsZOI0IGHhYSt7OYXlJUnRl0cXioK8Niiirc1srW+Z3mcfw7Os+qv+X+v2DHTeIoluJGnZaKOHsHp9rYfQfDrmJ0qq6w4Gu65oj++damOxkyKzDMZp3DIx4mdP+uRam3tCdxyz6yvCaNhru63MtDLNVA0iiJm/91AV7wgOr38+1MDrfMrc0QS2S6OeBvH45zccu9V4LgwWG2/YHQJY6amRT2pAAAAAASUVORK5CYII=", 245 | width: 48, 246 | height: 25 247 | }); 248 | armorStandTexture.add(); 249 | armorStandTexture.load(); 250 | } 251 | 252 | let armorStand = new Group({name: formData.armorStandName, origin: [0, 0, 0], isOpen: true}).init(); 253 | 254 | let headBone = new Group({name: "head_bone", origin: [0, 21.5, 0], isOpen: true}).addTo(armorStand).init(); 255 | let headCube = new Cube({name: "head", from: [-1, 23, -1], to: [1, 29, 1], uv_offset: [0, 1]}).addTo(headBone).init(); 256 | 257 | let leftArmBone = new Group({name: "left_arm_bone", origin: [6.25, 21.25, 0], isOpen: true}).addTo(armorStand).init(); 258 | let leftArmCube = new Cube({name: "left_arm", from: [5, 11, -1], to: [7, 23, 1], uv_offset: [40, 10]}).addTo(leftArmBone).init(); 259 | let rightArmBone = new Group({name: "right_arm_bone", origin: [-6.25, 21.25, 0], isOpen: true}).addTo(armorStand).init(); 260 | let rightArmCube = new Cube({name: "right_arm", from: [-7, 11, -1], to: [-5, 23, 1], uv_offset: [40, 10]}).addTo(rightArmBone).init(); 261 | 262 | let bodyBone = new Group({name: "body_bone", origin: [0, 23.5, 0], isOpen: true}).addTo(armorStand).init(); 263 | let leftRibCube = new Cube({name: "left_rib", from: [1, 13, -1], to: [3, 20, 1], uv_offset: [0, 1]}).addTo(bodyBone).init(); 264 | let rightRibCube = new Cube({name: "right_rib", from: [-3, 13, -1], to: [-1, 20, 1], uv_offset: [0, 1]}).addTo(bodyBone).init(); 265 | let collarCube = new Cube({name: "collar", from: [-6, 20, -1.5], to: [6, 23, 1.5], uv_offset: [9, 5]}).addTo(bodyBone).init(); 266 | let hipCube = new Cube({name: "hip", from: [-4, 11, -1], to: [4, 13, 1], uv_offset: [14, 0]}).addTo(bodyBone).init(); 267 | 268 | let leftLegBone = new Group({name: "left_leg_bone", origin: [2, 12, 0], isOpen: true}).addTo(armorStand).init(); 269 | let leftLegCube = new Cube({name: "left_leg", from: [1, 0.5, -1], to: [3, 11, 1], uv_offset: [0, 11]}).addTo(leftLegBone).init(); 270 | let rightLegBone = new Group({name: "right_leg_bone", origin: [-2, 12, 0], isOpen: true}).addTo(armorStand).init(); 271 | let rightLegCube = new Cube({name: "right_leg", from: [-3, 0.5, -1], to: [-1, 11, 1], uv_offset: [0, 11]}).addTo(rightLegBone).init(); 272 | 273 | let cubesToApplyTexturesTo = [headCube, leftArmCube, rightArmCube, leftRibCube, rightRibCube, collarCube, hipCube, leftLegCube, rightLegCube]; 274 | 275 | if (formData.includeBasePlate) { 276 | let plate = new Cube({name: "plate", from: [-6, 0, -6], to: [6, 1, 6], uv_offset: [0, 12]}).addTo(armorStand).init(); 277 | cubesToApplyTexturesTo.push(plate); 278 | } 279 | 280 | for (let cube of cubesToApplyTexturesTo) { 281 | cube.applyTexture(armorStandTexture, true); 282 | } 283 | 284 | this.hide(); 285 | } 286 | }).show(); 287 | 288 | // TODO new texture and new model 289 | 290 | // Hardcoded positions and sizes 291 | 292 | 293 | 294 | } 295 | }); 296 | 297 | exportAnimationAction = new Action("export_animation", { 298 | name: "Export Armor Stand Animation", 299 | description: "Exports the animation to a data pack", 300 | icon: "file_download", 301 | click: function() { 302 | let startTime = new Date(); 303 | 304 | // Fetch the selected animation by checking which one has the selected property 305 | let selectedAnimation = Animator.animations.find(q => (q.selected === true)); 306 | 307 | // Set the start delay to 0 if it wasn't defined by the user 308 | let animationStartDelay = selectedAnimation.startDelay; 309 | animationStartDelay = typeof animationStartDelay === "number" ? animationStartDelay : 0; 310 | 311 | // If no animation is selected, show an error 312 | if (selectedAnimation === undefined) { 313 | Blockbench.showQuickMessage("You must select an animation to export", 2000); 314 | return; 315 | } 316 | 317 | // Check snapping values and their validity 318 | let waitingForSnappingWarning = false; 319 | let shouldReturn = false; 320 | if (selectedAnimation.snapping > 20 || selectedAnimation.snapping % 10 !== 0) { 321 | waitingForSnappingWarning = true; 322 | Blockbench.showMessageBox({ 323 | title: "Warning", 324 | message: "The snapping value of this animation is 10 or 20. All keyframe times will be rounded up to the nearest 50th millisecond. Do you wish to continue?", 325 | icon: "warning", 326 | buttons: ["Yes", "No"], 327 | confirm: 0, 328 | cancel: 1, 329 | }, function (buttonIndex) { 330 | if (buttonIndex === 0) { 331 | waitingForSnappingWarning = false; 332 | } else if (buttonIndex === 1) { 333 | shouldReturn = true; 334 | } 335 | }); 336 | 337 | // Returning where the "shouldReturn = true;" is will cause it to exit the callback function, not this one. 338 | if (shouldReturn) { return; } 339 | } 340 | 341 | // Wrapper function for the warning check done below 342 | function makeAnimation() { 343 | // Bind all the bone objects to variables 344 | let bones = Object.values(selectedAnimation.animators); 345 | let parentBone = bones.find(q => (q.name === "armor_stand")); 346 | let headBone = bones.find(q => (q.name === "head_bone")); 347 | let leftArmBone = bones.find(q => (q.name === "left_arm_bone")); 348 | let rightArmBone = bones.find(q => (q.name === "right_arm_bone")); 349 | let bodyBone = bones.find(q => (q.name === "body_bone")); 350 | let leftLegBone = bones.find(q => (q.name === "left_leg_bone")); 351 | let rightLegBone = bones.find(q => (q.name === "right_leg_bone")); 352 | 353 | // Remove the animation. prefix (it is added by blockbench by default) if it exists 354 | animationName = selectedAnimation.name.startsWith("animation.") ? selectedAnimation.name.slice(10) : selectedAnimation.name; 355 | 356 | new Dialog("exportAnimationOptions", { 357 | title: "Export Animation", 358 | form: { 359 | // Default value is 16 units to 1 block, minimum value is 64 units to 1 block 360 | blockUnitScale: {type: "number", label: "Block/Unit Ratio", value: 0.0625, min: 0.015625, max: 16, step: 0.1}, 361 | // How frames are turned into tick times. A time scale of 0.5 will make the exported animation play at half speed. 362 | timeScale: {type: "number", label: "Time Scale", value: 1, min: 0.1, max: 100, step: 0.1}, 363 | // The name of the pack namespace, and the data pack folder (if generateMode is data_pack) 364 | packName: {type: "text", label: "Pack Name", value: animationName, height: 30}, 365 | // The tag that the entity should have. Should be unique per animation. 366 | entityTag: {type: "text", label: "Entity Tag", value: animationName, height: 30}, 367 | // Whether or not the behavior for pausing and resuming the animation should be added to the data pack 368 | playbackControl: {type: "checkbox", label: "Enable Pause/Play Control", value: true}, 369 | // Whether or not the animation should be exported as a data pack or namespace (useful for integrating into one pack) 370 | generationMode: {type: "select", label: "Generation Mode", options: {data_pack: "Complete Data Pack", namespace: "Data Pack Namespace"}} 371 | }, 372 | onConfirm: function(formData) { 373 | // Loop through each bone and construct JSON data sorted by keyframe, then by rotation or position, then by bone 374 | let animationContent = {}; 375 | for ([boneObj, boneName] of [ 376 | [parentBone, "main"], 377 | [headBone, "head"], 378 | [leftArmBone, "leftArm"], 379 | [rightArmBone, "rightArm"], 380 | [bodyBone, "body"], 381 | [leftLegBone, "leftLeg"], 382 | [rightLegBone, "rightLeg"] 383 | ]) { 384 | // Show warning if the user modifies the position of any non-parent bone 385 | if (boneObj.position.length > 0 && boneName !== "main") { 386 | let positionWarning = Blockbench.showToastNotification({ 387 | text: "The animation includes a keyframe which modifies the position of a bone that is not \"armor_stand\". Positional animations for armor stand limbs are not supported and will not be included in the exported animation.", 388 | icon: "error", 389 | expire: 10000, 390 | click: function() { 391 | positionWarning.delete(); 392 | } 393 | }); 394 | } 395 | 396 | // Show warning if the user modifies the scale of the armor stand in any way 397 | if (boneObj.scale.length > 0) { 398 | let scaleWarning = Blockbench.showToastNotification({ 399 | text: "The animation includes a keyframe which modifies the scale of a bone. Scale animations are not supported and will not be included in the exported animation.", 400 | icon: "error", 401 | expire: 10000, 402 | click: function() { 403 | scaleWarning.delete(); 404 | } 405 | }); 406 | } 407 | 408 | let shouldDisplayXZRotationWarning = false; 409 | if (boneName === "main") { 410 | // Functionality is essentially the same when using .entries, but we need index for position calculation 411 | for ([index, keyframe] of boneObj.position.entries()) { 412 | let keyframeData = keyframe.data_points[0]; 413 | // Round the keyframe timer up to the nearest increment of 0.05, multiply it by 20 to turn it into ticks, and implement the starting delay 414 | keyframeTime = (roundKeyframeTime(keyframe.time) + animationStartDelay) * 20 * formData.timeScale; 415 | animationContent[keyframeTime] ??= {}; 416 | // If we couldn't find a previous keyframe, then set the positions to 0 so the below calculation doesn't change the current keyframe 417 | let previousKeyframe = boneObj.position[index - 1]; 418 | let previousKeyframeData = previousKeyframe ? previousKeyframe.data_points[0] : {x: 0, y: 0, z: 0}; 419 | animationContent[keyframeTime].pos = [(keyframeData.x - previousKeyframeData.x).toString(), (keyframeData.y - previousKeyframeData.y).toString(), (keyframeData.z - previousKeyframeData.z).toString()]; 420 | } 421 | 422 | // Handle armor stand base rotation 423 | for (keyframe of boneObj.rotation) { 424 | let keyframeData = keyframe.data_points[0]; 425 | if (!shouldDisplayXZRotationWarning && (keyframeData.x != 0 || keyframeData.z != 0)) { 426 | shouldDisplayXZRotationWarning = true; 427 | } 428 | keyframeTime = (roundKeyframeTime(keyframe.time) + animationStartDelay) * 20 * formData.timeScale; 429 | animationContent[keyframeTime] ??= {}; 430 | animationContent[keyframeTime].rot ??= {}; 431 | animationContent[keyframeTime].rot.main = [keyframeData.y.toString()]; 432 | } 433 | } else { 434 | for (keyframe of boneObj.rotation) { 435 | let keyframeData = keyframe.data_points[0]; 436 | keyframeTime = (roundKeyframeTime(keyframe.time) + animationStartDelay) * 20 * formData.timeScale; 437 | animationContent[keyframeTime] ??= {}; 438 | animationContent[keyframeTime].rot ??= {}; 439 | // Invert X and Z rotation since minecraft is weird 440 | animationContent[keyframeTime].rot[boneName] = [(keyframeData.x * -1).toString(), keyframeData.y.toString(), (keyframeData.z * -1).toString()]; 441 | } 442 | } 443 | 444 | // Display a warning if the user tries to modify any non-Y axis of the main armor stand bone 445 | if (shouldDisplayXZRotationWarning) { 446 | let rotationWarning = Blockbench.showToastNotification({ 447 | text: "The animation includes a keyframe which modifies the X or Z rotation of the \"armor_stand\" bone. Rotations for the \"armor_stand\" bone on the X and Z axis are not supported and will not be included in the exported animation.", 448 | icon: "error", 449 | expire: 10000, 450 | click: function() { 451 | rotationWarning.delete(); 452 | } 453 | }); 454 | } 455 | } 456 | this.hide(); 457 | 458 | generatePackFromAnimation(animationContent, animationName, formData, selectedAnimation.loop, startTime) 459 | } 460 | }).show(); 461 | } 462 | 463 | // Repeatedly poll to see if the snapping warning is gone, and if it is, then generate the animation. 464 | function checkSnappingWarningCompletion() { 465 | if (waitingForSnappingWarning) { 466 | setTimeout(checkSnappingWarningCompletion, 250); 467 | } else { 468 | makeAnimation(); 469 | } 470 | } 471 | checkSnappingWarningCompletion(); 472 | 473 | Blockbench.setStatusBarText(`Animation exported in ${(new Date() - startTime) / 100} seconds`); 474 | } 475 | }); 476 | MenuBar.addAction(createArmorStandAction, "filter"); 477 | MenuBar.addAction(exportAnimationAction, "filter"); 478 | }, 479 | onunload() { 480 | createArmorStandAction.delete(); 481 | exportAnimationAction.delete(); 482 | } 483 | }); 484 | })(); --------------------------------------------------------------------------------