diff --git a/blender/README.md b/blender/README.md index 17cff9338..1f3ea0afb 100644 --- a/blender/README.md +++ b/blender/README.md @@ -1,8 +1,9 @@ # Player Animation Tools The `.blend` files are [Blender](https://www.blender.org/) projects. -`emote_creator.blend` is the rig for blender 5.2+ and only for the latest version of Emotecraft! -[Emotecraft wiki](https://docs.zigythebird.com/emotecraft/creatingemotes/) if you're stuck. + +`emote_creator.blend` is the latest rig for blender, it has the most features. It is made for Blender 5.2+ and intended to work only on the latest version of PAL (1.2.5+mc.26.2 at the time this is written)! Other rigs work fine on versions lower. +Read [Emotecraft wiki](https://docs.zigythebird.com/emotecraft/creatingemotes/) to learn more about `emote_creator.blend`. `.bbmodel` files are models for [Blockbench](https://blockbench.net/). You can use them as well. To use them, you'll need to install the [GeckoLib](https://geckolib.com/) Blockbench plugin first. diff --git a/blender/emote_creator.blend b/blender/emote_creator.blend index dc450403a..2239c9daf 100644 Binary files a/blender/emote_creator.blend and b/blender/emote_creator.blend differ diff --git a/blender/export.py b/blender/export.py index 8b9354817..e9403b885 100644 --- a/blender/export.py +++ b/blender/export.py @@ -1,112 +1,62 @@ import sys, bpy, json from pathlib import Path -project_dir = Path(bpy.data.filepath).parent -rig_object = bpy.data.objects["export_armature"] +import base64 +import tempfile +import os +rig_object = bpy.context.active_object action = rig_object.animation_data.action scene = bpy.context.scene -emote_save_folder = project_dir -blender_save_folder = project_dir - -prefix = "" -filename = prefix + action.name - -name = f"{filename}" -description = "" -author = "3APA3EH" - -isLoop = action.use_cyclic -baking_error_threshold = 0.001 # how much error is fine when converting baked animation to bezier keyframes - # 0: just make every keyframe bezier; >0: curve is allowed to be off by this much - #from what bones to read the animation export_bones = [ - "body", - "body_control", + "body", "body_control", "head", - "left_arm", - "left_leg", - "right_arm", - "right_leg", - "torso", - "left_arm_bend", - "left_leg_bend", - "right_arm_bend", - "right_leg_bend", - "torso_bend", - "right_item", - "left_item", - "cape", - "cape_bend", - "waist" + "left_arm", "right_arm", + "left_leg", "right_leg", + "waist", "torso", "cape", + "left_item", "right_item", ] +for pivot_bone in action.emote.pivot_bones: + export_bones.append(pivot_bone.name) -# https://misode.github.io/text-component/ -badges = [ -# { -# "translate": "mineemotes.emote.badge.dance", -# "fallback": "Dance", -# "color": "#E73A3A" -# }, -# { -# "translate": "mineemotes.emote.badge.test", -# "fallback": "Test", -# "color": "#003A3A" -# } -# { -# "translate": "mineemotes.emote.badge.bendless", -# "fallback": "Bendless", -# "color": "#34c415" -# } -] -#end of the settings -# how many decimal places to keep in values -value_precision = 3 +collect_animation_data = bpy.data.texts['collect_animation_data.py'].as_module().collect_animation_data +create_emote = bpy.data.texts['set_up_bedrock.py'].as_module().create_emote -framerate = scene.render.fps/scene.render.fps_base +print(f"Exporting {action.name}.json!") +preview_frame = scene.frame_current +scene.frame_set(0) +animation_data, work_action = collect_animation_data(rig_object, export_bones) +emote = create_emote(rig_object, export_bones, animation_data) +bpy.data.actions.remove(work_action) -bpy.ops.wm.save_mainfile(filepath=f"{blender_save_folder}\\{filename}.blend") +emote_save_folder = action.emote.emote_save_path +print("Rendering icon...") +scene.frame_set(preview_frame) +bpy.ops.render.render() -collect_animation_data = bpy.data.texts['collect_animation_data.py'].as_module().collect_animation_data -create_emote = bpy.data.texts['set_up_bedrock.py'].as_module().create_emote -print(f"Exporting {filename}.json!") -is_vanilla = rig_object.pose.bones["settings"]["vanilla"] +image = bpy.data.images["Render Result"] -preview_frame = scene.frame_current -scene.frame_set(0) +with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + path = tmp.name -animation_data, work_action = collect_animation_data(baking_error_threshold, - isLoop, - int(action.frame_start), - int(action.frame_end), - export_bones - ) - -emote = create_emote(filename, - scene.frame_start, - int(action.frame_start), - int(action.frame_end), - isLoop, - name, author, description, badges, - export_bones, - animation_data, - value_precision - ) +try: + image.save_render(path) -bpy.data.actions.remove(work_action) + with open(path, "rb") as f: + image_base64 = base64.b64encode(f.read()).decode("ascii") +finally: + os.remove(path) + +emote["animations"][action.name]["player_animation_library"]["iconData"] = image_base64 print("Saving json...") -with open(str(emote_save_folder / (prefix + filename + ".json")), 'w', encoding="utf-8") as e: +with open(str(emote_save_folder + "/" + action.name + ".json"), 'w', encoding="utf-8") as e: json.dump(emote, e, ensure_ascii=False, indent=4) -scene.frame_set(preview_frame) -print("Rendering icon...") -scene.render.filepath = str(emote_save_folder / (prefix + filename + ".png")) -bpy.ops.render.render(write_still = 1) print("Emote has been exported successfuly!") \ No newline at end of file diff --git a/blender/export_functions/action_settings_panel.py b/blender/export_functions/action_settings_panel.py new file mode 100644 index 000000000..b0cd91e1c --- /dev/null +++ b/blender/export_functions/action_settings_panel.py @@ -0,0 +1,257 @@ +import bpy +from bpy.types import Panel, PropertyGroup +from bpy.props import ( + StringProperty, + FloatProperty, + IntProperty, + BoolProperty, + PointerProperty, + CollectionProperty, + FloatVectorProperty, +) +from bl_ui.space_dopesheet import DOPESHEET_PT_action +from pathlib import Path + +class ActionBadge(PropertyGroup): + text: StringProperty( + name="", + default="Badge", + ) + + color: FloatVectorProperty( + name="", + subtype='COLOR', + size=3, + min=0.0, + max=1.0, + default=(1.0, 1.0, 1.0), + ) +class PivotBone(PropertyGroup): + name: StringProperty( + name="", + description="Custom pivot bone name, the same as the name of the bone you created" + ) + +class ActionMetadata(PropertyGroup): + + hold_on_last_frame: BoolProperty( + name="Hold on last frame", + default=False, + description = "Keep the animation on the last frame after it was played" + ) + + name: StringProperty( + name="Name", + default="Name" + ) + + description: StringProperty( + name="Description", + default="Description" + ) + + author: StringProperty( + name="Author", + default="Author" + ) + + badges: CollectionProperty( + type=ActionBadge + ) + + pivot_bones: CollectionProperty( + type=PivotBone + ) + + baking_error_threshold: FloatProperty( + name="Baking Error Threshold", + default=0.001, + precision=3, + step=0.001, + description="How much error is fine when converting baked animation to bezier keyframes\n0: just keep it fully baked\n>0: curve is allowed to be off by this much" + ) + + value_precision: IntProperty( + name="Value Precision", + default=3, + min=0, + max=8, + description="How many digits after the dot to keep in values" + ) + emote_save_path: StringProperty( + name="", + default=str(Path(bpy.data.filepath).parent), + subtype='DIR_PATH', + description="Directory to save the emote" + ) + + +# ------------------------------------------------------------ +# UI +# ------------------------------------------------------------ + +def draw_emote_settings(self, context): + layout = self.layout + + action = context.object.animation_data.action + data = action.emote + + if action.use_cyclic: + layout.prop(data, "hold_on_last_frame") + layout.separator() + + layout.prop(data, "name") + layout.prop(data, "description") + layout.prop(data, "author") + + box = layout.box() + row = box.row() + + row.label(text="Badges") + row.operator("action.add_badge", text="", icon='ADD') + + for i, badge in enumerate(data.badges): + + badge_box = box.box() + row = badge_box.row() + row.prop(badge, "text") + row.prop(badge, "color") + op = row.operator("action.remove_badge", text="", icon='X') + op.index = i + + layout.separator() + + layout.label(text="Export") + + box = layout.box() + row = box.row() + + row.label(text="Pivot Bones") + row.operator("action.add_pivot_bone", text="", icon='ADD') + + for i, pivot_bone in enumerate(data.pivot_bones): + + pivot_bone_box = box.box() + row = pivot_bone_box.row() + row.prop(pivot_bone, "name") + op = row.operator("action.remove_pivot_bone", text="", icon='X') + op.index = i + + layout.prop(data, "baking_error_threshold") + layout.prop(data, "value_precision") + + layout.separator() + + layout.prop(data, "emote_save_path") + layout.operator("action.export_animation", icon='EXPORT') + +class ACTION_OT_add_badge(bpy.types.Operator): + bl_idname = "action.add_badge" + bl_label = "Add Badge" + + def execute(self, context): + action = context.object.animation_data.action + badge = action.emote.badges.add() + + badge.text = "" + badge.color = (1.0, 1.0, 1.0) + + return {'FINISHED'} + +class ACTION_OT_remove_badge(bpy.types.Operator): + bl_idname = "action.remove_badge" + bl_label = "Remove Badge" + + index: IntProperty() + + def execute(self, context): + action = context.object.animation_data.action + action.emote.badges.remove(self.index) + return {'FINISHED'} + +class ACTION_OT_export(bpy.types.Operator): + bl_idname = "action.export_animation" + bl_label = "Export" + bl_description = "Exports the current action as an emote" + + def execute(self, context): + text = bpy.data.texts.get("export.py") + + if text is None: + self.report({'ERROR'}, "Text 'export.py' not found") + return {'CANCELLED'} + + override = context.copy() + override["edit_text"] = text + + with context.temp_override(**override): + bpy.ops.text.run_script() + + self.report({'INFO'}, "Export finished") + + context.window_manager.popup_menu( + lambda self, context: self.layout.label(text="Export finished successfully."), + title="Export", + icon='CHECKMARK' + ) + + return {'FINISHED'} +class ACTION_OT_add_pivot_bone(bpy.types.Operator): + bl_idname = "action.add_pivot_bone" + bl_label = "Add Pivot Bone" + + def execute(self, context): + action = context.object.animation_data.action + pivot_bone = action.emote.pivot_bones.add() + + pivot_bone.bone_name = "" + + return {'FINISHED'} + +class ACTION_OT_remove_pivot_bone(bpy.types.Operator): + bl_idname = "action.remove_pivot_bone" + bl_label = "Remove Pivot Bone" + + index: IntProperty() + + def execute(self, context): + action = context.object.animation_data.action + action.emote.pivot_bones.remove(self.index) + return {'FINISHED'} +# ------------------------------------------------------------ +# Registration +# ------------------------------------------------------------ + +classes = ( + PivotBone, + ActionBadge, + ActionMetadata, + ACTION_OT_export, +# DOPESHEET_PT_emote, + ACTION_OT_add_badge, + ACTION_OT_remove_badge, + ACTION_OT_add_pivot_bone, + ACTION_OT_remove_pivot_bone, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + bpy.types.Action.emote = PointerProperty( + type=ActionMetadata + ) + DOPESHEET_PT_action.append(draw_emote_settings) + + +def unregister(): + DOPESHEET_PT_action.remove(draw_emote_settings) + del bpy.types.Action.emote + + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() \ No newline at end of file diff --git a/blender/export_functions/collect_animation_data.py b/blender/export_functions/collect_animation_data.py index d82ec9abc..9f652ba3f 100644 --- a/blender/export_functions/collect_animation_data.py +++ b/blender/export_functions/collect_animation_data.py @@ -168,7 +168,6 @@ def _insert_segment(base_fcurve, baked_fcurve, start, end): options={'FAST'} ) k.interpolation = 'BEZIER' -# k.interpolation = 'LINEAR' k.type = 'GENERATED' def blender_type(type): @@ -212,8 +211,6 @@ def merge_fcurves( if not diff and in_segment: in_segment = False segment_end = baked_keys[i - 1].co.x -# if i==len(baked_keys)-1: -# segment_end += 1 _insert_segment(base_fcurve, baked_fcurve, segment_start, segment_end) if in_segment: segment_end = baked_keys[-1].co.x @@ -226,17 +223,21 @@ def merge_fcurves( m.mute = mute -def collect_animation_data(baking_error_threshold, - isLoop, - export_frame_start, - export_frame_end, - export_bones - ): +def collect_animation_data(rig_object, export_bones): + scene = bpy.context.scene + is_vanilla = rig_object.pose.bones["settings"]["vanilla"] + print("Collecting animation data...") bpy.ops.object.mode_set(mode='OBJECT') - rig_object = bpy.data.objects["export_armature"] original_action = rig_object.animation_data.action original_slot = rig_object.animation_data.action_slot + baking_error_threshold = original_action.emote.baking_error_threshold + + export_frame_start = 0 + export_frame_end = int(scene.frame_end) + if original_action.use_frame_range: + export_frame_start = int(original_action.frame_start) + export_frame_end = int(original_action.frame_end) for stale in list(bpy.data.actions): if PAL_TMP_SUFFIX in stale.name and stale.users == 0: @@ -264,11 +265,21 @@ def collect_animation_data(baking_error_threshold, # for bone in rig_object.pose.bones: # for c in ["location", "rotation_euler", "scale"]: # bone.keyframe_insert(c, frame=0) - for bone in rig_object.pose.bones: - if bone.name in export_bones: bone.select = True + bone_collection_visibility = { + collection.name: collection.is_visible + for collection in rig_object.data.collections_all + } + + for collection in rig_object.data.collections_all: + collection.is_visible = True + + for bone in ["left_arm", "right_arm", "left_leg", "right_leg"]: + export_bones.append(bone+"_vanilla") + for bone in ["left_arm", "right_arm", "left_leg", "right_leg", "torso", "cape"]: + export_bones.append(bone+"_bend") bpy.ops.object.mode_set(mode='POSE') bpy.ops.nla.bake( - only_selected=True, + only_selected=False, frame_start=export_frame_start, frame_end= export_frame_end+1, step=1, @@ -277,6 +288,9 @@ def collect_animation_data(baking_error_threshold, bake_types={'POSE'}, channel_types={'LOCATION', 'ROTATION', 'SCALE', 'PROPS'} ) + + for collection in rig_object.data.collections_all: + collection.is_visible = bone_collection_visibility[collection.name] bpy.ops.object.mode_set(mode='OBJECT') baked_action = rig_object.animation_data.action @@ -288,10 +302,8 @@ def collect_animation_data(baking_error_threshold, baked_animation_data = {} animation_data = {} - + baked_curve_to_bezier(rig_object, baked_action.name, error_threshold=baking_error_threshold) - for bone in ["left_arm", "right_arm", "left_leg", "right_leg"]: - export_bones.append(bone+"_vanilla") for bone in export_bones: if bone not in [b.name for b in rig_object.pose.bones]: print(f'You have selected for export a bone that doesn\'t exist:"{bone}"') @@ -301,8 +313,11 @@ def collect_animation_data(baking_error_threshold, "rotation": [], "scale": [] } + target_bone = bone + if is_vanilla and f"{bone}_vanilla" in rig_object.pose.bones: + target_bone = f"{bone}_vanilla" for type in "location", "rotation_euler", "scale": - baked_animation_data[bone][blender_type(type)]= [fcurves.find(data_path = f'pose.bones["{bone}"].{type}', index = axis) for axis in [0,1,2]] + baked_animation_data[bone][blender_type(type)]= [fcurves.find(data_path = f'pose.bones["{target_bone}"].{type}', index = axis) for axis in [0,1,2]] rig_object.animation_data.action = work_action rig_object.animation_data.action_slot = work_slot @@ -318,9 +333,9 @@ def collect_animation_data(baking_error_threshold, "position": [], "rotation": [], "scale": [] - } + } for type in "location", "rotation_euler", "scale": - animation_data[bone][blender_type(type)] = [fcurves.find(data_path = f'pose.bones["{bone}"].{type}', index = axis) for axis in [0,1,2]] + animation_data[bone][blender_type(type)]= [fcurves.find(data_path = f'pose.bones["{bone}"].{type}', index = axis) for axis in [0,1,2]] for bone in export_bones: if bone not in [b.name for b in rig_object.pose.bones]: diff --git a/blender/export_functions/set_up_bedrock.py b/blender/export_functions/set_up_bedrock.py index 0433af9b1..177dde31a 100644 --- a/blender/export_functions/set_up_bedrock.py +++ b/blender/export_functions/set_up_bedrock.py @@ -1,13 +1,18 @@ import bpy, os, math, json from mathutils import * -def get_bone_axis_difference(arm: bpy.types.Object, bone_name: str, mode: str): +def rgb_to_hex(color): + r = round(color[0] * 255) + g = round(color[1] * 255) + b = round(color[2] * 255) + return f"#{r:02X}{g:02X}{b:02X}" + +def get_bone_axis_difference(rig_object, bone_name, mode): # get the difference between bone's axes and the blockbench axes - is_vanilla = bpy.data.objects["export_armature"].pose.bones["settings"]["vanilla"] - prev_mode = arm.mode + prev_mode = rig_object.mode bpy.ops.object.mode_set(mode='EDIT') - edit_bone = arm.data.edit_bones[bone_name + "_bend"*(mode == 'bend') + "_vanilla"*(is_vanilla and mode !="bend" and bone_name in ["left_arm", "right_arm", "left_leg", "right_leg"])] + edit_bone = rig_object.data.edit_bones[bone_name + "_bend"*(mode == 'bend')] bone_axes = [ edit_bone.x_axis, edit_bone.y_axis, @@ -40,12 +45,13 @@ def get_bone_axis_difference(arm: bpy.types.Object, bone_name: str, mode: str): return result -def fcurves_to_mode_dict(fcurves: list[bpy.types.Curve], is_bend: bool=False): - def build_frame_map(fcurve: bpy.types.Curve): +def fcurves_to_mode_dict(fcurves, is_bend=False): + def build_frame_map(fcurve): if fcurve == None: return {} return {round(kf.co.x, 6): kf for kf in fcurve.keyframe_points} - framerate = bpy.data.scenes["Scene"].render.fps/bpy.data.scenes["Scene"].render.fps_base + scene = bpy.context.scene + framerate = scene.render.fps/scene.render.fps_base maps = [build_frame_map(fc) for fc in fcurves] all_frames = set() @@ -74,11 +80,12 @@ def build_frame_map(fcurve: bpy.types.Curve): def get_bezier_args(keyframe, mode, multiplier): - framerate = bpy.data.scenes["Scene"].render.fps/bpy.data.scenes["Scene"].render.fps_base + scene = bpy.context.scene + framerate = scene.render.fps/scene.render.fps_base - handle_left_y = (keyframe.handle_left.y - keyframe.co.y)*multiplier + handle_left_y = (keyframe.handle_left.y - keyframe.co.y) handle_left_x = (keyframe.handle_left.x - keyframe.co.x)/framerate - handle_right_y = (keyframe.handle_right.y - keyframe.co.y)*multiplier + handle_right_y = (keyframe.handle_right.y - keyframe.co.y) handle_right_x = (keyframe.handle_right.x - keyframe.co.x)/framerate if mode == "position": @@ -171,19 +178,13 @@ def get_easingArgs_list(keyframes: list[bpy.types.Keyframe], mode: str, sign: fl return [get_easingArgs(keyframes[i], mode, round(sign[i]), value_precision) for i in range(len(keyframes))] -def write_mode(bone_name: str, mode: str, animation_data, rig_object, value_precision, default_bones, export_bones): - is_vanilla = bpy.data.objects["export_armature"].pose.bones["settings"]["vanilla"] - if mode == 'bend' and is_vanilla: return +def write_mode(bone_name: str, mode: str, animation_data, rig_object, default_bones, export_bones): + value_precision = rig_object.animation_data.action.emote.value_precision if mode == 'bend': - if f"{bone_name}_bend" not in default_bones or f"{bone_name}_bend" not in export_bones: # bone isn't bendable or isn't selected for export + if f"{bone_name}_bend" not in default_bones: # bone isn't bendable return None mode_dict = fcurves_to_mode_dict(animation_data[f"{bone_name}_bend"]["rotation"], is_bend=True) - elif is_vanilla and bone_name in ["left_arm", "right_arm", "left_leg", "right_leg"]: - if f"{bone_name}_bend" not in default_bones or f"{bone_name}_bend" not in export_bones: # bone isn't bendable or isn't selected for export - return None - - mode_dict = fcurves_to_mode_dict(animation_data[f"{bone_name}_vanilla"][mode]) else: mode_dict = fcurves_to_mode_dict(animation_data[bone_name][mode]) @@ -287,41 +288,56 @@ def write_mode(bone_name: str, mode: str, animation_data, rig_object, value_prec return mode_dict -def create_emote(filename, - loop_return_frame, - export_frame_start, - export_frame_end, - isLoop, - name, author, description, badges, - export_bones, - animation_data, - value_precision - ): - rig_object = bpy.data.objects["export_armature"] - framerate = bpy.data.scenes["Scene"].render.fps/bpy.data.scenes["Scene"].render.fps_base +def create_emote(rig_object, export_bones, animation_data): + + action = rig_object.animation_data.action + scene = bpy.context.scene + + framerate = scene.render.fps/scene.render.fps_base + value_precision = action.emote.value_precision + + loop_return_frame = scene.frame_start + + export_frame_start = 0 + export_frame_end = int(scene.frame_end) + if action.use_frame_range: + export_frame_start = int(action.frame_start) + export_frame_end = int(action.frame_end) + + badges = [] + for badge in action.emote.badges: + badges.append({ + "text": badge.text, + "color": rgb_to_hex(badge.color) + }) + + loop = action.use_cyclic + if action.use_cyclic and action.emote.hold_on_last_frame: + loop = "hold_on_last_frame" emote = { "format_version": "1.8.0", "geckolib_format_version": 2, - "model": {}, - "parents": {}, "animations": { - filename: { - "loopTick": round((loop_return_frame-export_frame_start)/framerate, 3), - "loop": isLoop, - "animation_length": round((export_frame_end-export_frame_start)/framerate, 3), - "player_animation_library": { - "name": name, - "author": author, - "description": description, - "bages": badges - # "applyBendToOtherBones": True - }, - "bones":{} + action.name: { + "model": {}, + "parents": {}, + "animation_length": round((export_frame_end-export_frame_start)/framerate, value_precision), + "loop": loop } } } + if loop is True: + emote["animations"][action.name]["loopTick"] = round((loop_return_frame-export_frame_start)/framerate, 3) + emote["animations"][action.name]["player_animation_library"] = { + "name": action.emote.name, + "author": action.emote.author, + "description": action.emote.description, + "bages": badges + # "applyBendToOtherBones": True + } + emote["animations"][action.name]["bones"] = {} print("Figuring out the custom bones...") default_bones = ["body","head","left_arm","left_leg","right_arm", @@ -337,12 +353,12 @@ def create_emote(filename, pivot = bone.head #in blockbench compared to blender x is negated and y is swapped with z pivot = [-pivot[0]*4, pivot[2]*4, pivot[1]*4] - emote["model"][bone.name] = {"pivot": pivot} + emote["animations"][action.name]["model"][bone.name] = {"pivot": pivot} for child in bone.children: if child.name in export_bones: if "_vanilla" in child.name: continue - emote["parents"][child.name] = bone.name + emote["animations"][action.name]["parents"][child.name] = bone.name print("Fixing animation data for bedrock...") @@ -352,12 +368,14 @@ def create_emote(filename, if bone_name not in [b.name for b in rig_object.pose.bones]: print(bone_name, "doesn't exist!") continue - - emote["animations"][filename]["bones"][bone_name] = {} + bone_anim = {} for mode in ["position", "rotation", "bend", "scale"]: - k = write_mode(bone_name, mode, animation_data, rig_object,value_precision,default_bones, export_bones) + if rig_object.pose.bones["settings"]["vanilla"] and mode == "bend": continue + + k = write_mode(bone_name, mode, animation_data, rig_object, default_bones, export_bones) if k is None: continue - emote["animations"][filename]["bones"][bone_name][mode] = k + bone_anim[mode] = k + if bone_anim != {}: emote["animations"][action.name]["bones"][bone_name] = bone_anim bpy.ops.object.mode_set(mode='POSE') return emote \ No newline at end of file