From 76477705b171659f07f5f18b8046df030af74d34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:19:05 +0000 Subject: [PATCH 01/13] Initial plan From 374de8a6a2b17fd6194195f6a23a26cdb7422b00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:22:51 +0000 Subject: [PATCH 02/13] Add Super Mario Bros. physics addon with accurate NES physics Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- README.md | 8 + __pycache__/Rotating ASCII.cpython-312.pyc | Bin 0 -> 15220 bytes __pycache__/pixel2cube.cpython-312.pyc | Bin 0 -> 8964 bytes __pycache__/smb_physics.cpython-312.pyc | Bin 0 -> 22452 bytes smb_physics.py | 640 +++++++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 __pycache__/Rotating ASCII.cpython-312.pyc create mode 100644 __pycache__/pixel2cube.cpython-312.pyc create mode 100644 __pycache__/smb_physics.cpython-312.pyc create mode 100644 smb_physics.py diff --git a/README.md b/README.md index 42da6d3..dfa2ae2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ Rotating ASCII - Export Object as Rotating ASCII python script pixel2cube - Imports images as colored cubes, best used with sprites. Make sure when you seperate the sprite models to merge by distance, it creates doubles for some reason. + +smb_physics - Super Mario Bros. Physics addon that recreates accurate NES Super Mario Bros. physics for a selected object (PLAYER). Features include: + - Accurate gravity, jump physics with variable height (hold to jump higher) + - Walking and running speeds with momentum + - Skid deceleration when changing direction + - Air control while jumping + - Customizable physics parameters with reset to SMB defaults + - Controls: Arrow keys (←→) to move, Space to jump, Shift to run, Esc to stop diff --git a/__pycache__/Rotating ASCII.cpython-312.pyc b/__pycache__/Rotating ASCII.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73a7dd8e0102cdd8fbc729310363730df47a7e68 GIT binary patch literal 15220 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 UJ!9iXjN^?L){PX?L}ugv01K6iH~;_u literal 0 HcmV?d00001 diff --git a/__pycache__/pixel2cube.cpython-312.pyc b/__pycache__/pixel2cube.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7803e849337686b940bad774add231c1a522d4cc GIT binary patch literal 8964 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 literal 0 HcmV?d00001 diff --git a/__pycache__/smb_physics.cpython-312.pyc b/__pycache__/smb_physics.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a329736588b808bf3fa3e77c33ed2bfe9c39e90b GIT binary patch literal 22452 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 literal 0 HcmV?d00001 diff --git a/smb_physics.py b/smb_physics.py new file mode 100644 index 0000000..abfa7c1 --- /dev/null +++ b/smb_physics.py @@ -0,0 +1,640 @@ +# Super Mario Bros. Physics Addon for Blender +# Recreates accurate Super Mario Bros. physics for selected object +# The selected object will be considered as the PLAYER + +import bpy +import math +from bpy.app.handlers import persistent + +bl_info = { + "name": "Super Mario Bros. Physics", + "author": "Pink", + "version": (1, 0), + "blender": (2, 80, 0), + "location": "View3D > Sidebar > SMB Physics", + "description": "Recreates accurate Super Mario Bros. physics for the selected object (PLAYER)", + "category": "Physics", +} + +# ============================================================================== +# Super Mario Bros. Physics Constants +# All values are derived from the original NES Super Mario Bros. +# Original game runs at 60fps with specific subpixel physics +# +# Reference: https://www.smwcentral.net/?p=viewthread&t=98861 +# And various SMB physics documentation sources +# ============================================================================== + +# Scale factor to convert NES pixel units to Blender units +# NES screen was 256x240 pixels, we scale to make physics feel right in Blender +PIXEL_TO_BLENDER = 0.0625 # 1 NES pixel = 0.0625 Blender units (1/16) + +# Gravity - SMB uses 0x0007 per subframe (1/256 pixel per frame²) +# Actual gravity: 7/256 = 0.02734375 pixels/frame² +# At 60fps this becomes our gravity value +SMB_GRAVITY = 0.02734375 * PIXEL_TO_BLENDER * 60 * 60 # Convert to units/sec² + +# Terminal velocity (max falling speed) +# SMB caps vertical velocity at 0x0480 (4.5 pixels/frame) +SMB_TERMINAL_VELOCITY = 4.5 * PIXEL_TO_BLENDER * 60 + +# Jump initial velocities based on horizontal speed (in pixels/frame) +# These are the actual NES values for Small Mario +# Jump velocity at different run speeds: 4.0, 4.0, 4.0, 5.0 pixels/frame +SMB_JUMP_VELOCITY_WALK = 4.0 * PIXEL_TO_BLENDER * 60 +SMB_JUMP_VELOCITY_RUN = 5.0 * PIXEL_TO_BLENDER * 60 + +# Horizontal movement speeds +# Walking max speed: 0x0130 (1.1875 pixels/frame) +# Running max speed: 0x0290 (2.5625 pixels/frame) +SMB_MAX_WALK_SPEED = 1.1875 * PIXEL_TO_BLENDER * 60 +SMB_MAX_RUN_SPEED = 2.5625 * PIXEL_TO_BLENDER * 60 + +# Acceleration values +# Walking acceleration: 0x0098 (0.59375 pixels/frame²) +# Running acceleration: 0x00E4 (0.890625 pixels/frame²) +SMB_WALK_ACCEL = 0.09375 * PIXEL_TO_BLENDER * 60 * 60 +SMB_RUN_ACCEL = 0.140625 * PIXEL_TO_BLENDER * 60 * 60 + +# Deceleration/friction +# Release friction (skidding): 0x00D0 (0.8125 pixels/frame²) +# Skid deceleration: 0x01A0 (1.625 pixels/frame²) +SMB_FRICTION = 0.8125 * PIXEL_TO_BLENDER * 60 * 60 +SMB_SKID_DECEL = 1.625 * PIXEL_TO_BLENDER * 60 * 60 + +# Air control (reduced control while airborne) +# In SMB, air acceleration is the same but momentum is preserved +SMB_AIR_ACCEL_MULTIPLIER = 1.0 # SMB has full air control for acceleration + +# Jump sustain - holding jump reduces gravity effect +# When holding jump: gravity is halved until peak or button release +SMB_JUMP_GRAVITY_MULTIPLIER = 0.5 + + +class SMBPhysicsProperties(bpy.types.PropertyGroup): + """Properties for SMB Physics simulation""" + + # Simulation state + is_active: bpy.props.BoolProperty( + name="Physics Active", + description="Toggle SMB physics simulation", + default=False + ) + + # Current velocity (stored for persistence) + velocity_x: bpy.props.FloatProperty(name="Velocity X", default=0.0) + velocity_y: bpy.props.FloatProperty(name="Velocity Y", default=0.0) + velocity_z: bpy.props.FloatProperty(name="Velocity Z", default=0.0) + + # Physics state + is_grounded: bpy.props.BoolProperty(name="Is Grounded", default=True) + is_jumping: bpy.props.BoolProperty(name="Is Jumping", default=False) + jump_held: bpy.props.BoolProperty(name="Jump Held", default=False) + is_running: bpy.props.BoolProperty(name="Is Running", default=False) + + # Input state (controlled via UI or keymap) + input_left: bpy.props.BoolProperty(name="Move Left", default=False) + input_right: bpy.props.BoolProperty(name="Move Right", default=False) + input_jump: bpy.props.BoolProperty(name="Jump", default=False) + input_run: bpy.props.BoolProperty(name="Run", default=False) + + # Ground level (Y position of the ground plane) + ground_level: bpy.props.FloatProperty( + name="Ground Level", + description="Z position of the ground plane", + default=0.0, + unit='LENGTH' + ) + + # Customizable physics values (defaulting to accurate SMB values) + gravity: bpy.props.FloatProperty( + name="Gravity", + description="Gravity acceleration (units/sec²)", + default=SMB_GRAVITY, + min=0.0 + ) + + terminal_velocity: bpy.props.FloatProperty( + name="Terminal Velocity", + description="Maximum falling speed (units/sec)", + default=SMB_TERMINAL_VELOCITY, + min=0.0 + ) + + jump_velocity: bpy.props.FloatProperty( + name="Jump Velocity (Walk)", + description="Initial jump velocity when walking (units/sec)", + default=SMB_JUMP_VELOCITY_WALK + ) + + jump_velocity_run: bpy.props.FloatProperty( + name="Jump Velocity (Run)", + description="Initial jump velocity when running (units/sec)", + default=SMB_JUMP_VELOCITY_RUN + ) + + max_walk_speed: bpy.props.FloatProperty( + name="Max Walk Speed", + description="Maximum walking speed (units/sec)", + default=SMB_MAX_WALK_SPEED, + min=0.0 + ) + + max_run_speed: bpy.props.FloatProperty( + name="Max Run Speed", + description="Maximum running speed (units/sec)", + default=SMB_MAX_RUN_SPEED, + min=0.0 + ) + + walk_acceleration: bpy.props.FloatProperty( + name="Walk Acceleration", + description="Acceleration when walking (units/sec²)", + default=SMB_WALK_ACCEL, + min=0.0 + ) + + run_acceleration: bpy.props.FloatProperty( + name="Run Acceleration", + description="Acceleration when running (units/sec²)", + default=SMB_RUN_ACCEL, + min=0.0 + ) + + friction: bpy.props.FloatProperty( + name="Friction", + description="Deceleration when not moving (units/sec²)", + default=SMB_FRICTION, + min=0.0 + ) + + skid_deceleration: bpy.props.FloatProperty( + name="Skid Deceleration", + description="Deceleration when changing direction (units/sec²)", + default=SMB_SKID_DECEL, + min=0.0 + ) + + # Movement axis (which axis is forward/up) + forward_axis: bpy.props.EnumProperty( + name="Forward Axis", + description="Which axis represents forward movement", + items=[ + ('X', "X Axis", "Move along X axis"), + ('Y', "Y Axis", "Move along Y axis"), + ], + default='X' + ) + + up_axis: bpy.props.EnumProperty( + name="Up Axis", + description="Which axis represents up (jump direction)", + items=[ + ('Y', "Y Axis", "Jump along Y axis"), + ('Z', "Z Axis", "Jump along Z axis"), + ], + default='Z' + ) + + +class SMBPhysicsEngine: + """Core physics engine implementing SMB physics""" + + @staticmethod + def update_physics(context, delta_time): + """Update physics for one frame""" + obj = context.active_object + if not obj: + return + + props = context.scene.smb_physics_props + if not props.is_active: + return + + # Get current position + pos_x = obj.location.x + pos_y = obj.location.y + pos_z = obj.location.z + + # Determine which axes to use + if props.forward_axis == 'X': + horizontal_pos = pos_x + horizontal_vel = props.velocity_x + else: + horizontal_pos = pos_y + horizontal_vel = props.velocity_y + + if props.up_axis == 'Z': + vertical_pos = pos_z + vertical_vel = props.velocity_z + else: + vertical_pos = pos_y + vertical_vel = props.velocity_y + + # Check if grounded + props.is_grounded = vertical_pos <= props.ground_level + + # Horizontal movement + horizontal_vel = SMBPhysicsEngine.update_horizontal( + props, horizontal_vel, delta_time + ) + + # Vertical movement (jumping/gravity) + vertical_vel = SMBPhysicsEngine.update_vertical( + props, vertical_vel, delta_time + ) + + # Apply velocities to position + if props.forward_axis == 'X': + pos_x += horizontal_vel * delta_time + props.velocity_x = horizontal_vel + else: + pos_y += horizontal_vel * delta_time + props.velocity_y = horizontal_vel + + if props.up_axis == 'Z': + pos_z += vertical_vel * delta_time + props.velocity_z = vertical_vel + else: + pos_y += vertical_vel * delta_time + props.velocity_y = vertical_vel + + # Ground collision + if props.up_axis == 'Z': + if pos_z < props.ground_level: + pos_z = props.ground_level + props.velocity_z = 0 + props.is_grounded = True + props.is_jumping = False + else: + if pos_y < props.ground_level: + pos_y = props.ground_level + props.velocity_y = 0 + props.is_grounded = True + props.is_jumping = False + + # Update object position + obj.location.x = pos_x + obj.location.y = pos_y + obj.location.z = pos_z + + @staticmethod + def update_horizontal(props, velocity, delta_time): + """Update horizontal velocity based on input""" + # Determine target direction + direction = 0 + if props.input_left: + direction -= 1 + if props.input_right: + direction += 1 + + # Determine max speed and acceleration based on run state + if props.input_run or props.is_running: + max_speed = props.max_run_speed + acceleration = props.run_acceleration + props.is_running = props.input_run + else: + max_speed = props.max_walk_speed + acceleration = props.walk_acceleration + props.is_running = False + + # Apply air control multiplier if airborne + if not props.is_grounded: + acceleration *= SMB_AIR_ACCEL_MULTIPLIER + + if direction != 0: + # Check if skidding (moving opposite to velocity) + is_skidding = (velocity > 0 and direction < 0) or (velocity < 0 and direction > 0) + + if is_skidding: + # Apply skid deceleration + decel = props.skid_deceleration * delta_time + if velocity > 0: + velocity = max(0, velocity - decel) + else: + velocity = min(0, velocity + decel) + else: + # Apply acceleration + velocity += direction * acceleration * delta_time + + # Clamp to max speed + velocity = max(-max_speed, min(max_speed, velocity)) + else: + # No input - apply friction + if props.is_grounded: + friction = props.friction * delta_time + if velocity > 0: + velocity = max(0, velocity - friction) + elif velocity < 0: + velocity = min(0, velocity + friction) + + return velocity + + @staticmethod + def update_vertical(props, velocity, delta_time): + """Update vertical velocity (jumping and gravity)""" + # Handle jump initiation + if props.input_jump and props.is_grounded and not props.is_jumping: + props.is_jumping = True + props.jump_held = True + + # Jump velocity depends on horizontal speed + horizontal_speed = abs(props.velocity_x) if props.forward_axis == 'X' else abs(props.velocity_y) + + if horizontal_speed > props.max_walk_speed * 0.8: + velocity = props.jump_velocity_run + else: + velocity = props.jump_velocity + + # Track if jump button is still held + if not props.input_jump: + props.jump_held = False + + # Apply gravity + if not props.is_grounded: + # Holding jump reduces gravity (allows variable jump height) + gravity = props.gravity + if props.jump_held and velocity > 0: + gravity *= SMB_JUMP_GRAVITY_MULTIPLIER + + velocity -= gravity * delta_time + + # Cap at terminal velocity + velocity = max(-props.terminal_velocity, velocity) + + return velocity + + +class SMB_OT_start_physics(bpy.types.Operator): + """Start SMB Physics Simulation""" + bl_idname = "smb.start_physics" + bl_label = "Start Physics" + bl_description = "Start Super Mario Bros. physics simulation for the selected object" + bl_options = {'REGISTER', 'UNDO'} + + _timer = None + _last_time = None + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def modal(self, context, event): + props = context.scene.smb_physics_props + + if not props.is_active: + self.cancel(context) + return {'CANCELLED'} + + if event.type == 'TIMER': + import time + current_time = time.time() + if self._last_time is not None: + delta_time = min(current_time - self._last_time, 0.1) # Cap delta to prevent physics explosion + SMBPhysicsEngine.update_physics(context, delta_time) + self._last_time = current_time + + # Force viewport update + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + # Handle keyboard input for movement + if event.type == 'LEFT_ARROW': + props.input_left = (event.value == 'PRESS') + elif event.type == 'RIGHT_ARROW': + props.input_right = (event.value == 'PRESS') + elif event.type == 'SPACE': + props.input_jump = (event.value == 'PRESS') + elif event.type == 'LEFT_SHIFT': + props.input_run = (event.value == 'PRESS') + elif event.type in {'ESC'}: + props.is_active = False + self.cancel(context) + return {'CANCELLED'} + + return {'PASS_THROUGH'} + + def execute(self, context): + props = context.scene.smb_physics_props + + if props.is_active: + props.is_active = False + return {'CANCELLED'} + + # Reset velocities + props.velocity_x = 0 + props.velocity_y = 0 + props.velocity_z = 0 + props.is_jumping = False + props.is_grounded = True + props.input_left = False + props.input_right = False + props.input_jump = False + props.input_run = False + + # Set ground level to current object position + obj = context.active_object + if props.up_axis == 'Z': + props.ground_level = obj.location.z + else: + props.ground_level = obj.location.y + + props.is_active = True + + wm = context.window_manager + self._timer = wm.event_timer_add(1.0 / 60.0, window=context.window) # 60 FPS + self._last_time = None + wm.modal_handler_add(self) + + return {'RUNNING_MODAL'} + + def cancel(self, context): + props = context.scene.smb_physics_props + props.is_active = False + props.input_left = False + props.input_right = False + props.input_jump = False + props.input_run = False + + wm = context.window_manager + if self._timer: + wm.event_timer_remove(self._timer) + + +class SMB_OT_stop_physics(bpy.types.Operator): + """Stop SMB Physics Simulation""" + bl_idname = "smb.stop_physics" + bl_label = "Stop Physics" + bl_description = "Stop Super Mario Bros. physics simulation" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.smb_physics_props + props.is_active = False + return {'FINISHED'} + + +class SMB_OT_reset_to_defaults(bpy.types.Operator): + """Reset physics values to accurate SMB defaults""" + bl_idname = "smb.reset_defaults" + bl_label = "Reset to SMB Defaults" + bl_description = "Reset all physics values to accurate Super Mario Bros. values" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.smb_physics_props + + props.gravity = SMB_GRAVITY + props.terminal_velocity = SMB_TERMINAL_VELOCITY + props.jump_velocity = SMB_JUMP_VELOCITY_WALK + props.jump_velocity_run = SMB_JUMP_VELOCITY_RUN + props.max_walk_speed = SMB_MAX_WALK_SPEED + props.max_run_speed = SMB_MAX_RUN_SPEED + props.walk_acceleration = SMB_WALK_ACCEL + props.run_acceleration = SMB_RUN_ACCEL + props.friction = SMB_FRICTION + props.skid_deceleration = SMB_SKID_DECEL + + self.report({'INFO'}, "Physics values reset to SMB defaults") + return {'FINISHED'} + + +class SMB_PT_physics_panel(bpy.types.Panel): + """Panel for SMB Physics controls""" + bl_label = "Super Mario Bros. Physics" + bl_idname = "SMB_PT_physics_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "SMB Physics" + + def draw(self, context): + layout = self.layout + props = context.scene.smb_physics_props + + # Player info + obj = context.active_object + if obj: + box = layout.box() + box.label(text=f"PLAYER: {obj.name}", icon='OUTLINER_OB_MESH') + else: + box = layout.box() + box.label(text="No object selected!", icon='ERROR') + + # Main controls + layout.separator() + + if props.is_active: + layout.operator("smb.stop_physics", text="Stop Physics", icon='PAUSE') + + # Show current state + box = layout.box() + box.label(text="Status:", icon='INFO') + col = box.column(align=True) + col.label(text=f"Grounded: {'Yes' if props.is_grounded else 'No'}") + col.label(text=f"Jumping: {'Yes' if props.is_jumping else 'No'}") + col.label(text=f"Running: {'Yes' if props.is_running else 'No'}") + + # Show velocity + box = layout.box() + box.label(text="Velocity:", icon='FORCE_WIND') + col = box.column(align=True) + col.label(text=f"X: {props.velocity_x:.2f}") + col.label(text=f"Y: {props.velocity_y:.2f}") + col.label(text=f"Z: {props.velocity_z:.2f}") + + # Controls help + box = layout.box() + box.label(text="Controls:", icon='KEYTYPE_KEYFRAME_VEC') + col = box.column(align=True) + col.label(text="← → : Move Left/Right") + col.label(text="Space : Jump") + col.label(text="Shift : Run") + col.label(text="Esc : Stop") + else: + layout.operator("smb.start_physics", text="Start Physics", icon='PLAY') + + +class SMB_PT_physics_settings(bpy.types.Panel): + """Panel for SMB Physics settings""" + bl_label = "Physics Settings" + bl_idname = "SMB_PT_physics_settings" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "SMB Physics" + bl_parent_id = "SMB_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.smb_physics_props + + # Axis settings + box = layout.box() + box.label(text="Axes:", icon='EMPTY_AXIS') + col = box.column(align=True) + col.prop(props, "forward_axis") + col.prop(props, "up_axis") + + layout.separator() + + # Movement settings + box = layout.box() + box.label(text="Movement:", icon='CON_LOCLIKE') + col = box.column(align=True) + col.prop(props, "max_walk_speed") + col.prop(props, "max_run_speed") + col.prop(props, "walk_acceleration") + col.prop(props, "run_acceleration") + col.prop(props, "friction") + col.prop(props, "skid_deceleration") + + layout.separator() + + # Jump settings + box = layout.box() + box.label(text="Jumping:", icon='SORT_ASC') + col = box.column(align=True) + col.prop(props, "jump_velocity") + col.prop(props, "jump_velocity_run") + col.prop(props, "gravity") + col.prop(props, "terminal_velocity") + + layout.separator() + + # Ground level + box = layout.box() + box.label(text="Environment:", icon='WORLD') + box.prop(props, "ground_level") + + layout.separator() + + # Reset button + layout.operator("smb.reset_defaults", icon='FILE_REFRESH') + + +# Registration +classes = [ + SMBPhysicsProperties, + SMB_OT_start_physics, + SMB_OT_stop_physics, + SMB_OT_reset_to_defaults, + SMB_PT_physics_panel, + SMB_PT_physics_settings, +] + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.smb_physics_props = bpy.props.PointerProperty(type=SMBPhysicsProperties) + + +def unregister(): + del bpy.types.Scene.smb_physics_props + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() From 849ca71a76e5ec4b42a84f297af9d4ff9fd19646 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:23:16 +0000 Subject: [PATCH 03/13] Add .gitignore to exclude Python cache files Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- .gitignore | 3 +++ __pycache__/Rotating ASCII.cpython-312.pyc | Bin 15220 -> 0 bytes __pycache__/pixel2cube.cpython-312.pyc | Bin 8964 -> 0 bytes __pycache__/smb_physics.cpython-312.pyc | Bin 22452 -> 0 bytes 4 files changed, 3 insertions(+) create mode 100644 .gitignore delete mode 100644 __pycache__/Rotating ASCII.cpython-312.pyc delete mode 100644 __pycache__/pixel2cube.cpython-312.pyc delete mode 100644 __pycache__/smb_physics.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbe7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/__pycache__/Rotating ASCII.cpython-312.pyc b/__pycache__/Rotating ASCII.cpython-312.pyc deleted file mode 100644 index 73a7dd8e0102cdd8fbc729310363730df47a7e68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15220 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 UJ!9iXjN^?L){PX?L}ugv01K6iH~;_u diff --git a/__pycache__/pixel2cube.cpython-312.pyc b/__pycache__/pixel2cube.cpython-312.pyc deleted file mode 100644 index 7803e849337686b940bad774add231c1a522d4cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8964 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 diff --git a/__pycache__/smb_physics.cpython-312.pyc b/__pycache__/smb_physics.cpython-312.pyc deleted file mode 100644 index a329736588b808bf3fa3e77c33ed2bfe9c39e90b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22452 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 From e68f2a10edcc5846beaf4f3ba32bdfe7de67259b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:25:55 +0000 Subject: [PATCH 04/13] Address code review feedback: fix input handling, axis conflict, and imports Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/smb_physics.py b/smb_physics.py index abfa7c1..ba0e4d9 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -3,7 +3,7 @@ # The selected object will be considered as the PLAYER import bpy -import math +import time from bpy.app.handlers import persistent bl_info = { @@ -51,8 +51,9 @@ SMB_MAX_RUN_SPEED = 2.5625 * PIXEL_TO_BLENDER * 60 # Acceleration values -# Walking acceleration: 0x0098 (0.59375 pixels/frame²) -# Running acceleration: 0x00E4 (0.890625 pixels/frame²) +# Walking acceleration: 0x0018 (0.09375 pixels/frame²) +# Running acceleration: 0x0024 (0.140625 pixels/frame²) +# Note: These are per-frame acceleration values from the NES SMB_WALK_ACCEL = 0.09375 * PIXEL_TO_BLENDER * 60 * 60 SMB_RUN_ACCEL = 0.140625 * PIXEL_TO_BLENDER * 60 * 60 @@ -217,6 +218,11 @@ def update_physics(context, delta_time): pos_z = obj.location.z # Determine which axes to use + # Prevent axis conflict: if forward_axis is Y, force up_axis to Z + effective_up_axis = props.up_axis + if props.forward_axis == 'Y' and props.up_axis == 'Y': + effective_up_axis = 'Z' + if props.forward_axis == 'X': horizontal_pos = pos_x horizontal_vel = props.velocity_x @@ -224,7 +230,7 @@ def update_physics(context, delta_time): horizontal_pos = pos_y horizontal_vel = props.velocity_y - if props.up_axis == 'Z': + if effective_up_axis == 'Z': vertical_pos = pos_z vertical_vel = props.velocity_z else: @@ -252,7 +258,7 @@ def update_physics(context, delta_time): pos_y += horizontal_vel * delta_time props.velocity_y = horizontal_vel - if props.up_axis == 'Z': + if effective_up_axis == 'Z': pos_z += vertical_vel * delta_time props.velocity_z = vertical_vel else: @@ -260,7 +266,7 @@ def update_physics(context, delta_time): props.velocity_y = vertical_vel # Ground collision - if props.up_axis == 'Z': + if effective_up_axis == 'Z': if pos_z < props.ground_level: pos_z = props.ground_level props.velocity_z = 0 @@ -387,7 +393,6 @@ def modal(self, context, event): return {'CANCELLED'} if event.type == 'TIMER': - import time current_time = time.time() if self._last_time is not None: delta_time = min(current_time - self._last_time, 0.1) # Cap delta to prevent physics explosion @@ -399,15 +404,27 @@ def modal(self, context, event): if area.type == 'VIEW_3D': area.tag_redraw() - # Handle keyboard input for movement + # Handle keyboard input for movement (PRESS sets True, RELEASE sets False) if event.type == 'LEFT_ARROW': - props.input_left = (event.value == 'PRESS') + if event.value == 'PRESS': + props.input_left = True + elif event.value == 'RELEASE': + props.input_left = False elif event.type == 'RIGHT_ARROW': - props.input_right = (event.value == 'PRESS') + if event.value == 'PRESS': + props.input_right = True + elif event.value == 'RELEASE': + props.input_right = False elif event.type == 'SPACE': - props.input_jump = (event.value == 'PRESS') + if event.value == 'PRESS': + props.input_jump = True + elif event.value == 'RELEASE': + props.input_jump = False elif event.type == 'LEFT_SHIFT': - props.input_run = (event.value == 'PRESS') + if event.value == 'PRESS': + props.input_run = True + elif event.value == 'RELEASE': + props.input_run = False elif event.type in {'ESC'}: props.is_active = False self.cancel(context) From 155369bccc6f213c688d5c8ca85d2b4992a0427d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:40:39 +0000 Subject: [PATCH 05/13] Add AABB collision detection for platforms, walls, and blocks Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- README.md | 2 + smb_physics.py | 310 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 295 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index dfa2ae2..5b0d577 100644 --- a/README.md +++ b/README.md @@ -7,5 +7,7 @@ smb_physics - Super Mario Bros. Physics addon that recreates accurate NES Super - Walking and running speeds with momentum - Skid deceleration when changing direction - Air control while jumping + - AABB collision detection with tagged objects (platforms, walls, blocks) + - Tag objects for collision using the Collision panel or by adding 'smb_collision' to object name - Customizable physics parameters with reset to SMB defaults - Controls: Arrow keys (←→) to move, Space to jump, Shift to run, Esc to stop diff --git a/smb_physics.py b/smb_physics.py index ba0e4d9..71b190c 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -4,6 +4,7 @@ import bpy import time +from mathutils import Vector from bpy.app.handlers import persistent bl_info = { @@ -107,6 +108,20 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): unit='LENGTH' ) + # Collision settings + enable_collision: bpy.props.BoolProperty( + name="Enable Collision", + description="Enable collision detection with objects tagged as 'smb_collision'", + default=True + ) + + collision_padding: bpy.props.FloatProperty( + name="Collision Padding", + description="Extra padding around collision boxes", + default=0.01, + min=0.0 + ) + # Customizable physics values (defaulting to accurate SMB values) gravity: bpy.props.FloatProperty( name="Gravity", @@ -199,7 +214,135 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): class SMBPhysicsEngine: - """Core physics engine implementing SMB physics""" + """Core physics engine implementing SMB physics with collision detection""" + + @staticmethod + def get_object_bounds(obj): + """Get AABB (Axis-Aligned Bounding Box) for an object in world space""" + # Get the bounding box corners in local space + bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + + # Find min/max for each axis + min_x = min(corner.x for corner in bbox_corners) + max_x = max(corner.x for corner in bbox_corners) + min_y = min(corner.y for corner in bbox_corners) + max_y = max(corner.y for corner in bbox_corners) + min_z = min(corner.z for corner in bbox_corners) + max_z = max(corner.z for corner in bbox_corners) + + return (min_x, max_x, min_y, max_y, min_z, max_z) + + @staticmethod + def check_aabb_collision(bounds1, bounds2): + """Check if two AABBs are colliding""" + min_x1, max_x1, min_y1, max_y1, min_z1, max_z1 = bounds1 + min_x2, max_x2, min_y2, max_y2, min_z2, max_z2 = bounds2 + + return (min_x1 <= max_x2 and max_x1 >= min_x2 and + min_y1 <= max_y2 and max_y1 >= min_y2 and + min_z1 <= max_z2 and max_z1 >= min_z2) + + @staticmethod + def get_collision_objects(context, player_obj): + """Get all objects tagged for collision (name contains 'smb_collision' or has custom property)""" + collision_objects = [] + for obj in context.scene.objects: + if obj == player_obj: + continue + # Check if object is tagged for collision + if 'smb_collision' in obj.name.lower() or obj.get('smb_collision', False): + collision_objects.append(obj) + return collision_objects + + @staticmethod + def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up_axis): + """ + Resolve collision between player and obstacle. + Returns: (new_pos_offset, new_velocity, is_grounded_on_top) + + SMB-style collision resolution: + - Landing on top of objects (like platforms/blocks) + - Hitting head on bottom of objects + - Horizontal wall collision + """ + p_min_x, p_max_x, p_min_y, p_max_y, p_min_z, p_max_z = player_bounds + o_min_x, o_max_x, o_min_y, o_max_y, o_min_z, o_max_z = obstacle_bounds + + vel_x, vel_y, vel_z = velocity + offset_x, offset_y, offset_z = 0.0, 0.0, 0.0 + is_grounded = False + + # Calculate overlap on each axis + overlap_x = min(p_max_x - o_min_x, o_max_x - p_min_x) + overlap_y = min(p_max_y - o_min_y, o_max_y - p_min_y) + overlap_z = min(p_max_z - o_min_z, o_max_z - p_min_z) + + # Determine which axis has smallest overlap (that's where we resolve) + # Also consider velocity direction for better resolution + + # For Z axis (vertical in default config) + if up_axis == 'Z': + # Check if player is mostly above or below obstacle + player_center_z = (p_min_z + p_max_z) / 2 + obstacle_center_z = (o_min_z + o_max_z) / 2 + + if overlap_z <= overlap_x and overlap_z <= overlap_y: + # Resolve vertically + if player_center_z > obstacle_center_z: + # Player is above - land on top + offset_z = o_max_z - p_min_z + if vel_z < 0: + vel_z = 0 + is_grounded = True + else: + # Player is below - hit head + offset_z = o_min_z - p_max_z + if vel_z > 0: + vel_z = 0 + else: + # Resolve horizontally + if forward_axis == 'X': + player_center_x = (p_min_x + p_max_x) / 2 + obstacle_center_x = (o_min_x + o_max_x) / 2 + if player_center_x > obstacle_center_x: + offset_x = o_max_x - p_min_x + else: + offset_x = o_min_x - p_max_x + vel_x = 0 + else: + player_center_y = (p_min_y + p_max_y) / 2 + obstacle_center_y = (o_min_y + o_max_y) / 2 + if player_center_y > obstacle_center_y: + offset_y = o_max_y - p_min_y + else: + offset_y = o_min_y - p_max_y + vel_y = 0 + else: + # Y is up axis + player_center_y = (p_min_y + p_max_y) / 2 + obstacle_center_y = (o_min_y + o_max_y) / 2 + + if overlap_y <= overlap_x and overlap_y <= overlap_z: + if player_center_y > obstacle_center_y: + offset_y = o_max_y - p_min_y + if vel_y < 0: + vel_y = 0 + is_grounded = True + else: + offset_y = o_min_y - p_max_y + if vel_y > 0: + vel_y = 0 + else: + # Horizontal resolution + player_center_x = (p_min_x + p_max_x) / 2 + obstacle_center_x = (o_min_x + o_max_x) / 2 + if player_center_x > obstacle_center_x: + offset_x = o_max_x - p_min_x + else: + offset_x = o_min_x - p_max_x + vel_x = 0 + + return (offset_x, offset_y, offset_z), (vel_x, vel_y, vel_z), is_grounded @staticmethod def update_physics(context, delta_time): @@ -224,21 +367,20 @@ def update_physics(context, delta_time): effective_up_axis = 'Z' if props.forward_axis == 'X': - horizontal_pos = pos_x horizontal_vel = props.velocity_x else: - horizontal_pos = pos_y horizontal_vel = props.velocity_y if effective_up_axis == 'Z': - vertical_pos = pos_z vertical_vel = props.velocity_z else: - vertical_pos = pos_y vertical_vel = props.velocity_y - # Check if grounded - props.is_grounded = vertical_pos <= props.ground_level + # Check if grounded (will be updated by collision) + if effective_up_axis == 'Z': + props.is_grounded = pos_z <= props.ground_level + 0.01 + else: + props.is_grounded = pos_y <= props.ground_level + 0.01 # Horizontal movement horizontal_vel = SMBPhysicsEngine.update_horizontal( @@ -265,24 +407,69 @@ def update_physics(context, delta_time): pos_y += vertical_vel * delta_time props.velocity_y = vertical_vel - # Ground collision + # Update position before collision detection + obj.location.x = pos_x + obj.location.y = pos_y + obj.location.z = pos_z + + # Object collision detection + if props.enable_collision: + collision_objects = SMBPhysicsEngine.get_collision_objects(context, obj) + padding = props.collision_padding + + for obstacle in collision_objects: + # Recalculate player bounds each iteration (position may change from previous collision) + player_bounds = SMBPhysicsEngine.get_object_bounds(obj) + obstacle_bounds = SMBPhysicsEngine.get_object_bounds(obstacle) + + # Apply padding to shrink the effective player collision box slightly + # This helps prevent getting stuck on edges + # min values decrease (subtract padding), max values decrease (subtract padding) + # This creates a slightly smaller hitbox for smoother collision + player_bounds = ( + player_bounds[0] + padding, player_bounds[1] - padding, + player_bounds[2] + padding, player_bounds[3] - padding, + player_bounds[4] + padding, player_bounds[5] - padding + ) + + if SMBPhysicsEngine.check_aabb_collision(player_bounds, obstacle_bounds): + # Get current velocity + vel = (props.velocity_x, props.velocity_y, props.velocity_z) + + # Resolve collision + offset, new_vel, is_grounded = SMBPhysicsEngine.resolve_collision( + player_bounds, obstacle_bounds, vel, + props.forward_axis, effective_up_axis + ) + + # Apply offset + obj.location.x += offset[0] + obj.location.y += offset[1] + obj.location.z += offset[2] + + # Update velocity + props.velocity_x = new_vel[0] + props.velocity_y = new_vel[1] + props.velocity_z = new_vel[2] + + # Update grounded state if landed on top + if is_grounded: + props.is_grounded = True + props.is_jumping = False + + # Ground plane collision (fallback) if effective_up_axis == 'Z': - if pos_z < props.ground_level: - pos_z = props.ground_level + if obj.location.z < props.ground_level: + obj.location.z = props.ground_level props.velocity_z = 0 props.is_grounded = True props.is_jumping = False else: - if pos_y < props.ground_level: - pos_y = props.ground_level + if obj.location.y < props.ground_level: + obj.location.y = props.ground_level props.velocity_y = 0 props.is_grounded = True props.is_jumping = False - - # Update object position - obj.location.x = pos_x - obj.location.y = pos_y - obj.location.z = pos_z @staticmethod def update_horizontal(props, velocity, delta_time): @@ -517,6 +704,47 @@ def execute(self, context): return {'FINISHED'} +class SMB_OT_tag_collision(bpy.types.Operator): + """Tag selected objects as collision objects""" + bl_idname = "smb.tag_collision" + bl_label = "Tag as Collision" + bl_description = "Tag selected objects as collision objects for SMB physics" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.selected_objects + + def execute(self, context): + count = 0 + for obj in context.selected_objects: + obj['smb_collision'] = True + count += 1 + self.report({'INFO'}, f"Tagged {count} object(s) for collision") + return {'FINISHED'} + + +class SMB_OT_untag_collision(bpy.types.Operator): + """Remove collision tag from selected objects""" + bl_idname = "smb.untag_collision" + bl_label = "Remove Collision Tag" + bl_description = "Remove collision tag from selected objects" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.selected_objects + + def execute(self, context): + count = 0 + for obj in context.selected_objects: + if 'smb_collision' in obj: + del obj['smb_collision'] + count += 1 + self.report({'INFO'}, f"Removed collision tag from {count} object(s)") + return {'FINISHED'} + + class SMB_PT_physics_panel(bpy.types.Panel): """Panel for SMB Physics controls""" bl_label = "Super Mario Bros. Physics" @@ -630,14 +858,62 @@ def draw(self, context): layout.operator("smb.reset_defaults", icon='FILE_REFRESH') +class SMB_PT_collision_panel(bpy.types.Panel): + """Panel for SMB Collision settings""" + bl_label = "Collision" + bl_idname = "SMB_PT_collision_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "SMB Physics" + bl_parent_id = "SMB_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.smb_physics_props + + # Collision toggle + layout.prop(props, "enable_collision") + + if props.enable_collision: + layout.prop(props, "collision_padding") + + layout.separator() + + # Tag/Untag buttons + box = layout.box() + box.label(text="Collision Objects:", icon='MOD_PHYSICS') + col = box.column(align=True) + col.operator("smb.tag_collision", icon='ADD') + col.operator("smb.untag_collision", icon='REMOVE') + + # List collision objects in scene + layout.separator() + box = layout.box() + box.label(text="Tagged Objects:", icon='OUTLINER_OB_MESH') + + collision_count = 0 + for obj in context.scene.objects: + if obj.get('smb_collision', False) or 'smb_collision' in obj.name.lower(): + collision_count += 1 + row = box.row() + row.label(text=obj.name, icon='CUBE') + + if collision_count == 0: + box.label(text="No collision objects", icon='INFO') + + # Registration classes = [ SMBPhysicsProperties, SMB_OT_start_physics, SMB_OT_stop_physics, SMB_OT_reset_to_defaults, + SMB_OT_tag_collision, + SMB_OT_untag_collision, SMB_PT_physics_panel, SMB_PT_physics_settings, + SMB_PT_collision_panel, ] From 69e17460236b81878f3e0dee0acbfc5fba2c0757 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:08:51 +0000 Subject: [PATCH 06/13] Add custom properties for drivers, physics presets, and game mode Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- README.md | 3 + smb_physics.py | 393 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) diff --git a/README.md b/README.md index 5b0d577..e1c0b32 100644 --- a/README.md +++ b/README.md @@ -9,5 +9,8 @@ smb_physics - Super Mario Bros. Physics addon that recreates accurate NES Super - Air control while jumping - AABB collision detection with tagged objects (platforms, walls, blocks) - Tag objects for collision using the Collision panel or by adding 'smb_collision' to object name + - Physics presets (SMB1, SMB3, Floaty, Tight) with ability to save/load custom presets + - Custom properties on player object for driver usage (velocity, grounded state, etc.) + - Game Mode: Block Blender shortcuts while physics is active for uninterrupted gameplay - Customizable physics parameters with reset to SMB defaults - Controls: Arrow keys (←→) to move, Space to jump, Shift to run, Esc to stop diff --git a/smb_physics.py b/smb_physics.py index 71b190c..b9db01f 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -4,6 +4,8 @@ import bpy import time +import json +import os from mathutils import Vector from bpy.app.handlers import persistent @@ -73,6 +75,116 @@ SMB_JUMP_GRAVITY_MULTIPLIER = 0.5 +# ============================================================================== +# Built-in Physics Presets +# ============================================================================== +PHYSICS_PRESETS = { + 'SMB1': { + 'name': 'Super Mario Bros. 1', + 'gravity': SMB_GRAVITY, + 'terminal_velocity': SMB_TERMINAL_VELOCITY, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN, + 'max_walk_speed': SMB_MAX_WALK_SPEED, + 'max_run_speed': SMB_MAX_RUN_SPEED, + 'walk_acceleration': SMB_WALK_ACCEL, + 'run_acceleration': SMB_RUN_ACCEL, + 'friction': SMB_FRICTION, + 'skid_deceleration': SMB_SKID_DECEL, + }, + 'SMB3': { + 'name': 'Super Mario Bros. 3', + # SMB3 has slightly different physics - floatier jumps + 'gravity': SMB_GRAVITY * 0.85, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.9, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.1, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.15, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 1.05, + 'max_run_speed': SMB_MAX_RUN_SPEED * 1.1, + 'walk_acceleration': SMB_WALK_ACCEL * 1.1, + 'run_acceleration': SMB_RUN_ACCEL * 1.1, + 'friction': SMB_FRICTION * 0.9, + 'skid_deceleration': SMB_SKID_DECEL * 0.85, + }, + 'FLOATY': { + 'name': 'Floaty (Low Gravity)', + 'gravity': SMB_GRAVITY * 0.5, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.6, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 0.8, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 0.85, + 'max_walk_speed': SMB_MAX_WALK_SPEED, + 'max_run_speed': SMB_MAX_RUN_SPEED, + 'walk_acceleration': SMB_WALK_ACCEL * 0.8, + 'run_acceleration': SMB_RUN_ACCEL * 0.8, + 'friction': SMB_FRICTION * 0.5, + 'skid_deceleration': SMB_SKID_DECEL * 0.6, + }, + 'TIGHT': { + 'name': 'Tight Controls', + 'gravity': SMB_GRAVITY * 1.3, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 1.2, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.2, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.2, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 1.2, + 'max_run_speed': SMB_MAX_RUN_SPEED * 1.2, + 'walk_acceleration': SMB_WALK_ACCEL * 1.5, + 'run_acceleration': SMB_RUN_ACCEL * 1.5, + 'friction': SMB_FRICTION * 1.5, + 'skid_deceleration': SMB_SKID_DECEL * 1.3, + }, +} + + +def get_presets_directory(): + """Get the directory for storing custom presets""" + # Use Blender's config directory for user presets + config_dir = bpy.utils.user_resource('CONFIG') + presets_dir = os.path.join(config_dir, 'smb_physics_presets') + if not os.path.exists(presets_dir): + os.makedirs(presets_dir) + return presets_dir + + +def get_custom_presets(): + """Load all custom presets from the presets directory""" + presets = {} + presets_dir = get_presets_directory() + if os.path.exists(presets_dir): + for filename in os.listdir(presets_dir): + if filename.endswith('.json'): + filepath = os.path.join(presets_dir, filename) + try: + with open(filepath, 'r') as f: + preset = json.load(f) + preset_name = filename[:-5] # Remove .json + presets[preset_name] = preset + except (json.JSONDecodeError, IOError): + pass + return presets + + +def update_player_custom_properties(obj, props): + """Update custom properties on the player object for driver usage""" + if obj is None: + return + + # Physics state properties (can be used with drivers) + obj['smb_velocity_x'] = props.velocity_x + obj['smb_velocity_y'] = props.velocity_y + obj['smb_velocity_z'] = props.velocity_z + obj['smb_speed'] = (props.velocity_x ** 2 + props.velocity_y ** 2 + props.velocity_z ** 2) ** 0.5 + obj['smb_horizontal_speed'] = abs(props.velocity_x) if props.forward_axis == 'X' else abs(props.velocity_y) + obj['smb_is_grounded'] = 1.0 if props.is_grounded else 0.0 + obj['smb_is_jumping'] = 1.0 if props.is_jumping else 0.0 + obj['smb_is_running'] = 1.0 if props.is_running else 0.0 + obj['smb_is_moving_left'] = 1.0 if props.input_left else 0.0 + obj['smb_is_moving_right'] = 1.0 if props.input_right else 0.0 + # Facing direction based on the configured forward axis + horizontal_vel = props.velocity_x if props.forward_axis == 'X' else props.velocity_y + obj['smb_facing_direction'] = 1.0 if horizontal_vel >= 0 else -1.0 + obj['smb_physics_active'] = 1.0 if props.is_active else 0.0 + + class SMBPhysicsProperties(bpy.types.PropertyGroup): """Properties for SMB Physics simulation""" @@ -211,6 +323,20 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): ], default='Z' ) + + # Preset name for saving + preset_name: bpy.props.StringProperty( + name="Preset Name", + description="Name for saving the current physics settings as a preset", + default="My Preset" + ) + + # Block Blender shortcuts in game mode + block_shortcuts: bpy.props.BoolProperty( + name="Block Shortcuts", + description="Block Blender shortcuts while physics is active (Game Mode)", + default=True + ) class SMBPhysicsEngine: @@ -470,6 +596,9 @@ def update_physics(context, delta_time): props.velocity_y = 0 props.is_grounded = True props.is_jumping = False + + # Update custom properties on player object for driver usage + update_player_custom_properties(obj, props) @staticmethod def update_horizontal(props, velocity, delta_time): @@ -592,31 +721,51 @@ def modal(self, context, event): area.tag_redraw() # Handle keyboard input for movement (PRESS sets True, RELEASE sets False) + handled = False if event.type == 'LEFT_ARROW': if event.value == 'PRESS': props.input_left = True elif event.value == 'RELEASE': props.input_left = False + handled = True elif event.type == 'RIGHT_ARROW': if event.value == 'PRESS': props.input_right = True elif event.value == 'RELEASE': props.input_right = False + handled = True elif event.type == 'SPACE': if event.value == 'PRESS': props.input_jump = True elif event.value == 'RELEASE': props.input_jump = False + handled = True elif event.type == 'LEFT_SHIFT': if event.value == 'PRESS': props.input_run = True elif event.value == 'RELEASE': props.input_run = False + handled = True elif event.type in {'ESC'}: props.is_active = False self.cancel(context) return {'CANCELLED'} + # Block Blender shortcuts in game mode if enabled + if props.block_shortcuts: + # Allow mouse events and timer events to pass through + if event.type in {'TIMER', 'MOUSEMOVE', 'INBETWEEN_MOUSEMOVE', + 'LEFTMOUSE', 'RIGHTMOUSE', 'MIDDLEMOUSE', + 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', + 'WINDOW_DEACTIVATE', 'NONE'}: + return {'PASS_THROUGH'} + # Block all keyboard events except our game controls + if event.type not in {'LEFT_ARROW', 'RIGHT_ARROW', 'SPACE', 'LEFT_SHIFT', 'ESC'}: + return {'RUNNING_MODAL'} + # If we handled a game control, consume it + if handled: + return {'RUNNING_MODAL'} + return {'PASS_THROUGH'} def execute(self, context): @@ -644,6 +793,9 @@ def execute(self, context): else: props.ground_level = obj.location.y + # Initialize custom properties on player object + update_player_custom_properties(obj, props) + props.is_active = True wm = context.window_manager @@ -661,6 +813,11 @@ def cancel(self, context): props.input_jump = False props.input_run = False + # Update custom properties to reflect inactive state + obj = context.active_object + if obj: + update_player_custom_properties(obj, props) + wm = context.window_manager if self._timer: wm.event_timer_remove(self._timer) @@ -704,6 +861,126 @@ def execute(self, context): return {'FINISHED'} +class SMB_OT_load_preset(bpy.types.Operator): + """Load a physics preset""" + bl_idname = "smb.load_preset" + bl_label = "Load Preset" + bl_description = "Load a physics preset" + bl_options = {'REGISTER', 'UNDO'} + + preset_key: bpy.props.StringProperty( + name="Preset Key", + description="Key of the preset to load" + ) + + def execute(self, context): + props = context.scene.smb_physics_props + + # Check built-in presets first + if self.preset_key in PHYSICS_PRESETS: + preset = PHYSICS_PRESETS[self.preset_key] + else: + # Check custom presets + custom_presets = get_custom_presets() + if self.preset_key in custom_presets: + preset = custom_presets[self.preset_key] + else: + self.report({'ERROR'}, f"Preset '{self.preset_key}' not found") + return {'CANCELLED'} + + # Apply preset values + props.gravity = preset.get('gravity', SMB_GRAVITY) + props.terminal_velocity = preset.get('terminal_velocity', SMB_TERMINAL_VELOCITY) + props.jump_velocity = preset.get('jump_velocity', SMB_JUMP_VELOCITY_WALK) + props.jump_velocity_run = preset.get('jump_velocity_run', SMB_JUMP_VELOCITY_RUN) + props.max_walk_speed = preset.get('max_walk_speed', SMB_MAX_WALK_SPEED) + props.max_run_speed = preset.get('max_run_speed', SMB_MAX_RUN_SPEED) + props.walk_acceleration = preset.get('walk_acceleration', SMB_WALK_ACCEL) + props.run_acceleration = preset.get('run_acceleration', SMB_RUN_ACCEL) + props.friction = preset.get('friction', SMB_FRICTION) + props.skid_deceleration = preset.get('skid_deceleration', SMB_SKID_DECEL) + + preset_name = preset.get('name', self.preset_key) + self.report({'INFO'}, f"Loaded preset: {preset_name}") + return {'FINISHED'} + + +class SMB_OT_save_preset(bpy.types.Operator): + """Save current physics settings as a preset""" + bl_idname = "smb.save_preset" + bl_label = "Save Preset" + bl_description = "Save current physics settings as a custom preset" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.smb_physics_props + + preset_name = props.preset_name.strip() + if not preset_name: + self.report({'ERROR'}, "Please enter a preset name") + return {'CANCELLED'} + + # Create preset data + preset = { + 'name': preset_name, + 'gravity': props.gravity, + 'terminal_velocity': props.terminal_velocity, + 'jump_velocity': props.jump_velocity, + 'jump_velocity_run': props.jump_velocity_run, + 'max_walk_speed': props.max_walk_speed, + 'max_run_speed': props.max_run_speed, + 'walk_acceleration': props.walk_acceleration, + 'run_acceleration': props.run_acceleration, + 'friction': props.friction, + 'skid_deceleration': props.skid_deceleration, + } + + # Save to file + presets_dir = get_presets_directory() + # Sanitize filename + safe_name = "".join(c for c in preset_name if c.isalnum() or c in (' ', '-', '_')).strip() + safe_name = safe_name.replace(' ', '_') + filepath = os.path.join(presets_dir, f"{safe_name}.json") + + try: + with open(filepath, 'w') as f: + json.dump(preset, f, indent=2) + self.report({'INFO'}, f"Saved preset: {preset_name}") + return {'FINISHED'} + except IOError as e: + self.report({'ERROR'}, f"Failed to save preset: {e}") + return {'CANCELLED'} + + +class SMB_OT_delete_preset(bpy.types.Operator): + """Delete a custom preset""" + bl_idname = "smb.delete_preset" + bl_label = "Delete Preset" + bl_description = "Delete a custom preset" + bl_options = {'REGISTER', 'UNDO'} + + preset_key: bpy.props.StringProperty( + name="Preset Key", + description="Key of the preset to delete" + ) + + def execute(self, context): + presets_dir = get_presets_directory() + filepath = os.path.join(presets_dir, f"{self.preset_key}.json") + + if os.path.exists(filepath): + try: + os.remove(filepath) + self.report({'INFO'}, f"Deleted preset: {self.preset_key}") + return {'FINISHED'} + except IOError as e: + self.report({'ERROR'}, f"Failed to delete preset: {e}") + return {'CANCELLED'} + else: + self.report({'ERROR'}, f"Preset file not found: {self.preset_key}") + return {'CANCELLED'} + + class SMB_OT_tag_collision(bpy.types.Operator): """Tag selected objects as collision objects""" bl_idname = "smb.tag_collision" @@ -796,8 +1073,119 @@ def draw(self, context): col.label(text="Space : Jump") col.label(text="Shift : Run") col.label(text="Esc : Stop") + + # Game mode indicator + if props.block_shortcuts: + box = layout.box() + box.label(text="🎮 GAME MODE ACTIVE", icon='GAME') + box.label(text="Blender shortcuts blocked") else: layout.operator("smb.start_physics", text="Start Physics", icon='PLAY') + + # Game mode option + layout.prop(props, "block_shortcuts") + + +class SMB_PT_presets_panel(bpy.types.Panel): + """Panel for SMB Physics presets""" + bl_label = "Presets" + bl_idname = "SMB_PT_presets_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "SMB Physics" + bl_parent_id = "SMB_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.smb_physics_props + + # Built-in presets + box = layout.box() + box.label(text="Built-in Presets:", icon='PRESET') + col = box.column(align=True) + for key, preset in PHYSICS_PRESETS.items(): + op = col.operator("smb.load_preset", text=preset['name'], icon='PLAY') + op.preset_key = key + + layout.separator() + + # Custom presets + box = layout.box() + box.label(text="Custom Presets:", icon='USER') + + custom_presets = get_custom_presets() + if custom_presets: + for key, preset in custom_presets.items(): + row = box.row(align=True) + op = row.operator("smb.load_preset", text=preset.get('name', key), icon='PLAY') + op.preset_key = key + op = row.operator("smb.delete_preset", text="", icon='X') + op.preset_key = key + else: + box.label(text="No custom presets", icon='INFO') + + layout.separator() + + # Save preset + box = layout.box() + box.label(text="Save Current Settings:", icon='FILE_NEW') + box.prop(props, "preset_name", text="Name") + box.operator("smb.save_preset", icon='FILE_TICK') + + +class SMB_PT_driver_props_panel(bpy.types.Panel): + """Panel showing driver-compatible properties""" + bl_label = "Driver Properties" + bl_idname = "SMB_PT_driver_props_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "SMB Physics" + bl_parent_id = "SMB_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + obj = context.active_object + + if not obj: + layout.label(text="No object selected", icon='ERROR') + return + + box = layout.box() + box.label(text="Player Custom Properties:", icon='DRIVER') + box.label(text="(For use with drivers)", icon='INFO') + + col = box.column(align=True) + + # List available properties + driver_props = [ + ('smb_velocity_x', 'Velocity X'), + ('smb_velocity_y', 'Velocity Y'), + ('smb_velocity_z', 'Velocity Z'), + ('smb_speed', 'Total Speed'), + ('smb_horizontal_speed', 'Horizontal Speed'), + ('smb_is_grounded', 'Is Grounded (0/1)'), + ('smb_is_jumping', 'Is Jumping (0/1)'), + ('smb_is_running', 'Is Running (0/1)'), + ('smb_is_moving_left', 'Moving Left (0/1)'), + ('smb_is_moving_right', 'Moving Right (0/1)'), + ('smb_facing_direction', 'Facing Direction (-1/1)'), + ('smb_physics_active', 'Physics Active (0/1)'), + ] + + for prop_name, prop_label in driver_props: + row = col.row() + value = obj.get(prop_name, 'N/A') + if isinstance(value, float): + row.label(text=f"{prop_label}: {value:.2f}") + else: + row.label(text=f"{prop_label}: {value}") + + layout.separator() + box = layout.box() + box.label(text="Driver Path Example:", icon='QUESTION') + box.label(text='bpy.data.objects["' + obj.name + '"]["smb_velocity_x"]') class SMB_PT_physics_settings(bpy.types.Panel): @@ -909,9 +1297,14 @@ def draw(self, context): SMB_OT_start_physics, SMB_OT_stop_physics, SMB_OT_reset_to_defaults, + SMB_OT_load_preset, + SMB_OT_save_preset, + SMB_OT_delete_preset, SMB_OT_tag_collision, SMB_OT_untag_collision, SMB_PT_physics_panel, + SMB_PT_presets_panel, + SMB_PT_driver_props_panel, SMB_PT_physics_settings, SMB_PT_collision_panel, ] From 9f5aadf3f19f3e546fe79882f46c8f4f6a51b724 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:29:43 +0000 Subject: [PATCH 07/13] Fix icon error, add more presets, collision types, and swimming mode Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 395 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 360 insertions(+), 35 deletions(-) diff --git a/smb_physics.py b/smb_physics.py index b9db01f..cb5fb92 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -92,6 +92,20 @@ 'friction': SMB_FRICTION, 'skid_deceleration': SMB_SKID_DECEL, }, + 'SMB2': { + 'name': 'Super Mario Bros. 2 (USA)', + # SMB2 USA has floatier physics, higher jumps, can pick up enemies + 'gravity': SMB_GRAVITY * 0.75, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.85, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.3, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.35, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 0.9, + 'max_run_speed': SMB_MAX_RUN_SPEED * 0.95, + 'walk_acceleration': SMB_WALK_ACCEL * 0.9, + 'run_acceleration': SMB_RUN_ACCEL * 0.9, + 'friction': SMB_FRICTION * 0.8, + 'skid_deceleration': SMB_SKID_DECEL * 0.7, + }, 'SMB3': { 'name': 'Super Mario Bros. 3', # SMB3 has slightly different physics - floatier jumps @@ -106,6 +120,62 @@ 'friction': SMB_FRICTION * 0.9, 'skid_deceleration': SMB_SKID_DECEL * 0.85, }, + 'SMW': { + 'name': 'Super Mario World', + # SMW has more momentum, spin jumps, cape flying + 'gravity': SMB_GRAVITY * 0.9, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.95, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.15, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.2, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 1.1, + 'max_run_speed': SMB_MAX_RUN_SPEED * 1.2, + 'walk_acceleration': SMB_WALK_ACCEL * 1.0, + 'run_acceleration': SMB_RUN_ACCEL * 1.1, + 'friction': SMB_FRICTION * 0.85, + 'skid_deceleration': SMB_SKID_DECEL * 0.9, + }, + 'NSMB': { + 'name': 'New Super Mario Bros.', + # NSMB has more floaty physics, wall jumps, ground pound + 'gravity': SMB_GRAVITY * 0.8, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.85, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.2, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.25, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 1.05, + 'max_run_speed': SMB_MAX_RUN_SPEED * 1.15, + 'walk_acceleration': SMB_WALK_ACCEL * 1.0, + 'run_acceleration': SMB_RUN_ACCEL * 1.05, + 'friction': SMB_FRICTION * 0.75, + 'skid_deceleration': SMB_SKID_DECEL * 0.8, + }, + 'LUIGI_SMB1': { + 'name': 'Luigi (SMB1 Style)', + # Luigi has higher jumps but less traction + 'gravity': SMB_GRAVITY * 0.85, + 'terminal_velocity': SMB_TERMINAL_VELOCITY, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.25, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.3, + 'max_walk_speed': SMB_MAX_WALK_SPEED, + 'max_run_speed': SMB_MAX_RUN_SPEED, + 'walk_acceleration': SMB_WALK_ACCEL * 0.85, + 'run_acceleration': SMB_RUN_ACCEL * 0.85, + 'friction': SMB_FRICTION * 0.5, # Slippery! + 'skid_deceleration': SMB_SKID_DECEL * 0.6, + }, + 'LUIGI_SMB2': { + 'name': 'Luigi (SMB2 Style)', + # SMB2 Luigi has flutter jump, highest jumps + 'gravity': SMB_GRAVITY * 0.6, + 'terminal_velocity': SMB_TERMINAL_VELOCITY * 0.75, + 'jump_velocity': SMB_JUMP_VELOCITY_WALK * 1.5, + 'jump_velocity_run': SMB_JUMP_VELOCITY_RUN * 1.55, + 'max_walk_speed': SMB_MAX_WALK_SPEED * 0.85, + 'max_run_speed': SMB_MAX_RUN_SPEED * 0.9, + 'walk_acceleration': SMB_WALK_ACCEL * 0.8, + 'run_acceleration': SMB_RUN_ACCEL * 0.8, + 'friction': SMB_FRICTION * 0.4, # Very slippery! + 'skid_deceleration': SMB_SKID_DECEL * 0.5, + }, 'FLOATY': { 'name': 'Floaty (Low Gravity)', 'gravity': SMB_GRAVITY * 0.5, @@ -177,6 +247,7 @@ def update_player_custom_properties(obj, props): obj['smb_is_grounded'] = 1.0 if props.is_grounded else 0.0 obj['smb_is_jumping'] = 1.0 if props.is_jumping else 0.0 obj['smb_is_running'] = 1.0 if props.is_running else 0.0 + obj['smb_is_swimming'] = 1.0 if props.is_swimming else 0.0 obj['smb_is_moving_left'] = 1.0 if props.input_left else 0.0 obj['smb_is_moving_right'] = 1.0 if props.input_right else 0.0 # Facing direction based on the configured forward axis @@ -337,6 +408,47 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): description="Block Blender shortcuts while physics is active (Game Mode)", default=True ) + + # Swimming mode + is_swimming: bpy.props.BoolProperty( + name="Is Swimming", + description="Whether the player is currently in water", + default=False + ) + + swim_gravity: bpy.props.FloatProperty( + name="Swim Gravity", + description="Gravity while swimming (units/sec²)", + default=SMB_GRAVITY * 0.3, + min=0.0 + ) + + swim_speed: bpy.props.FloatProperty( + name="Swim Speed", + description="Maximum swimming speed (units/sec)", + default=SMB_MAX_WALK_SPEED * 0.7, + min=0.0 + ) + + swim_acceleration: bpy.props.FloatProperty( + name="Swim Acceleration", + description="Acceleration while swimming (units/sec²)", + default=SMB_WALK_ACCEL * 0.6, + min=0.0 + ) + + +# Collision type constants +COLLISION_TYPES = { + 'SOLID': 'Standard solid collision', + 'BREAKABLE': 'Can be broken by hitting from below', + 'SPRING': 'Bounces player upward', + 'ENEMY': 'Damages player on side contact, defeated by jumping on', + 'WATER': 'Enables swimming mode when entered', + 'FIRE': 'Damages player on any contact', + 'MOVING': 'Moving platform that carries player', + 'ONE_WAY': 'Can pass through from below', +} class SMBPhysicsEngine: @@ -370,26 +482,56 @@ def check_aabb_collision(bounds1, bounds2): @staticmethod def get_collision_objects(context, player_obj): - """Get all objects tagged for collision (name contains 'smb_collision' or has custom property)""" + """Get all objects tagged for collision with their collision type""" collision_objects = [] for obj in context.scene.objects: if obj == player_obj: continue # Check if object is tagged for collision if 'smb_collision' in obj.name.lower() or obj.get('smb_collision', False): - collision_objects.append(obj) + # Get collision type (default to SOLID) + collision_type = obj.get('smb_collision_type', 'SOLID') + collision_objects.append((obj, collision_type)) return collision_objects @staticmethod - def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up_axis): + def get_water_boxes(context, player_obj): + """Get all objects tagged as water boxes""" + water_boxes = [] + for obj in context.scene.objects: + if obj == player_obj: + continue + if obj.get('smb_collision_type') == 'WATER' or 'smb_water' in obj.name.lower(): + water_boxes.append(obj) + return water_boxes + + @staticmethod + def check_in_water(context, player_obj, props): + """Check if player is inside any water box""" + water_boxes = SMBPhysicsEngine.get_water_boxes(context, player_obj) + player_bounds = SMBPhysicsEngine.get_object_bounds(player_obj) + + for water_obj in water_boxes: + water_bounds = SMBPhysicsEngine.get_object_bounds(water_obj) + if SMBPhysicsEngine.check_aabb_collision(player_bounds, water_bounds): + return True + return False + + @staticmethod + def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up_axis, collision_type='SOLID'): """ Resolve collision between player and obstacle. - Returns: (new_pos_offset, new_velocity, is_grounded_on_top) + Returns: (new_pos_offset, new_velocity, is_grounded_on_top, special_action) SMB-style collision resolution: - Landing on top of objects (like platforms/blocks) - Hitting head on bottom of objects - Horizontal wall collision + + Special collision types: + - SPRING: Bounces player upward + - ONE_WAY: Only collide from above + - ENEMY: Can stomp from above """ p_min_x, p_max_x, p_min_y, p_max_y, p_min_z, p_max_z = player_bounds o_min_x, o_max_x, o_min_y, o_max_y, o_min_z, o_max_z = obstacle_bounds @@ -397,36 +539,56 @@ def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up vel_x, vel_y, vel_z = velocity offset_x, offset_y, offset_z = 0.0, 0.0, 0.0 is_grounded = False + special_action = None # Can be 'bounce', 'stomp', 'damage', 'break' # Calculate overlap on each axis overlap_x = min(p_max_x - o_min_x, o_max_x - p_min_x) overlap_y = min(p_max_y - o_min_y, o_max_y - p_min_y) overlap_z = min(p_max_z - o_min_z, o_max_z - p_min_z) - # Determine which axis has smallest overlap (that's where we resolve) - # Also consider velocity direction for better resolution - # For Z axis (vertical in default config) if up_axis == 'Z': - # Check if player is mostly above or below obstacle player_center_z = (p_min_z + p_max_z) / 2 obstacle_center_z = (o_min_z + o_max_z) / 2 + is_above = player_center_z > obstacle_center_z + is_below = player_center_z < obstacle_center_z + + # Handle ONE_WAY platforms - only collide from above + if collision_type == 'ONE_WAY': + if not is_above or vel_z > 0: + return (0, 0, 0), velocity, False, None if overlap_z <= overlap_x and overlap_z <= overlap_y: - # Resolve vertically - if player_center_z > obstacle_center_z: + if is_above: # Player is above - land on top offset_z = o_max_z - p_min_z - if vel_z < 0: - vel_z = 0 - is_grounded = True + + if collision_type == 'SPRING': + # Bounce upward + vel_z = abs(vel_z) * 2.0 if abs(vel_z) > 1.0 else 15.0 + special_action = 'bounce' + elif collision_type == 'ENEMY': + vel_z = 8.0 # Stomp bounce + special_action = 'stomp' + else: + if vel_z < 0: + vel_z = 0 + is_grounded = True else: # Player is below - hit head offset_z = o_min_z - p_max_z if vel_z > 0: vel_z = 0 + + if collision_type == 'BREAKABLE': + special_action = 'break' + elif collision_type == 'ENEMY': + special_action = 'damage' else: - # Resolve horizontally + # Horizontal collision + if collision_type == 'ENEMY' or collision_type == 'FIRE': + special_action = 'damage' + if forward_axis == 'X': player_center_x = (p_min_x + p_max_x) / 2 obstacle_center_x = (o_min_x + o_max_x) / 2 @@ -447,19 +609,41 @@ def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up # Y is up axis player_center_y = (p_min_y + p_max_y) / 2 obstacle_center_y = (o_min_y + o_max_y) / 2 + is_above = player_center_y > obstacle_center_y + + # Handle ONE_WAY platforms + if collision_type == 'ONE_WAY': + if not is_above or vel_y > 0: + return (0, 0, 0), velocity, False, None if overlap_y <= overlap_x and overlap_y <= overlap_z: - if player_center_y > obstacle_center_y: + if is_above: offset_y = o_max_y - p_min_y - if vel_y < 0: - vel_y = 0 - is_grounded = True + + if collision_type == 'SPRING': + vel_y = abs(vel_y) * 2.0 if abs(vel_y) > 1.0 else 15.0 + special_action = 'bounce' + elif collision_type == 'ENEMY': + vel_y = 8.0 + special_action = 'stomp' + else: + if vel_y < 0: + vel_y = 0 + is_grounded = True else: offset_y = o_min_y - p_max_y if vel_y > 0: vel_y = 0 + + if collision_type == 'BREAKABLE': + special_action = 'break' + elif collision_type == 'ENEMY': + special_action = 'damage' else: # Horizontal resolution + if collision_type == 'ENEMY' or collision_type == 'FIRE': + special_action = 'damage' + player_center_x = (p_min_x + p_max_x) / 2 obstacle_center_x = (o_min_x + o_max_x) / 2 if player_center_x > obstacle_center_x: @@ -468,7 +652,7 @@ def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up offset_x = o_min_x - p_max_x vel_x = 0 - return (offset_x, offset_y, offset_z), (vel_x, vel_y, vel_z), is_grounded + return (offset_x, offset_y, offset_z), (vel_x, vel_y, vel_z), is_grounded, special_action @staticmethod def update_physics(context, delta_time): @@ -508,12 +692,16 @@ def update_physics(context, delta_time): else: props.is_grounded = pos_y <= props.ground_level + 0.01 - # Horizontal movement + # Check if in water + was_swimming = props.is_swimming + props.is_swimming = SMBPhysicsEngine.check_in_water(context, obj, props) + + # Horizontal movement (modified for swimming) horizontal_vel = SMBPhysicsEngine.update_horizontal( props, horizontal_vel, delta_time ) - # Vertical movement (jumping/gravity) + # Vertical movement (jumping/gravity/swimming) vertical_vel = SMBPhysicsEngine.update_vertical( props, vertical_vel, delta_time ) @@ -543,15 +731,16 @@ def update_physics(context, delta_time): collision_objects = SMBPhysicsEngine.get_collision_objects(context, obj) padding = props.collision_padding - for obstacle in collision_objects: + for obstacle, collision_type in collision_objects: + # Skip WATER type in normal collision (handled separately) + if collision_type == 'WATER': + continue + # Recalculate player bounds each iteration (position may change from previous collision) player_bounds = SMBPhysicsEngine.get_object_bounds(obj) obstacle_bounds = SMBPhysicsEngine.get_object_bounds(obstacle) # Apply padding to shrink the effective player collision box slightly - # This helps prevent getting stuck on edges - # min values decrease (subtract padding), max values decrease (subtract padding) - # This creates a slightly smaller hitbox for smoother collision player_bounds = ( player_bounds[0] + padding, player_bounds[1] - padding, player_bounds[2] + padding, player_bounds[3] - padding, @@ -562,12 +751,24 @@ def update_physics(context, delta_time): # Get current velocity vel = (props.velocity_x, props.velocity_y, props.velocity_z) - # Resolve collision - offset, new_vel, is_grounded = SMBPhysicsEngine.resolve_collision( + # Resolve collision with type + offset, new_vel, is_grounded, special_action = SMBPhysicsEngine.resolve_collision( player_bounds, obstacle_bounds, vel, - props.forward_axis, effective_up_axis + props.forward_axis, effective_up_axis, collision_type ) + # Handle special actions + if special_action == 'break': + # Hide or mark the object as broken + obstacle['smb_broken'] = True + obstacle.hide_viewport = True + obstacle.hide_render = True + elif special_action == 'stomp': + # Mark enemy as defeated + obstacle['smb_defeated'] = True + obstacle.hide_viewport = True + obstacle.hide_render = True + # Apply offset obj.location.x += offset[0] obj.location.y += offset[1] @@ -610,6 +811,25 @@ def update_horizontal(props, velocity, delta_time): if props.input_right: direction += 1 + # Swimming mode uses different physics + if props.is_swimming: + max_speed = props.swim_speed + acceleration = props.swim_acceleration + friction = props.swim_acceleration * 0.5 # Less friction in water + + if direction != 0: + velocity += direction * acceleration * delta_time + velocity = max(-max_speed, min(max_speed, velocity)) + else: + # Apply water resistance + if velocity > 0: + velocity = max(0, velocity - friction * delta_time) + elif velocity < 0: + velocity = min(0, velocity + friction * delta_time) + + return velocity + + # Normal movement # Determine max speed and acceleration based on run state if props.input_run or props.is_running: max_speed = props.max_run_speed @@ -654,7 +874,20 @@ def update_horizontal(props, velocity, delta_time): @staticmethod def update_vertical(props, velocity, delta_time): - """Update vertical velocity (jumping and gravity)""" + """Update vertical velocity (jumping, gravity, and swimming)""" + + # Swimming mode + if props.is_swimming: + # In water, pressing jump swims upward + if props.input_jump: + velocity = props.swim_speed * 0.8 # Swim upward + else: + # Slowly sink in water + velocity -= props.swim_gravity * delta_time + velocity = max(-props.swim_speed * 0.5, velocity) # Cap sinking speed + return velocity + + # Normal jump/gravity # Handle jump initiation if props.input_jump and props.is_grounded and not props.is_jumping: props.is_jumping = True @@ -985,7 +1218,7 @@ class SMB_OT_tag_collision(bpy.types.Operator): """Tag selected objects as collision objects""" bl_idname = "smb.tag_collision" bl_label = "Tag as Collision" - bl_description = "Tag selected objects as collision objects for SMB physics" + bl_description = "Tag selected objects as collision objects for SMB physics (SOLID type)" bl_options = {'REGISTER', 'UNDO'} @classmethod @@ -996,8 +1229,9 @@ def execute(self, context): count = 0 for obj in context.selected_objects: obj['smb_collision'] = True + obj['smb_collision_type'] = 'SOLID' count += 1 - self.report({'INFO'}, f"Tagged {count} object(s) for collision") + self.report({'INFO'}, f"Tagged {count} object(s) for SOLID collision") return {'FINISHED'} @@ -1015,13 +1249,56 @@ def poll(cls, context): def execute(self, context): count = 0 for obj in context.selected_objects: + removed = False if 'smb_collision' in obj: del obj['smb_collision'] + removed = True + if 'smb_collision_type' in obj: + del obj['smb_collision_type'] + removed = True + if removed: count += 1 self.report({'INFO'}, f"Removed collision tag from {count} object(s)") return {'FINISHED'} +class SMB_OT_set_collision_type(bpy.types.Operator): + """Set collision type for selected objects""" + bl_idname = "smb.set_collision_type" + bl_label = "Set Collision Type" + bl_description = "Set the collision type for selected objects" + bl_options = {'REGISTER', 'UNDO'} + + collision_type: bpy.props.EnumProperty( + name="Collision Type", + description="Type of collision behavior", + items=[ + ('SOLID', "Solid", "Standard solid collision"), + ('BREAKABLE', "Breakable", "Can be broken by hitting from below"), + ('SPRING', "Spring", "Bounces player upward"), + ('ENEMY', "Enemy", "Damages player on side contact, defeated by jumping on"), + ('WATER', "Water", "Enables swimming mode when entered"), + ('FIRE', "Fire", "Damages player on any contact"), + ('MOVING', "Moving Platform", "Moving platform that carries player"), + ('ONE_WAY', "One-Way", "Can pass through from below"), + ], + default='SOLID' + ) + + @classmethod + def poll(cls, context): + return context.selected_objects + + def execute(self, context): + count = 0 + for obj in context.selected_objects: + obj['smb_collision'] = True + obj['smb_collision_type'] = self.collision_type + count += 1 + self.report({'INFO'}, f"Set {count} object(s) to {self.collision_type} collision") + return {'FINISHED'} + + class SMB_PT_physics_panel(bpy.types.Panel): """Panel for SMB Physics controls""" bl_label = "Super Mario Bros. Physics" @@ -1056,6 +1333,7 @@ def draw(self, context): col.label(text=f"Grounded: {'Yes' if props.is_grounded else 'No'}") col.label(text=f"Jumping: {'Yes' if props.is_jumping else 'No'}") col.label(text=f"Running: {'Yes' if props.is_running else 'No'}") + col.label(text=f"Swimming: {'Yes' if props.is_swimming else 'No'}") # Show velocity box = layout.box() @@ -1070,14 +1348,14 @@ def draw(self, context): box.label(text="Controls:", icon='KEYTYPE_KEYFRAME_VEC') col = box.column(align=True) col.label(text="← → : Move Left/Right") - col.label(text="Space : Jump") + col.label(text="Space : Jump (or Swim Up)") col.label(text="Shift : Run") col.label(text="Esc : Stop") # Game mode indicator if props.block_shortcuts: box = layout.box() - box.label(text="🎮 GAME MODE ACTIVE", icon='GAME') + box.label(text="🎮 GAME MODE ACTIVE", icon='PLAY') box.label(text="Blender shortcuts blocked") else: layout.operator("smb.start_physics", text="Start Physics", icon='PLAY') @@ -1168,6 +1446,7 @@ def draw(self, context): ('smb_is_grounded', 'Is Grounded (0/1)'), ('smb_is_jumping', 'Is Jumping (0/1)'), ('smb_is_running', 'Is Running (0/1)'), + ('smb_is_swimming', 'Is Swimming (0/1)'), ('smb_is_moving_left', 'Moving Left (0/1)'), ('smb_is_moving_right', 'Moving Right (0/1)'), ('smb_facing_direction', 'Facing Direction (-1/1)'), @@ -1272,9 +1551,44 @@ def draw(self, context): box = layout.box() box.label(text="Collision Objects:", icon='MOD_PHYSICS') col = box.column(align=True) - col.operator("smb.tag_collision", icon='ADD') + col.operator("smb.tag_collision", text="Tag as Solid", icon='ADD') col.operator("smb.untag_collision", icon='REMOVE') + layout.separator() + + # Collision type buttons + box = layout.box() + box.label(text="Set Collision Type:", icon='PHYSICS') + col = box.column(align=True) + + # Row 1 + row = col.row(align=True) + op = row.operator("smb.set_collision_type", text="Solid") + op.collision_type = 'SOLID' + op = row.operator("smb.set_collision_type", text="One-Way") + op.collision_type = 'ONE_WAY' + + # Row 2 + row = col.row(align=True) + op = row.operator("smb.set_collision_type", text="Breakable") + op.collision_type = 'BREAKABLE' + op = row.operator("smb.set_collision_type", text="Spring") + op.collision_type = 'SPRING' + + # Row 3 + row = col.row(align=True) + op = row.operator("smb.set_collision_type", text="Enemy") + op.collision_type = 'ENEMY' + op = row.operator("smb.set_collision_type", text="Fire") + op.collision_type = 'FIRE' + + # Row 4 + row = col.row(align=True) + op = row.operator("smb.set_collision_type", text="Water") + op.collision_type = 'WATER' + op = row.operator("smb.set_collision_type", text="Moving") + op.collision_type = 'MOVING' + # List collision objects in scene layout.separator() box = layout.box() @@ -1285,10 +1599,20 @@ def draw(self, context): if obj.get('smb_collision', False) or 'smb_collision' in obj.name.lower(): collision_count += 1 row = box.row() - row.label(text=obj.name, icon='CUBE') + col_type = obj.get('smb_collision_type', 'SOLID') + row.label(text=f"{obj.name} [{col_type}]", icon='CUBE') if collision_count == 0: box.label(text="No collision objects", icon='INFO') + + # Swimming settings + layout.separator() + box = layout.box() + box.label(text="Swimming Settings:", icon='MOD_FLUID') + col = box.column(align=True) + col.prop(props, "swim_gravity") + col.prop(props, "swim_speed") + col.prop(props, "swim_acceleration") # Registration @@ -1302,6 +1626,7 @@ def draw(self, context): SMB_OT_delete_preset, SMB_OT_tag_collision, SMB_OT_untag_collision, + SMB_OT_set_collision_type, SMB_PT_physics_panel, SMB_PT_presets_panel, SMB_PT_driver_props_panel, From a033d52953f69fa58ce3f668ceb20bda57f3b870 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:04:13 +0000 Subject: [PATCH 08/13] Add is_running, is_skidding, is_falling properties for drivers Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/smb_physics.py b/smb_physics.py index cb5fb92..ea5a121 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -247,6 +247,8 @@ def update_player_custom_properties(obj, props): obj['smb_is_grounded'] = 1.0 if props.is_grounded else 0.0 obj['smb_is_jumping'] = 1.0 if props.is_jumping else 0.0 obj['smb_is_running'] = 1.0 if props.is_running else 0.0 + obj['smb_is_skidding'] = 1.0 if props.is_skidding else 0.0 + obj['smb_is_falling'] = 1.0 if props.is_falling else 0.0 obj['smb_is_swimming'] = 1.0 if props.is_swimming else 0.0 obj['smb_is_moving_left'] = 1.0 if props.input_left else 0.0 obj['smb_is_moving_right'] = 1.0 if props.input_right else 0.0 @@ -276,6 +278,8 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): is_jumping: bpy.props.BoolProperty(name="Is Jumping", default=False) jump_held: bpy.props.BoolProperty(name="Jump Held", default=False) is_running: bpy.props.BoolProperty(name="Is Running", default=False) + is_skidding: bpy.props.BoolProperty(name="Is Skidding", default=False) + is_falling: bpy.props.BoolProperty(name="Is Falling", default=False) # Input state (controlled via UI or keymap) input_left: bpy.props.BoolProperty(name="Move Left", default=False) @@ -847,6 +851,7 @@ def update_horizontal(props, velocity, delta_time): if direction != 0: # Check if skidding (moving opposite to velocity) is_skidding = (velocity > 0 and direction < 0) or (velocity < 0 and direction > 0) + props.is_skidding = is_skidding and props.is_grounded # Only skid when grounded if is_skidding: # Apply skid deceleration @@ -856,13 +861,17 @@ def update_horizontal(props, velocity, delta_time): else: velocity = min(0, velocity + decel) else: + # Not skidding - clear the flag + props.is_skidding = False # Apply acceleration velocity += direction * acceleration * delta_time # Clamp to max speed velocity = max(-max_speed, min(max_speed, velocity)) else: - # No input - apply friction + # No input - not skidding + props.is_skidding = False + # Apply friction if props.is_grounded: friction = props.friction * delta_time if velocity > 0: @@ -916,6 +925,11 @@ def update_vertical(props, velocity, delta_time): # Cap at terminal velocity velocity = max(-props.terminal_velocity, velocity) + + # Update is_falling (falling = not grounded and moving downward) + props.is_falling = velocity < 0 + else: + props.is_falling = False return velocity @@ -1333,6 +1347,8 @@ def draw(self, context): col.label(text=f"Grounded: {'Yes' if props.is_grounded else 'No'}") col.label(text=f"Jumping: {'Yes' if props.is_jumping else 'No'}") col.label(text=f"Running: {'Yes' if props.is_running else 'No'}") + col.label(text=f"Skidding: {'Yes' if props.is_skidding else 'No'}") + col.label(text=f"Falling: {'Yes' if props.is_falling else 'No'}") col.label(text=f"Swimming: {'Yes' if props.is_swimming else 'No'}") # Show velocity @@ -1446,6 +1462,8 @@ def draw(self, context): ('smb_is_grounded', 'Is Grounded (0/1)'), ('smb_is_jumping', 'Is Jumping (0/1)'), ('smb_is_running', 'Is Running (0/1)'), + ('smb_is_skidding', 'Is Skidding (0/1)'), + ('smb_is_falling', 'Is Falling (0/1)'), ('smb_is_swimming', 'Is Swimming (0/1)'), ('smb_is_moving_left', 'Moving Left (0/1)'), ('smb_is_moving_right', 'Moving Right (0/1)'), From 1f8bb17e89cd4ff20b2ac70e9a20527098adc157 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:19:26 +0000 Subject: [PATCH 09/13] Fix collision friction bug, add raycast, facing left/right properties Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 7 deletions(-) diff --git a/smb_physics.py b/smb_physics.py index ea5a121..23eb90e 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -74,6 +74,10 @@ # When holding jump: gravity is halved until peak or button release SMB_JUMP_GRAVITY_MULTIPLIER = 0.5 +# Collision detection constants +STANDING_TOLERANCE = 0.1 # Tolerance for detecting if player is standing on surface +RAYCAST_GROUND_DISTANCE = 0.2 # Max distance for raycast ground detection + # ============================================================================== # Built-in Physics Presets @@ -255,6 +259,8 @@ def update_player_custom_properties(obj, props): # Facing direction based on the configured forward axis horizontal_vel = props.velocity_x if props.forward_axis == 'X' else props.velocity_y obj['smb_facing_direction'] = 1.0 if horizontal_vel >= 0 else -1.0 + obj['smb_is_facing_left'] = 1.0 if horizontal_vel < 0 else 0.0 + obj['smb_is_facing_right'] = 1.0 if horizontal_vel >= 0 else 0.0 obj['smb_physics_active'] = 1.0 if props.is_active else 0.0 @@ -309,6 +315,12 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): min=0.0 ) + use_raycast_collision: bpy.props.BoolProperty( + name="Use Raycast Collision", + description="Use raycasting for more precise collision (slower but more accurate for complex shapes)", + default=False + ) + # Customizable physics values (defaulting to accurate SMB values) gravity: bpy.props.FloatProperty( name="Gravity", @@ -521,6 +533,62 @@ def check_in_water(context, player_obj, props): return True return False + @staticmethod + def check_standing_on(player_bounds, obstacle_bounds, up_axis): + """Check if player is standing on top of an obstacle (for grounded detection)""" + p_min_x, p_max_x, p_min_y, p_max_y, p_min_z, p_max_z = player_bounds + o_min_x, o_max_x, o_min_y, o_max_y, o_min_z, o_max_z = obstacle_bounds + + # Check horizontal overlap (must be overlapping in X and Y for standing) + if not (p_min_x <= o_max_x and p_max_x >= o_min_x): + return False + if not (p_min_y <= o_max_y and p_max_y >= o_min_y): + return False + + # Check if player bottom is near obstacle top + if up_axis == 'Z': + # Player's bottom should be at or near obstacle's top + return abs(p_min_z - o_max_z) < STANDING_TOLERANCE + else: # Y is up + return abs(p_min_y - o_max_y) < STANDING_TOLERANCE + + @staticmethod + def raycast_ground_check(context, obj, up_axis, max_distance=RAYCAST_GROUND_DISTANCE): + """ + Use raycasting to check if player is standing on ground. + More accurate for complex mesh shapes. + Returns: (is_grounded, ground_object, hit_location) + """ + from mathutils import Vector + + # Cast ray downward from player center + origin = obj.location.copy() + + # Ray direction is down along the up axis + if up_axis == 'Z': + direction = Vector((0, 0, -1)) + else: + direction = Vector((0, -1, 0)) + + # Use scene raycast + depsgraph = context.evaluated_depsgraph_get() + result, location, normal, index, hit_obj, matrix = context.scene.ray_cast( + depsgraph, origin, direction, distance=max_distance + ) + + if result and hit_obj: + # Check if hit object is a collision object (property check is authoritative) + if hit_obj.get('smb_collision', False): + return True, hit_obj, location + + return False, None, None + + @staticmethod + def get_collision_friction(obstacle): + """Get friction multiplier from collision object""" + # Default friction is 1.0 (normal friction) + return obstacle.get('smb_friction', 1.0) + @staticmethod def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up_axis, collision_type='SOLID'): """ @@ -690,19 +758,50 @@ def update_physics(context, delta_time): else: vertical_vel = props.velocity_y - # Check if grounded (will be updated by collision) + # Store last frame's grounded state for this frame's physics + # Will be updated by collision detection + was_grounded_last_frame = props.is_grounded + ground_friction_mult = 1.0 # Default friction multiplier + + # Check if grounded (initial check - will be updated by collision) if effective_up_axis == 'Z': props.is_grounded = pos_z <= props.ground_level + 0.01 else: props.is_grounded = pos_y <= props.ground_level + 0.01 + # Pre-check collision for grounded state BEFORE applying horizontal physics + # This fixes the friction bug when standing on collision objects + if props.enable_collision: + if props.use_raycast_collision: + # Use raycast for more precise ground detection + is_on_ground, ground_obj, hit_loc = SMBPhysicsEngine.raycast_ground_check( + context, obj, effective_up_axis + ) + if is_on_ground: + props.is_grounded = True + ground_friction_mult = SMBPhysicsEngine.get_collision_friction(ground_obj) + else: + # Use AABB for ground detection + collision_objects = SMBPhysicsEngine.get_collision_objects(context, obj) + for obstacle, collision_type in collision_objects: + if collision_type == 'WATER': + continue + player_bounds = SMBPhysicsEngine.get_object_bounds(obj) + obstacle_bounds = SMBPhysicsEngine.get_object_bounds(obstacle) + + # Check if player is standing on this object + if SMBPhysicsEngine.check_standing_on(player_bounds, obstacle_bounds, effective_up_axis): + props.is_grounded = True + ground_friction_mult = SMBPhysicsEngine.get_collision_friction(obstacle) + break + # Check if in water was_swimming = props.is_swimming props.is_swimming = SMBPhysicsEngine.check_in_water(context, obj, props) - # Horizontal movement (modified for swimming) + # Horizontal movement (now with correct grounded state for friction) horizontal_vel = SMBPhysicsEngine.update_horizontal( - props, horizontal_vel, delta_time + props, horizontal_vel, delta_time, ground_friction_mult ) # Vertical movement (jumping/gravity/swimming) @@ -806,7 +905,7 @@ def update_physics(context, delta_time): update_player_custom_properties(obj, props) @staticmethod - def update_horizontal(props, velocity, delta_time): + def update_horizontal(props, velocity, delta_time, friction_multiplier=1.0): """Update horizontal velocity based on input""" # Determine target direction direction = 0 @@ -871,9 +970,9 @@ def update_horizontal(props, velocity, delta_time): else: # No input - not skidding props.is_skidding = False - # Apply friction + # Apply friction (with surface friction multiplier) if props.is_grounded: - friction = props.friction * delta_time + friction = props.friction * friction_multiplier * delta_time if velocity > 0: velocity = max(0, velocity - friction) elif velocity < 0: @@ -1467,6 +1566,8 @@ def draw(self, context): ('smb_is_swimming', 'Is Swimming (0/1)'), ('smb_is_moving_left', 'Moving Left (0/1)'), ('smb_is_moving_right', 'Moving Right (0/1)'), + ('smb_is_facing_left', 'Facing Left (0/1)'), + ('smb_is_facing_right', 'Facing Right (0/1)'), ('smb_facing_direction', 'Facing Direction (-1/1)'), ('smb_physics_active', 'Physics Active (0/1)'), ] @@ -1562,6 +1663,7 @@ def draw(self, context): if props.enable_collision: layout.prop(props, "collision_padding") + layout.prop(props, "use_raycast_collision") layout.separator() @@ -1607,6 +1709,13 @@ def draw(self, context): op = row.operator("smb.set_collision_type", text="Moving") op.collision_type = 'MOVING' + # Surface friction info + layout.separator() + box = layout.box() + box.label(text="Surface Friction:", icon='FORCE_DRAG') + box.label(text="Set 'smb_friction' property on objects") + box.label(text="Default: 1.0 (normal), 0.5 (icy), 2.0 (sticky)") + # List collision objects in scene layout.separator() box = layout.box() @@ -1618,7 +1727,8 @@ def draw(self, context): collision_count += 1 row = box.row() col_type = obj.get('smb_collision_type', 'SOLID') - row.label(text=f"{obj.name} [{col_type}]", icon='CUBE') + friction = obj.get('smb_friction', 1.0) + row.label(text=f"{obj.name} [{col_type}] F:{friction:.1f}", icon='CUBE') if collision_count == 0: box.label(text="No collision objects", icon='INFO') From 592749f6c32c92a025d619ca27b392574c2b3226 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:37:41 +0000 Subject: [PATCH 10/13] Fix facing direction to persist when player stops moving Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/smb_physics.py b/smb_physics.py index 23eb90e..386702c 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -257,10 +257,20 @@ def update_player_custom_properties(obj, props): obj['smb_is_moving_left'] = 1.0 if props.input_left else 0.0 obj['smb_is_moving_right'] = 1.0 if props.input_right else 0.0 # Facing direction based on the configured forward axis + # Only update facing direction when there's actual movement horizontal_vel = props.velocity_x if props.forward_axis == 'X' else props.velocity_y - obj['smb_facing_direction'] = 1.0 if horizontal_vel >= 0 else -1.0 - obj['smb_is_facing_left'] = 1.0 if horizontal_vel < 0 else 0.0 - obj['smb_is_facing_right'] = 1.0 if horizontal_vel >= 0 else 0.0 + + # Update facing direction only when moving (velocity > threshold) + velocity_threshold = 0.01 # Small threshold to ignore near-zero velocity + if abs(horizontal_vel) > velocity_threshold: + # Update the persistent facing direction based on movement + props.last_facing_direction = 1.0 if horizontal_vel > 0 else -1.0 + + # Use the persistent facing direction for the properties + facing = props.last_facing_direction + obj['smb_facing_direction'] = facing + obj['smb_is_facing_left'] = 1.0 if facing < 0 else 0.0 + obj['smb_is_facing_right'] = 1.0 if facing > 0 else 0.0 obj['smb_physics_active'] = 1.0 if props.is_active else 0.0 @@ -287,6 +297,14 @@ class SMBPhysicsProperties(bpy.types.PropertyGroup): is_skidding: bpy.props.BoolProperty(name="Is Skidding", default=False) is_falling: bpy.props.BoolProperty(name="Is Falling", default=False) + # Persistent facing direction (1 = right, -1 = left) + # This persists when the player stops moving + last_facing_direction: bpy.props.FloatProperty( + name="Last Facing Direction", + description="Persistent facing direction (-1 = left, 1 = right)", + default=1.0 + ) + # Input state (controlled via UI or keymap) input_left: bpy.props.BoolProperty(name="Move Left", default=False) input_right: bpy.props.BoolProperty(name="Move Right", default=False) From cdb1c343b17a9c72b5022daf60ad136512d3f88b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:15:51 +0000 Subject: [PATCH 11/13] Fix collision issues: improved raycast with multi-ray, better grounded detection and ground snapping Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- README.md | 12 +++-- smb_physics.py | 118 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e1c0b32..5d95ecd 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,14 @@ smb_physics - Super Mario Bros. Physics addon that recreates accurate NES Super - Walking and running speeds with momentum - Skid deceleration when changing direction - Air control while jumping - - AABB collision detection with tagged objects (platforms, walls, blocks) - - Tag objects for collision using the Collision panel or by adding 'smb_collision' to object name - - Physics presets (SMB1, SMB3, Floaty, Tight) with ability to save/load custom presets - - Custom properties on player object for driver usage (velocity, grounded state, etc.) + - Collision detection with tagged objects: + - AABB mode: Fast axis-aligned bounding box collision + - Raycast mode: Multi-ray collision for complex shapes like stairs (5 rays from player base) + - Collision types: SOLID, BREAKABLE, SPRING, ENEMY, WATER, FIRE, MOVING, ONE_WAY + - Surface friction: Set 'smb_friction' property on objects (0.5=icy, 1.0=normal, 2.0=sticky) + - Swimming mode: Tag objects as WATER to create water zones + - Physics presets (SMB1, SMB2, SMB3, SMW, NSMB, Luigi styles) with save/load custom presets + - Custom properties on player object for driver usage (velocity, grounded, facing direction, etc.) - Game Mode: Block Blender shortcuts while physics is active for uninterrupted gameplay - Customizable physics parameters with reset to SMB defaults - Controls: Arrow keys (←→) to move, Space to jump, Shift to run, Esc to stop diff --git a/smb_physics.py b/smb_physics.py index 386702c..e16edae 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -75,8 +75,9 @@ SMB_JUMP_GRAVITY_MULTIPLIER = 0.5 # Collision detection constants -STANDING_TOLERANCE = 0.1 # Tolerance for detecting if player is standing on surface -RAYCAST_GROUND_DISTANCE = 0.2 # Max distance for raycast ground detection +STANDING_TOLERANCE = 0.15 # Tolerance for detecting if player is standing on surface +RAYCAST_GROUND_DISTANCE = 0.3 # Max distance for raycast ground detection +RAYCAST_GROUNDED_SNAP = 0.05 # Distance to snap player to ground when close # ============================================================================== @@ -559,47 +560,99 @@ def check_standing_on(player_bounds, obstacle_bounds, up_axis): # Check horizontal overlap (must be overlapping in X and Y for standing) if not (p_min_x <= o_max_x and p_max_x >= o_min_x): - return False + return False, 0 if not (p_min_y <= o_max_y and p_max_y >= o_min_y): - return False + return False, 0 - # Check if player bottom is near obstacle top + # Check if player bottom is near or at obstacle top if up_axis == 'Z': # Player's bottom should be at or near obstacle's top - return abs(p_min_z - o_max_z) < STANDING_TOLERANCE + distance = p_min_z - o_max_z + # Standing if player is on top (within tolerance) or slightly above + if distance >= -0.01 and distance < STANDING_TOLERANCE: + return True, distance else: # Y is up - return abs(p_min_y - o_max_y) < STANDING_TOLERANCE + distance = p_min_y - o_max_y + if distance >= -0.01 and distance < STANDING_TOLERANCE: + return True, distance + + return False, 0 @staticmethod def raycast_ground_check(context, obj, up_axis, max_distance=RAYCAST_GROUND_DISTANCE): """ Use raycasting to check if player is standing on ground. - More accurate for complex mesh shapes. - Returns: (is_grounded, ground_object, hit_location) + Casts multiple rays from player base for better detection on complex shapes like stairs. + Returns: (is_grounded, ground_object, hit_location, closest_distance) """ from mathutils import Vector - # Cast ray downward from player center - origin = obj.location.copy() + # Get player bounds for ray origins + bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] - # Ray direction is down along the up axis if up_axis == 'Z': + # Get the 4 bottom corners of the bounding box (lowest Z) + min_z = min(corner.z for corner in bbox_corners) + bottom_corners = [c for c in bbox_corners if abs(c.z - min_z) < 0.001] direction = Vector((0, 0, -1)) else: + # Get the 4 bottom corners of the bounding box (lowest Y) + min_y = min(corner.y for corner in bbox_corners) + bottom_corners = [c for c in bbox_corners if abs(c.y - min_y) < 0.001] direction = Vector((0, -1, 0)) + # Calculate ray origins: center + 4 corners (inset slightly) + center = obj.location.copy() + + # Get player size for inset calculation + if len(bottom_corners) >= 2: + size_x = max(c.x for c in bottom_corners) - min(c.x for c in bottom_corners) + size_y = max(c.y for c in bottom_corners) - min(c.y for c in bottom_corners) + inset = 0.1 # Inset from edges + + ray_origins = [ + center, # Center + center + Vector((size_x/2 - inset, 0, 0)), # Right + center + Vector((-size_x/2 + inset, 0, 0)), # Left + center + Vector((0, size_y/2 - inset, 0)), # Front + center + Vector((0, -size_y/2 + inset, 0)), # Back + ] + else: + ray_origins = [center] + # Use scene raycast depsgraph = context.evaluated_depsgraph_get() - result, location, normal, index, hit_obj, matrix = context.scene.ray_cast( - depsgraph, origin, direction, distance=max_distance - ) - if result and hit_obj: - # Check if hit object is a collision object (property check is authoritative) - if hit_obj.get('smb_collision', False): - return True, hit_obj, location + closest_hit = None + closest_distance = float('inf') + closest_obj = None - return False, None, None + for origin in ray_origins: + result, location, normal, index, hit_obj, matrix = context.scene.ray_cast( + depsgraph, origin, direction, distance=max_distance + ) + + if result and hit_obj: + # Check if hit object is a collision object + is_collision = (hit_obj.get('smb_collision', False) or + 'smb_collision' in hit_obj.name.lower()) + + if is_collision: + # Calculate distance from player bottom to hit + if up_axis == 'Z': + dist = origin.z - location.z + else: + dist = origin.y - location.y + + if dist < closest_distance: + closest_distance = dist + closest_hit = location + closest_obj = hit_obj + + if closest_obj is not None: + return True, closest_obj, closest_hit, closest_distance + + return False, None, None, float('inf') @staticmethod def get_collision_friction(obstacle): @@ -780,6 +833,7 @@ def update_physics(context, delta_time): # Will be updated by collision detection was_grounded_last_frame = props.is_grounded ground_friction_mult = 1.0 # Default friction multiplier + ground_snap_distance = 0.0 # Distance to snap player to ground # Check if grounded (initial check - will be updated by collision) if effective_up_axis == 'Z': @@ -791,13 +845,16 @@ def update_physics(context, delta_time): # This fixes the friction bug when standing on collision objects if props.enable_collision: if props.use_raycast_collision: - # Use raycast for more precise ground detection - is_on_ground, ground_obj, hit_loc = SMBPhysicsEngine.raycast_ground_check( + # Use raycast for more precise ground detection on complex shapes + is_on_ground, ground_obj, hit_loc, hit_distance = SMBPhysicsEngine.raycast_ground_check( context, obj, effective_up_axis ) - if is_on_ground: + if is_on_ground and hit_distance < STANDING_TOLERANCE: props.is_grounded = True ground_friction_mult = SMBPhysicsEngine.get_collision_friction(ground_obj) + # Snap player to ground if very close (prevents floating) + if hit_distance > RAYCAST_GROUNDED_SNAP and hit_distance < STANDING_TOLERANCE: + ground_snap_distance = hit_distance - RAYCAST_GROUNDED_SNAP else: # Use AABB for ground detection collision_objects = SMBPhysicsEngine.get_collision_objects(context, obj) @@ -808,9 +865,13 @@ def update_physics(context, delta_time): obstacle_bounds = SMBPhysicsEngine.get_object_bounds(obstacle) # Check if player is standing on this object - if SMBPhysicsEngine.check_standing_on(player_bounds, obstacle_bounds, effective_up_axis): + is_standing, distance = SMBPhysicsEngine.check_standing_on(player_bounds, obstacle_bounds, effective_up_axis) + if is_standing: props.is_grounded = True ground_friction_mult = SMBPhysicsEngine.get_collision_friction(obstacle) + # Snap player to surface if slightly above + if distance > RAYCAST_GROUNDED_SNAP and distance < STANDING_TOLERANCE: + ground_snap_distance = distance - RAYCAST_GROUNDED_SNAP break # Check if in water @@ -842,6 +903,15 @@ def update_physics(context, delta_time): pos_y += vertical_vel * delta_time props.velocity_y = vertical_vel + # Apply ground snap if player is grounded but slightly above surface + if props.is_grounded and ground_snap_distance > 0: + if effective_up_axis == 'Z': + pos_z -= ground_snap_distance + props.velocity_z = 0 + else: + pos_y -= ground_snap_distance + props.velocity_y = 0 + # Update position before collision detection obj.location.x = pos_x obj.location.y = pos_y From 396f63301fbfbf9709c648017bb4654d95104d46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:40:35 +0000 Subject: [PATCH 12/13] Changes before error encountered Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- smb_physics.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/smb_physics.py b/smb_physics.py index e16edae..2fb48c1 100644 --- a/smb_physics.py +++ b/smb_physics.py @@ -554,27 +554,34 @@ def check_in_water(context, player_obj, props): @staticmethod def check_standing_on(player_bounds, obstacle_bounds, up_axis): - """Check if player is standing on top of an obstacle (for grounded detection)""" + """Check if player is standing on top of an obstacle (for grounded detection) + + Returns: (is_standing, distance_to_ground) + - is_standing: True if player is on or very close to top of obstacle + - distance_to_ground: How far player bottom is from obstacle top (positive = above) + """ p_min_x, p_max_x, p_min_y, p_max_y, p_min_z, p_max_z = player_bounds o_min_x, o_max_x, o_min_y, o_max_y, o_min_z, o_max_z = obstacle_bounds # Check horizontal overlap (must be overlapping in X and Y for standing) - if not (p_min_x <= o_max_x and p_max_x >= o_min_x): + # Use a generous overlap check + if not (p_min_x < o_max_x and p_max_x > o_min_x): return False, 0 - if not (p_min_y <= o_max_y and p_max_y >= o_min_y): + if not (p_min_y < o_max_y and p_max_y > o_min_y): return False, 0 # Check if player bottom is near or at obstacle top if up_axis == 'Z': # Player's bottom should be at or near obstacle's top distance = p_min_z - o_max_z - # Standing if player is on top (within tolerance) or slightly above - if distance >= -0.01 and distance < STANDING_TOLERANCE: - return True, distance + # Standing if player is on top (within generous tolerance) + # Allow slight overlap (-0.05) and up to STANDING_TOLERANCE above + if distance >= -0.05 and distance < STANDING_TOLERANCE: + return True, max(0, distance) else: # Y is up distance = p_min_y - o_max_y - if distance >= -0.01 and distance < STANDING_TOLERANCE: - return True, distance + if distance >= -0.05 and distance < STANDING_TOLERANCE: + return True, max(0, distance) return False, 0 From 1ef87b34e9872369b285d2259706e2ba02702d8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:51:50 +0000 Subject: [PATCH 13/13] Add Super Mario Bros. and Sonic The Hedgehog physics addons with collision, presets, and driver support Co-authored-by: ExtCan <60326708+ExtCan@users.noreply.github.com> --- sonic_physics.py | 1188 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1188 insertions(+) create mode 100644 sonic_physics.py diff --git a/sonic_physics.py b/sonic_physics.py new file mode 100644 index 0000000..eabc067 --- /dev/null +++ b/sonic_physics.py @@ -0,0 +1,1188 @@ +# Sonic The Hedgehog Physics Addon for Blender +# Recreates accurate Sonic physics for selected object +# The selected object will be considered as the PLAYER + +import bpy +import time +import json +import os +from mathutils import Vector +from bpy.app.handlers import persistent + +bl_info = { + "name": "Sonic The Hedgehog Physics", + "author": "Pink", + "version": (1, 0), + "blender": (2, 80, 0), + "location": "View3D > Sidebar > Sonic Physics", + "description": "Recreates accurate Sonic The Hedgehog physics for the selected object (PLAYER)", + "category": "Physics", +} + +# ============================================================================== +# Sonic The Hedgehog Physics Constants +# All values are derived from the original Sega Genesis Sonic games +# Original game runs at 60fps with specific subpixel physics +# +# Reference: Sonic Physics Guide (SPG) +# https://info.sonicretro.org/Sonic_Physics_Guide +# ============================================================================== + +# Scale factor to convert Genesis pixel units to Blender units +PIXEL_TO_BLENDER = 0.0625 # 1 pixel = 0.0625 Blender units (1/16) + +# Gravity - Sonic uses 0x00038 per frame (0.21875 pixels/frame²) +SONIC_GRAVITY = 0.21875 * PIXEL_TO_BLENDER * 60 * 60 + +# Air drag threshold - Sonic experiences air drag when Y velocity < -4 and X speed >= 0.125 +SONIC_AIR_DRAG_THRESHOLD = 4.0 * PIXEL_TO_BLENDER * 60 + +# Jump force - Initial jump velocity is 6.5 pixels/frame +SONIC_JUMP_VELOCITY = 6.5 * PIXEL_TO_BLENDER * 60 + +# Horizontal movement speeds (in pixels/frame) +# Top speed: 6 pixels/frame (normal), 12 pixels/frame (Speed Shoes/Super) +SONIC_TOP_SPEED = 6.0 * PIXEL_TO_BLENDER * 60 +SONIC_TOP_SPEED_SUPER = 10.0 * PIXEL_TO_BLENDER * 60 + +# Acceleration: 0.046875 pixels/frame² +SONIC_ACCELERATION = 0.046875 * PIXEL_TO_BLENDER * 60 * 60 + +# Deceleration: 0.5 pixels/frame² (when pressing opposite direction) +SONIC_DECELERATION = 0.5 * PIXEL_TO_BLENDER * 60 * 60 + +# Friction: 0.046875 pixels/frame² (same as acceleration) +SONIC_FRICTION = 0.046875 * PIXEL_TO_BLENDER * 60 * 60 + +# Air acceleration: 0.09375 pixels/frame² (2x ground acceleration) +SONIC_AIR_ACCELERATION = 0.09375 * PIXEL_TO_BLENDER * 60 * 60 + +# Rolling friction: 0.0234375 pixels/frame² (half of normal friction) +SONIC_ROLL_FRICTION = 0.0234375 * PIXEL_TO_BLENDER * 60 * 60 + +# Rolling deceleration: 0.125 pixels/frame² +SONIC_ROLL_DECEL = 0.125 * PIXEL_TO_BLENDER * 60 * 60 + +# Slope factor (affects speed on slopes) +# Normal: 0.125 pixels/frame² when walking/running +# Rolling uphill: 0.078125, Rolling downhill: 0.3125 +SONIC_SLOPE_FACTOR = 0.125 * PIXEL_TO_BLENDER * 60 * 60 +SONIC_SLOPE_ROLL_UP = 0.078125 * PIXEL_TO_BLENDER * 60 * 60 +SONIC_SLOPE_ROLL_DOWN = 0.3125 * PIXEL_TO_BLENDER * 60 * 60 + +# Spin dash power levels (8 levels, from 8 to 12 pixels/frame) +SONIC_SPINDASH_MIN = 8.0 * PIXEL_TO_BLENDER * 60 +SONIC_SPINDASH_MAX = 12.0 * PIXEL_TO_BLENDER * 60 + +# Terminal velocity (max falling speed) - typically around 16 pixels/frame +SONIC_TERMINAL_VELOCITY = 16.0 * PIXEL_TO_BLENDER * 60 + +# Variable jump - releasing jump button caps upward velocity at 4 pixels/frame +SONIC_JUMP_RELEASE_SPEED = 4.0 * PIXEL_TO_BLENDER * 60 + +# Collision detection constants +STANDING_TOLERANCE = 0.15 +RAYCAST_GROUND_DISTANCE = 0.3 +RAYCAST_GROUNDED_SNAP = 0.05 + + +# ============================================================================== +# Built-in Physics Presets +# ============================================================================== +PHYSICS_PRESETS = { + 'SONIC1': { + 'name': 'Sonic 1', + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY, + 'top_speed': SONIC_TOP_SPEED, + 'acceleration': SONIC_ACCELERATION, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'SONIC2': { + 'name': 'Sonic 2', + # Sonic 2 has spin dash and slightly different physics + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY, + 'top_speed': SONIC_TOP_SPEED * 1.1, + 'acceleration': SONIC_ACCELERATION * 1.1, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'SONIC3': { + 'name': 'Sonic 3 & Knuckles', + # S3K has instashield and slightly tighter controls + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 1.05, + 'top_speed': SONIC_TOP_SPEED, + 'acceleration': SONIC_ACCELERATION * 1.1, + 'deceleration': SONIC_DECELERATION * 1.1, + 'friction': SONIC_FRICTION * 1.1, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'SONIC_CD': { + 'name': 'Sonic CD', + # CD has figure-8 peel-out, slightly floatier + 'gravity': SONIC_GRAVITY * 0.95, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 1.1, + 'top_speed': SONIC_TOP_SPEED * 1.05, + 'acceleration': SONIC_ACCELERATION, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION * 0.9, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'TAILS': { + 'name': 'Tails', + # Tails can fly, slightly slower + 'gravity': SONIC_GRAVITY * 0.9, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY * 0.8, + 'jump_velocity': SONIC_JUMP_VELOCITY, + 'top_speed': SONIC_TOP_SPEED * 0.9, + 'acceleration': SONIC_ACCELERATION, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'KNUCKLES': { + 'name': 'Knuckles', + # Knuckles can glide and climb, lower jump + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 0.85, + 'top_speed': SONIC_TOP_SPEED, + 'acceleration': SONIC_ACCELERATION, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'AMY': { + 'name': 'Amy Rose', + # Amy is slower but has hammer + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 0.9, + 'top_speed': SONIC_TOP_SPEED * 0.8, + 'acceleration': SONIC_ACCELERATION * 0.9, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION * 0.9, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'SUPER_SONIC': { + 'name': 'Super Sonic', + # Super Sonic has double jump height and speed + 'gravity': SONIC_GRAVITY * 0.8, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 1.5, + 'top_speed': SONIC_TOP_SPEED_SUPER, + 'acceleration': SONIC_ACCELERATION * 2.0, + 'deceleration': SONIC_DECELERATION * 2.0, + 'friction': SONIC_FRICTION * 0.75, + 'air_acceleration': SONIC_AIR_ACCELERATION * 2.0, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, + 'SONIC_MANIA': { + 'name': 'Sonic Mania', + # Modern classic physics + 'gravity': SONIC_GRAVITY, + 'terminal_velocity': SONIC_TERMINAL_VELOCITY, + 'jump_velocity': SONIC_JUMP_VELOCITY * 1.05, + 'top_speed': SONIC_TOP_SPEED * 1.1, + 'acceleration': SONIC_ACCELERATION * 1.15, + 'deceleration': SONIC_DECELERATION, + 'friction': SONIC_FRICTION, + 'air_acceleration': SONIC_AIR_ACCELERATION, + 'roll_friction': SONIC_ROLL_FRICTION, + 'roll_deceleration': SONIC_ROLL_DECEL, + }, +} + + +def get_presets_directory(): + """Get the directory for storing custom presets""" + config_dir = bpy.utils.user_resource('CONFIG') + presets_dir = os.path.join(config_dir, 'sonic_physics_presets') + if not os.path.exists(presets_dir): + os.makedirs(presets_dir) + return presets_dir + + +def get_custom_presets(): + """Load all custom presets from the presets directory""" + presets = {} + presets_dir = get_presets_directory() + if os.path.exists(presets_dir): + for filename in os.listdir(presets_dir): + if filename.endswith('.json'): + filepath = os.path.join(presets_dir, filename) + try: + with open(filepath, 'r') as f: + preset = json.load(f) + preset_name = filename[:-5] + presets[preset_name] = preset + except (json.JSONDecodeError, IOError): + pass + return presets + + +def update_player_custom_properties(obj, props): + """Update custom properties on the player object for driver usage""" + if obj is None: + return + + # Physics state properties + obj['sonic_velocity_x'] = props.velocity_x + obj['sonic_velocity_y'] = props.velocity_y + obj['sonic_velocity_z'] = props.velocity_z + obj['sonic_ground_speed'] = props.ground_speed + obj['sonic_speed'] = (props.velocity_x ** 2 + props.velocity_y ** 2 + props.velocity_z ** 2) ** 0.5 + obj['sonic_is_grounded'] = 1.0 if props.is_grounded else 0.0 + obj['sonic_is_jumping'] = 1.0 if props.is_jumping else 0.0 + obj['sonic_is_rolling'] = 1.0 if props.is_rolling else 0.0 + obj['sonic_is_spindashing'] = 1.0 if props.is_spindashing else 0.0 + obj['sonic_is_falling'] = 1.0 if props.is_falling else 0.0 + obj['sonic_spindash_charge'] = props.spindash_charge + + # Facing direction + horizontal_vel = props.velocity_x if props.forward_axis == 'X' else props.velocity_y + velocity_threshold = 0.01 + if abs(horizontal_vel) > velocity_threshold: + props.last_facing_direction = 1.0 if horizontal_vel > 0 else -1.0 + + facing = props.last_facing_direction + obj['sonic_facing_direction'] = facing + obj['sonic_is_facing_left'] = 1.0 if facing < 0 else 0.0 + obj['sonic_is_facing_right'] = 1.0 if facing > 0 else 0.0 + obj['sonic_physics_active'] = 1.0 if props.is_active else 0.0 + + +class SonicPhysicsProperties(bpy.types.PropertyGroup): + """Properties for Sonic Physics simulation""" + + is_active: bpy.props.BoolProperty(name="Physics Active", default=False) + + # Velocity + velocity_x: bpy.props.FloatProperty(name="Velocity X", default=0.0) + velocity_y: bpy.props.FloatProperty(name="Velocity Y", default=0.0) + velocity_z: bpy.props.FloatProperty(name="Velocity Z", default=0.0) + ground_speed: bpy.props.FloatProperty(name="Ground Speed", default=0.0) + + # State + is_grounded: bpy.props.BoolProperty(name="Is Grounded", default=True) + is_jumping: bpy.props.BoolProperty(name="Is Jumping", default=False) + is_rolling: bpy.props.BoolProperty(name="Is Rolling", default=False) + is_spindashing: bpy.props.BoolProperty(name="Is Spindashing", default=False) + is_falling: bpy.props.BoolProperty(name="Is Falling", default=False) + jump_held: bpy.props.BoolProperty(name="Jump Held", default=False) + spindash_charge: bpy.props.FloatProperty(name="Spindash Charge", default=0.0, min=0.0, max=1.0) + + last_facing_direction: bpy.props.FloatProperty(name="Last Facing Direction", default=1.0) + + # Input + input_left: bpy.props.BoolProperty(name="Move Left", default=False) + input_right: bpy.props.BoolProperty(name="Move Right", default=False) + input_jump: bpy.props.BoolProperty(name="Jump", default=False) + input_down: bpy.props.BoolProperty(name="Down (Roll/Spindash)", default=False) + + # Ground level + ground_level: bpy.props.FloatProperty(name="Ground Level", default=0.0, unit='LENGTH') + + # Collision settings + enable_collision: bpy.props.BoolProperty(name="Enable Collision", default=True) + collision_padding: bpy.props.FloatProperty(name="Collision Padding", default=0.01, min=0.0) + use_raycast_collision: bpy.props.BoolProperty(name="Use Raycast Collision", default=False) + + # Physics values + gravity: bpy.props.FloatProperty(name="Gravity", default=SONIC_GRAVITY, min=0.0) + terminal_velocity: bpy.props.FloatProperty(name="Terminal Velocity", default=SONIC_TERMINAL_VELOCITY, min=0.0) + jump_velocity: bpy.props.FloatProperty(name="Jump Velocity", default=SONIC_JUMP_VELOCITY) + top_speed: bpy.props.FloatProperty(name="Top Speed", default=SONIC_TOP_SPEED, min=0.0) + acceleration: bpy.props.FloatProperty(name="Acceleration", default=SONIC_ACCELERATION, min=0.0) + deceleration: bpy.props.FloatProperty(name="Deceleration", default=SONIC_DECELERATION, min=0.0) + friction: bpy.props.FloatProperty(name="Friction", default=SONIC_FRICTION, min=0.0) + air_acceleration: bpy.props.FloatProperty(name="Air Acceleration", default=SONIC_AIR_ACCELERATION, min=0.0) + roll_friction: bpy.props.FloatProperty(name="Roll Friction", default=SONIC_ROLL_FRICTION, min=0.0) + roll_deceleration: bpy.props.FloatProperty(name="Roll Deceleration", default=SONIC_ROLL_DECEL, min=0.0) + + # Axis settings + forward_axis: bpy.props.EnumProperty( + name="Forward Axis", + items=[('X', "X Axis", "Move along X axis"), ('Y', "Y Axis", "Move along Y axis")], + default='X' + ) + up_axis: bpy.props.EnumProperty( + name="Up Axis", + items=[('Y', "Y Axis", "Jump along Y axis"), ('Z', "Z Axis", "Jump along Z axis")], + default='Z' + ) + + preset_name: bpy.props.StringProperty(name="Preset Name", default="My Preset") + block_shortcuts: bpy.props.BoolProperty(name="Block Shortcuts", default=True) + + +# Collision types +COLLISION_TYPES = { + 'SOLID': 'Standard solid collision', + 'SPRING_UP': 'Launches Sonic upward', + 'SPRING_SIDE': 'Launches Sonic horizontally', + 'RING': 'Collectible ring', + 'SPIKES': 'Damages Sonic', + 'BUMPER': 'Bounces Sonic away', + 'LOOP': 'Loop path trigger', + 'CHECKPOINT': 'Checkpoint marker', +} + + +class SonicPhysicsEngine: + """Core physics engine implementing Sonic physics""" + + @staticmethod + def get_object_bounds(obj): + """Get AABB for an object in world space""" + bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + min_x = min(corner.x for corner in bbox_corners) + max_x = max(corner.x for corner in bbox_corners) + min_y = min(corner.y for corner in bbox_corners) + max_y = max(corner.y for corner in bbox_corners) + min_z = min(corner.z for corner in bbox_corners) + max_z = max(corner.z for corner in bbox_corners) + return (min_x, max_x, min_y, max_y, min_z, max_z) + + @staticmethod + def check_aabb_collision(bounds1, bounds2): + """Check if two AABBs are colliding""" + min_x1, max_x1, min_y1, max_y1, min_z1, max_z1 = bounds1 + min_x2, max_x2, min_y2, max_y2, min_z2, max_z2 = bounds2 + return (min_x1 <= max_x2 and max_x1 >= min_x2 and + min_y1 <= max_y2 and max_y1 >= min_y2 and + min_z1 <= max_z2 and max_z1 >= min_z2) + + @staticmethod + def get_collision_objects(context, player_obj): + """Get all objects tagged for collision""" + collision_objects = [] + for obj in context.scene.objects: + if obj == player_obj: + continue + if 'sonic_collision' in obj.name.lower() or obj.get('sonic_collision', False): + collision_type = obj.get('sonic_collision_type', 'SOLID') + collision_objects.append((obj, collision_type)) + return collision_objects + + @staticmethod + def resolve_collision(player_bounds, obstacle_bounds, velocity, forward_axis, up_axis, collision_type='SOLID'): + """Resolve collision between player and obstacle""" + p_min_x, p_max_x, p_min_y, p_max_y, p_min_z, p_max_z = player_bounds + o_min_x, o_max_x, o_min_y, o_max_y, o_min_z, o_max_z = obstacle_bounds + + vel_x, vel_y, vel_z = velocity + offset_x, offset_y, offset_z = 0.0, 0.0, 0.0 + is_grounded = False + special_action = None + + overlap_x = min(p_max_x - o_min_x, o_max_x - p_min_x) + overlap_y = min(p_max_y - o_min_y, o_max_y - p_min_y) + overlap_z = min(p_max_z - o_min_z, o_max_z - p_min_z) + + if up_axis == 'Z': + player_center_z = (p_min_z + p_max_z) / 2 + obstacle_center_z = (o_min_z + o_max_z) / 2 + is_above = player_center_z > obstacle_center_z + + if collision_type == 'SPRING_UP': + if is_above: + vel_z = 16.0 * PIXEL_TO_BLENDER * 60 + special_action = 'spring' + return (0, 0, 0), (vel_x, vel_y, vel_z), False, special_action + elif collision_type == 'SPIKES': + special_action = 'damage' + elif collision_type == 'RING': + special_action = 'collect_ring' + return (0, 0, 0), velocity, False, special_action + elif collision_type == 'BUMPER': + # Bounce away from bumper + vel_x = -vel_x * 1.5 + vel_z = abs(vel_z) * 1.2 if vel_z < 0 else vel_z + special_action = 'bumper' + return (0, 0, 0), (vel_x, vel_y, vel_z), False, special_action + + if overlap_z <= overlap_x and overlap_z <= overlap_y: + if is_above: + offset_z = o_max_z - p_min_z + if vel_z < 0: + vel_z = 0 + is_grounded = True + else: + offset_z = o_min_z - p_max_z + if vel_z > 0: + vel_z = 0 + else: + if forward_axis == 'X': + player_center_x = (p_min_x + p_max_x) / 2 + obstacle_center_x = (o_min_x + o_max_x) / 2 + if player_center_x > obstacle_center_x: + offset_x = o_max_x - p_min_x + else: + offset_x = o_min_x - p_max_x + vel_x = 0 + else: + player_center_y = (p_min_y + p_max_y) / 2 + obstacle_center_y = (o_min_y + o_max_y) / 2 + if player_center_y > obstacle_center_y: + offset_y = o_max_y - p_min_y + else: + offset_y = o_min_y - p_max_y + vel_y = 0 + + return (offset_x, offset_y, offset_z), (vel_x, vel_y, vel_z), is_grounded, special_action + + @staticmethod + def update_physics(context, delta_time): + """Update physics for one frame""" + obj = context.active_object + if not obj: + return + + props = context.scene.sonic_physics_props + if not props.is_active: + return + + pos_x = obj.location.x + pos_y = obj.location.y + pos_z = obj.location.z + + effective_up_axis = props.up_axis + if props.forward_axis == 'Y' and props.up_axis == 'Y': + effective_up_axis = 'Z' + + # Ground check + if effective_up_axis == 'Z': + props.is_grounded = pos_z <= props.ground_level + 0.01 + else: + props.is_grounded = pos_y <= props.ground_level + 0.01 + + # Horizontal movement + horizontal_vel = SonicPhysicsEngine.update_horizontal(props, delta_time) + + # Vertical movement + vertical_vel = SonicPhysicsEngine.update_vertical(props, delta_time) + + # Apply velocities + if props.forward_axis == 'X': + pos_x += horizontal_vel * delta_time + props.velocity_x = horizontal_vel + else: + pos_y += horizontal_vel * delta_time + props.velocity_y = horizontal_vel + + if effective_up_axis == 'Z': + pos_z += vertical_vel * delta_time + props.velocity_z = vertical_vel + else: + pos_y += vertical_vel * delta_time + props.velocity_y = vertical_vel + + obj.location.x = pos_x + obj.location.y = pos_y + obj.location.z = pos_z + + # Collision detection + if props.enable_collision: + collision_objects = SonicPhysicsEngine.get_collision_objects(context, obj) + padding = props.collision_padding + + for obstacle, collision_type in collision_objects: + player_bounds = SonicPhysicsEngine.get_object_bounds(obj) + obstacle_bounds = SonicPhysicsEngine.get_object_bounds(obstacle) + + player_bounds = ( + player_bounds[0] + padding, player_bounds[1] - padding, + player_bounds[2] + padding, player_bounds[3] - padding, + player_bounds[4] + padding, player_bounds[5] - padding + ) + + if SonicPhysicsEngine.check_aabb_collision(player_bounds, obstacle_bounds): + vel = (props.velocity_x, props.velocity_y, props.velocity_z) + offset, new_vel, is_grounded, special_action = SonicPhysicsEngine.resolve_collision( + player_bounds, obstacle_bounds, vel, + props.forward_axis, effective_up_axis, collision_type + ) + + if special_action == 'collect_ring': + obstacle['sonic_collected'] = True + obstacle.hide_viewport = True + obstacle.hide_render = True + + obj.location.x += offset[0] + obj.location.y += offset[1] + obj.location.z += offset[2] + + props.velocity_x = new_vel[0] + props.velocity_y = new_vel[1] + props.velocity_z = new_vel[2] + + if is_grounded: + props.is_grounded = True + props.is_jumping = False + + # Ground plane collision + if effective_up_axis == 'Z': + if obj.location.z < props.ground_level: + obj.location.z = props.ground_level + props.velocity_z = 0 + props.is_grounded = True + props.is_jumping = False + props.is_rolling = False + else: + if obj.location.y < props.ground_level: + obj.location.y = props.ground_level + props.velocity_y = 0 + props.is_grounded = True + props.is_jumping = False + props.is_rolling = False + + update_player_custom_properties(obj, props) + + @staticmethod + def update_horizontal(props, delta_time): + """Update horizontal velocity""" + velocity = props.velocity_x if props.forward_axis == 'X' else props.velocity_y + + direction = 0 + if props.input_left: + direction -= 1 + if props.input_right: + direction += 1 + + # Spindash logic + if props.is_spindashing: + if props.input_jump: + # Charge spindash + props.spindash_charge = min(1.0, props.spindash_charge + delta_time * 2) + else: + # Release spindash + release_speed = SONIC_SPINDASH_MIN + (SONIC_SPINDASH_MAX - SONIC_SPINDASH_MIN) * props.spindash_charge + velocity = release_speed * props.last_facing_direction + props.is_spindashing = False + props.is_rolling = True + props.spindash_charge = 0 + return velocity + + # Start spindash if grounded, pressing down, and pressing jump + if props.is_grounded and props.input_down and props.input_jump and abs(velocity) < 0.5: + props.is_spindashing = True + props.spindash_charge = 0 + return 0 + + # Rolling + if props.is_grounded and props.input_down and abs(velocity) > props.top_speed * 0.3: + props.is_rolling = True + + if props.is_rolling: + if props.is_grounded: + # Apply roll friction + friction = props.roll_friction * delta_time + if velocity > 0: + velocity = max(0, velocity - friction) + elif velocity < 0: + velocity = min(0, velocity + friction) + + # Stop rolling if too slow + if abs(velocity) < 0.5: + props.is_rolling = False + return velocity + + # Normal movement + if props.is_grounded: + if direction != 0: + # Check if changing direction (skidding) + if (velocity > 0 and direction < 0) or (velocity < 0 and direction > 0): + # Deceleration + velocity += direction * props.deceleration * delta_time + else: + # Acceleration + velocity += direction * props.acceleration * delta_time + + # Clamp to top speed + velocity = max(-props.top_speed, min(props.top_speed, velocity)) + else: + # Apply friction + friction = props.friction * delta_time + if velocity > 0: + velocity = max(0, velocity - friction) + elif velocity < 0: + velocity = min(0, velocity + friction) + else: + # Air control (2x ground acceleration) + if direction != 0: + velocity += direction * props.air_acceleration * delta_time + velocity = max(-props.top_speed, min(props.top_speed, velocity)) + + props.ground_speed = abs(velocity) + return velocity + + @staticmethod + def update_vertical(props, delta_time): + """Update vertical velocity""" + velocity = props.velocity_z if props.up_axis == 'Z' else props.velocity_y + + # Handle jump + if props.input_jump and props.is_grounded and not props.is_jumping and not props.is_spindashing: + props.is_jumping = True + props.jump_held = True + velocity = props.jump_velocity + if props.is_rolling: + props.is_rolling = False # Jump out of roll + + # Variable jump height - releasing jump caps upward velocity + if not props.input_jump: + if props.jump_held and velocity > SONIC_JUMP_RELEASE_SPEED: + velocity = SONIC_JUMP_RELEASE_SPEED + props.jump_held = False + + # Apply gravity + if not props.is_grounded: + velocity -= props.gravity * delta_time + velocity = max(-props.terminal_velocity, velocity) + props.is_falling = velocity < 0 + else: + props.is_falling = False + + return velocity + + +class SONIC_OT_start_physics(bpy.types.Operator): + """Start Sonic Physics Simulation""" + bl_idname = "sonic.start_physics" + bl_label = "Start Physics" + bl_options = {'REGISTER', 'UNDO'} + + _timer = None + _last_time = None + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def modal(self, context, event): + props = context.scene.sonic_physics_props + + if not props.is_active: + self.cancel(context) + return {'CANCELLED'} + + if event.type == 'TIMER': + current_time = time.time() + if self._last_time is not None: + delta_time = min(current_time - self._last_time, 0.1) + SonicPhysicsEngine.update_physics(context, delta_time) + self._last_time = current_time + + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + # Input handling + handled = False + if event.type == 'LEFT_ARROW': + props.input_left = event.value == 'PRESS' + handled = True + elif event.type == 'RIGHT_ARROW': + props.input_right = event.value == 'PRESS' + handled = True + elif event.type == 'SPACE': + props.input_jump = event.value == 'PRESS' + handled = True + elif event.type == 'DOWN_ARROW': + props.input_down = event.value == 'PRESS' + handled = True + elif event.type in {'ESC'}: + props.is_active = False + self.cancel(context) + return {'CANCELLED'} + + if props.block_shortcuts: + if event.type in {'TIMER', 'MOUSEMOVE', 'INBETWEEN_MOUSEMOVE', + 'LEFTMOUSE', 'RIGHTMOUSE', 'MIDDLEMOUSE', + 'WHEELUPMOUSE', 'WHEELDOWNMOUSE', + 'WINDOW_DEACTIVATE', 'NONE'}: + return {'PASS_THROUGH'} + if event.type not in {'LEFT_ARROW', 'RIGHT_ARROW', 'SPACE', 'DOWN_ARROW', 'ESC'}: + return {'RUNNING_MODAL'} + if handled: + return {'RUNNING_MODAL'} + + return {'PASS_THROUGH'} + + def execute(self, context): + props = context.scene.sonic_physics_props + + if props.is_active: + props.is_active = False + return {'CANCELLED'} + + # Reset + props.velocity_x = 0 + props.velocity_y = 0 + props.velocity_z = 0 + props.ground_speed = 0 + props.is_jumping = False + props.is_rolling = False + props.is_spindashing = False + props.is_grounded = True + props.spindash_charge = 0 + props.input_left = False + props.input_right = False + props.input_jump = False + props.input_down = False + + obj = context.active_object + if props.up_axis == 'Z': + props.ground_level = obj.location.z + else: + props.ground_level = obj.location.y + + update_player_custom_properties(obj, props) + props.is_active = True + + wm = context.window_manager + self._timer = wm.event_timer_add(1.0 / 60.0, window=context.window) + self._last_time = None + wm.modal_handler_add(self) + + return {'RUNNING_MODAL'} + + def cancel(self, context): + props = context.scene.sonic_physics_props + props.is_active = False + props.input_left = False + props.input_right = False + props.input_jump = False + props.input_down = False + + obj = context.active_object + if obj: + update_player_custom_properties(obj, props) + + wm = context.window_manager + if self._timer: + wm.event_timer_remove(self._timer) + + +class SONIC_OT_stop_physics(bpy.types.Operator): + """Stop Sonic Physics Simulation""" + bl_idname = "sonic.stop_physics" + bl_label = "Stop Physics" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + context.scene.sonic_physics_props.is_active = False + return {'FINISHED'} + + +class SONIC_OT_reset_to_defaults(bpy.types.Operator): + """Reset physics values to Sonic 1 defaults""" + bl_idname = "sonic.reset_defaults" + bl_label = "Reset to Sonic 1 Defaults" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.sonic_physics_props + props.gravity = SONIC_GRAVITY + props.terminal_velocity = SONIC_TERMINAL_VELOCITY + props.jump_velocity = SONIC_JUMP_VELOCITY + props.top_speed = SONIC_TOP_SPEED + props.acceleration = SONIC_ACCELERATION + props.deceleration = SONIC_DECELERATION + props.friction = SONIC_FRICTION + props.air_acceleration = SONIC_AIR_ACCELERATION + props.roll_friction = SONIC_ROLL_FRICTION + props.roll_deceleration = SONIC_ROLL_DECEL + self.report({'INFO'}, "Physics values reset to Sonic 1 defaults") + return {'FINISHED'} + + +class SONIC_OT_load_preset(bpy.types.Operator): + """Load a physics preset""" + bl_idname = "sonic.load_preset" + bl_label = "Load Preset" + bl_options = {'REGISTER', 'UNDO'} + + preset_key: bpy.props.StringProperty(name="Preset Key") + + def execute(self, context): + props = context.scene.sonic_physics_props + + if self.preset_key in PHYSICS_PRESETS: + preset = PHYSICS_PRESETS[self.preset_key] + else: + custom_presets = get_custom_presets() + if self.preset_key in custom_presets: + preset = custom_presets[self.preset_key] + else: + self.report({'ERROR'}, f"Preset '{self.preset_key}' not found") + return {'CANCELLED'} + + props.gravity = preset.get('gravity', SONIC_GRAVITY) + props.terminal_velocity = preset.get('terminal_velocity', SONIC_TERMINAL_VELOCITY) + props.jump_velocity = preset.get('jump_velocity', SONIC_JUMP_VELOCITY) + props.top_speed = preset.get('top_speed', SONIC_TOP_SPEED) + props.acceleration = preset.get('acceleration', SONIC_ACCELERATION) + props.deceleration = preset.get('deceleration', SONIC_DECELERATION) + props.friction = preset.get('friction', SONIC_FRICTION) + props.air_acceleration = preset.get('air_acceleration', SONIC_AIR_ACCELERATION) + props.roll_friction = preset.get('roll_friction', SONIC_ROLL_FRICTION) + props.roll_deceleration = preset.get('roll_deceleration', SONIC_ROLL_DECEL) + + self.report({'INFO'}, f"Loaded preset: {preset.get('name', self.preset_key)}") + return {'FINISHED'} + + +class SONIC_OT_save_preset(bpy.types.Operator): + """Save current physics settings as a preset""" + bl_idname = "sonic.save_preset" + bl_label = "Save Preset" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + props = context.scene.sonic_physics_props + preset_name = props.preset_name.strip() + if not preset_name: + self.report({'ERROR'}, "Please enter a preset name") + return {'CANCELLED'} + + preset = { + 'name': preset_name, + 'gravity': props.gravity, + 'terminal_velocity': props.terminal_velocity, + 'jump_velocity': props.jump_velocity, + 'top_speed': props.top_speed, + 'acceleration': props.acceleration, + 'deceleration': props.deceleration, + 'friction': props.friction, + 'air_acceleration': props.air_acceleration, + 'roll_friction': props.roll_friction, + 'roll_deceleration': props.roll_deceleration, + } + + presets_dir = get_presets_directory() + safe_name = "".join(c for c in preset_name if c.isalnum() or c in (' ', '-', '_')).strip() + safe_name = safe_name.replace(' ', '_') + filepath = os.path.join(presets_dir, f"{safe_name}.json") + + try: + with open(filepath, 'w') as f: + json.dump(preset, f, indent=2) + self.report({'INFO'}, f"Saved preset: {preset_name}") + return {'FINISHED'} + except IOError as e: + self.report({'ERROR'}, f"Failed to save preset: {e}") + return {'CANCELLED'} + + +class SONIC_OT_tag_collision(bpy.types.Operator): + """Tag selected objects as collision objects""" + bl_idname = "sonic.tag_collision" + bl_label = "Tag as Collision" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return context.selected_objects + + def execute(self, context): + count = 0 + for obj in context.selected_objects: + obj['sonic_collision'] = True + obj['sonic_collision_type'] = 'SOLID' + count += 1 + self.report({'INFO'}, f"Tagged {count} object(s) for collision") + return {'FINISHED'} + + +class SONIC_OT_set_collision_type(bpy.types.Operator): + """Set collision type for selected objects""" + bl_idname = "sonic.set_collision_type" + bl_label = "Set Collision Type" + bl_options = {'REGISTER', 'UNDO'} + + collision_type: bpy.props.EnumProperty( + name="Collision Type", + items=[ + ('SOLID', "Solid", "Standard solid collision"), + ('SPRING_UP', "Spring (Up)", "Launches Sonic upward"), + ('RING', "Ring", "Collectible ring"), + ('SPIKES', "Spikes", "Damages Sonic"), + ('BUMPER', "Bumper", "Bounces Sonic away"), + ], + default='SOLID' + ) + + @classmethod + def poll(cls, context): + return context.selected_objects + + def execute(self, context): + count = 0 + for obj in context.selected_objects: + obj['sonic_collision'] = True + obj['sonic_collision_type'] = self.collision_type + count += 1 + self.report({'INFO'}, f"Set {count} object(s) to {self.collision_type}") + return {'FINISHED'} + + +class SONIC_PT_physics_panel(bpy.types.Panel): + """Panel for Sonic Physics controls""" + bl_label = "Sonic The Hedgehog Physics" + bl_idname = "SONIC_PT_physics_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Sonic Physics" + + def draw(self, context): + layout = self.layout + props = context.scene.sonic_physics_props + + obj = context.active_object + if obj: + box = layout.box() + box.label(text=f"PLAYER: {obj.name}", icon='OUTLINER_OB_MESH') + else: + box = layout.box() + box.label(text="No object selected!", icon='ERROR') + + layout.separator() + + if props.is_active: + layout.operator("sonic.stop_physics", text="Stop Physics", icon='PAUSE') + + box = layout.box() + box.label(text="Status:", icon='INFO') + col = box.column(align=True) + col.label(text=f"Grounded: {'Yes' if props.is_grounded else 'No'}") + col.label(text=f"Jumping: {'Yes' if props.is_jumping else 'No'}") + col.label(text=f"Rolling: {'Yes' if props.is_rolling else 'No'}") + col.label(text=f"Spindashing: {'Yes' if props.is_spindashing else 'No'}") + col.label(text=f"Falling: {'Yes' if props.is_falling else 'No'}") + col.label(text=f"Ground Speed: {props.ground_speed:.2f}") + + box = layout.box() + box.label(text="Controls:", icon='KEYTYPE_KEYFRAME_VEC') + col = box.column(align=True) + col.label(text="← → : Move Left/Right") + col.label(text="↓ : Roll / Crouch") + col.label(text="Space : Jump") + col.label(text="↓ + Space : Spindash") + col.label(text="Esc : Stop") + + if props.block_shortcuts: + box = layout.box() + box.label(text="🎮 GAME MODE ACTIVE", icon='PLAY') + else: + layout.operator("sonic.start_physics", text="Start Physics", icon='PLAY') + layout.prop(props, "block_shortcuts") + + +class SONIC_PT_presets_panel(bpy.types.Panel): + """Panel for Sonic Physics presets""" + bl_label = "Presets" + bl_idname = "SONIC_PT_presets_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Sonic Physics" + bl_parent_id = "SONIC_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.sonic_physics_props + + box = layout.box() + box.label(text="Built-in Presets:", icon='PRESET') + col = box.column(align=True) + for key, preset in PHYSICS_PRESETS.items(): + op = col.operator("sonic.load_preset", text=preset['name'], icon='PLAY') + op.preset_key = key + + layout.separator() + box = layout.box() + box.label(text="Save Current Settings:", icon='FILE_NEW') + box.prop(props, "preset_name", text="Name") + box.operator("sonic.save_preset", icon='FILE_TICK') + + +class SONIC_PT_physics_settings(bpy.types.Panel): + """Panel for Sonic Physics settings""" + bl_label = "Physics Settings" + bl_idname = "SONIC_PT_physics_settings" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Sonic Physics" + bl_parent_id = "SONIC_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.sonic_physics_props + + box = layout.box() + box.label(text="Axes:", icon='EMPTY_AXIS') + col = box.column(align=True) + col.prop(props, "forward_axis") + col.prop(props, "up_axis") + + layout.separator() + + box = layout.box() + box.label(text="Movement:", icon='CON_LOCLIKE') + col = box.column(align=True) + col.prop(props, "top_speed") + col.prop(props, "acceleration") + col.prop(props, "deceleration") + col.prop(props, "friction") + col.prop(props, "air_acceleration") + + layout.separator() + + box = layout.box() + box.label(text="Rolling:", icon='MESH_CIRCLE') + col = box.column(align=True) + col.prop(props, "roll_friction") + col.prop(props, "roll_deceleration") + + layout.separator() + + box = layout.box() + box.label(text="Jumping:", icon='SORT_ASC') + col = box.column(align=True) + col.prop(props, "jump_velocity") + col.prop(props, "gravity") + col.prop(props, "terminal_velocity") + + layout.separator() + + box = layout.box() + box.label(text="Environment:", icon='WORLD') + box.prop(props, "ground_level") + + layout.separator() + layout.operator("sonic.reset_defaults", icon='FILE_REFRESH') + + +class SONIC_PT_collision_panel(bpy.types.Panel): + """Panel for Sonic Collision settings""" + bl_label = "Collision" + bl_idname = "SONIC_PT_collision_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Sonic Physics" + bl_parent_id = "SONIC_PT_physics_panel" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + props = context.scene.sonic_physics_props + + layout.prop(props, "enable_collision") + + if props.enable_collision: + layout.prop(props, "collision_padding") + layout.prop(props, "use_raycast_collision") + + layout.separator() + + box = layout.box() + box.label(text="Collision Objects:", icon='MOD_PHYSICS') + col = box.column(align=True) + col.operator("sonic.tag_collision", text="Tag as Solid", icon='ADD') + + layout.separator() + + box = layout.box() + box.label(text="Set Collision Type:", icon='PHYSICS') + col = box.column(align=True) + + row = col.row(align=True) + op = row.operator("sonic.set_collision_type", text="Solid") + op.collision_type = 'SOLID' + op = row.operator("sonic.set_collision_type", text="Spring") + op.collision_type = 'SPRING_UP' + + row = col.row(align=True) + op = row.operator("sonic.set_collision_type", text="Ring") + op.collision_type = 'RING' + op = row.operator("sonic.set_collision_type", text="Spikes") + op.collision_type = 'SPIKES' + + row = col.row(align=True) + op = row.operator("sonic.set_collision_type", text="Bumper") + op.collision_type = 'BUMPER' + + layout.separator() + box = layout.box() + box.label(text="Tagged Objects:", icon='OUTLINER_OB_MESH') + + collision_count = 0 + for obj in context.scene.objects: + if obj.get('sonic_collision', False) or 'sonic_collision' in obj.name.lower(): + collision_count += 1 + row = box.row() + col_type = obj.get('sonic_collision_type', 'SOLID') + row.label(text=f"{obj.name} [{col_type}]", icon='CUBE') + + if collision_count == 0: + box.label(text="No collision objects", icon='INFO') + + +# Registration +classes = [ + SonicPhysicsProperties, + SONIC_OT_start_physics, + SONIC_OT_stop_physics, + SONIC_OT_reset_to_defaults, + SONIC_OT_load_preset, + SONIC_OT_save_preset, + SONIC_OT_tag_collision, + SONIC_OT_set_collision_type, + SONIC_PT_physics_panel, + SONIC_PT_presets_panel, + SONIC_PT_physics_settings, + SONIC_PT_collision_panel, +] + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.sonic_physics_props = bpy.props.PointerProperty(type=SonicPhysicsProperties) + + +def unregister(): + del bpy.types.Scene.sonic_physics_props + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register()