April 7, 2023

Godot Breakable Tiles

This is a quick and dirty example of adding breakable tiles in Godot 4.x. The setup assumes a projectile such as a fireball/bullet colliding with a tile map that is named Breakable. My game only defines one tile texture as breakable, so only one tile in the tile map


Create a Breakable Tile Map

Create a new tile set for the breakable tile and a tile map using the tile set. Name the tile map Breakable, this should go in your main scene. Now add a tile from the Breakable tile map to your main scene.


The Breakable Scene

Create a new scene named Breakable.tscn with the following structure. The root node is a RigidBody2D and the CollisionShape2D is a RectangleShape2D. The Sprite2D texture is the same as that in the tile map:

The Breakable Scene's Script

Attach the following script named Breakable.gd to the Breakable.tscn that was created above. In the simplest of terms, this script breaks the Sprite2D texture into pieces which will make up the blocks when the tile breaks. There is a maximum of 10 blocks per side for performance reasons:

extends RigidBody2D


@export_enum("2:2", "4:4", "6:6", "8:8", "10:10") var blocks_per_side: int = 6

@export var blocks_impulse = 600

@export var blocks_gravity_scale = 10

@export var debris_max_time = 5

@export var collision_one_way = false

@export var randomize_seed = false

@export var debug_mode = false


var object = {}

  

func initialize(parent_node):

   object = {

       blocks = [],

       blocks_container = Node2D.new(),

       blocks_gravity_scale = blocks_gravity_scale,

       blocks_impulse = blocks_impulse,

       blocks_per_side = blocks_per_side,

       can_detonate = true,

       collision_extents = Vector2(),

       collision_name = null,

       collision_one_way = collision_one_way,

       collision_position = Vector2(),

       debris_max_time = debris_max_time,

       debris_timer = Timer.new(),

       detonate = false,

       frame = 0,

       height = 0,

       hframes = 1,

       offset = Vector2(),

       parent = parent_node,

       sprite_name = null,

       vframes = 1,

       width = 0

   }

  

   # Add a unique name to 'blocks_container'.

   object.blocks_container.name = self.name + "_blocks_container"


   # Randomize the seed of the random number generator.

   if randomize_seed: randomize()


   if not self is RigidBody2D:

       print("ERROR: The '%s' node must be a 'RigidBody2D'" % self.name)

       object.can_detonate = false

       return


   for child in get_children():

       if child is Sprite2D:

           object.sprite_name = get_path_to(child)

  

       if child is CollisionShape2D:

           object.collision_name = get_path_to(child)


   if object.sprite_name.is_empty() and not object.collision_name.is_empty():

       print("ERROR: The 'RigidBody2D' (%s) must contain at least a 'Sprite' and a 'CollisionShape2D'." % self.name)

       object.can_detonate = false

       return


   if object.blocks_per_side > 10:

       print("ERROR: Too many blocks in '%s'! The maximum is 10 blocks per side." % self.name)

       object.can_detonate = false

       return


   if object.blocks_per_side % 2 != 0:

       print("ERROR: 'blocks_per_side' in '%s' must be an even number!" % self.name)

       object.can_detonate = false

       return


   # Set the debris timer.

   object.debris_timer.connect("timeout", Callable(self ,"_on_debris_timer_timeout"))

   object.debris_timer.set_one_shot(true)

   object.debris_timer.set_wait_time(object.debris_max_time)

   object.debris_timer.name = "debris_timer"

   add_child(object.debris_timer, true)

  

   if debug_mode: print("--------------------------------")

   if debug_mode: print("Debug mode for '%s'" % self.name)

   if debug_mode: print("--------------------------------")


   # Use vframes and hframes to divide the sprite.

   get_node(object.sprite_name).vframes = object.blocks_per_side

   get_node(object.sprite_name).hframes = object.blocks_per_side

   object.vframes = get_node(object.sprite_name).vframes

   object.hframes = get_node(object.sprite_name).hframes


   if debug_mode: print("object's blocks per side: ", object.blocks_per_side)

   if debug_mode: print("object's total blocks: ", object.blocks_per_side * object.blocks_per_side)


   # Check if the sprite is using 'Region' to get the proper width and height.

   if get_node(object.sprite_name).region_enabled:

       object.width = float(get_node(object.sprite_name).region_rect.size.x)

       object.height = float(get_node(object.sprite_name).region_rect.size.y)

   else:

       object.width = float(get_node(object.sprite_name).texture.get_width())

       object.height = float(get_node(object.sprite_name).texture.get_height())


   if debug_mode: print("object's width: ", object.width)

   if debug_mode: print("object's height: ", object.height)


   # Check if the sprite is centered to get the offset.

   if get_node(object.sprite_name).centered:

       object.offset = Vector2(object.width / 2, object.height / 2)


       if debug_mode: print("object is centered!")

       if debug_mode: print("object's offset: ", object.offset)


   object.collision_extents = Vector2((object.width / 2) / object.hframes,\

                                       (object.height / 2) / object.vframes)


   if debug_mode: print("object's collision_extents: ", object.collision_extents)


   object.collision_position = Vector2((ceil(object.collision_extents.x) - object.collision_extents.x) * -1,\

                                       (ceil(object.collision_extents.y) - object.collision_extents.y) * -1)


   if debug_mode: print("object's collision_position: ", object.collision_position)


   # Set each block's properties.

   for n in range(object.vframes * object.hframes):

       # Duplicate the object.

       var duplicated_object = self.duplicate(8)

       # Add a unique name to each block.

       duplicated_object.name = self.name + "_block_" + str(n)

       # Append it to the blocks array.

       object.blocks.append(duplicated_object)


       # Create a new collision shape for each block.

       var shape = RectangleShape2D.new()

       shape.extents = object.collision_extents


       object.blocks[n].freeze_mode = FREEZE_MODE_STATIC

       object.blocks[n].get_node(object.sprite_name).vframes = object.vframes

       object.blocks[n].get_node(object.sprite_name).hframes = object.hframes

       object.blocks[n].get_node(object.sprite_name).frame = n

       object.blocks[n].get_node(object.collision_name).shape = shape

       object.blocks[n].get_node(object.collision_name).position = object.collision_position


       if object.collision_one_way: object.blocks[n].get_node(object.collision_name).one_way_collision = true

      

       if debug_mode: object.blocks[n].modulate = Color(randf_range(0, 1), randf_range(0, 1), randf_range(0, 1), 0.9)


   # Position each block in place to create the whole sprite.

   for x in range(object.hframes):

       for y in range(object.vframes):

           object.blocks[object.frame].position = Vector2(\

               y * (object.width / object.hframes) - object.offset.x + object.collision_extents.x + position.x,\

               x * (object.height / object.vframes) - object.offset.y + object.collision_extents.y + position.y)


           if debug_mode: print("object[", object.frame, "] position: ", object.blocks[object.frame].position)


           object.frame += 1


   call_deferred("add_children", object)


   object.detonate = true

  

   if debug_mode: print("--------------------------------")

  

func _integrate_forces(_state):

   if object != {} and object.can_detonate and object.detonate:

       explosion()


func add_children(child_object):

   for i in range(child_object.blocks.size()):

       child_object.blocks_container.add_child(child_object.blocks[i], true)


   child_object.parent.add_child(child_object.blocks_container, true)


func explosion():

   if debug_mode: print("'%s' object exploded!" % self.name)


   object.can_detonate = false

  

   self.visible = false


   for i in range(object.blocks_container.get_child_count()):

       var child = object.blocks_container.get_child(i)

       child.apply_central_impulse(Vector2(randf_range(-blocks_impulse, blocks_impulse), -blocks_impulse))

      

   object.debris_timer.start()


func _on_debris_timer_timeout():

   if debug_mode: print("'%s' object's debris timer (%ss) timed out!" % [self.name, debris_max_time])


   for i in range(object.blocks_container.get_child_count()):

       var child = object.blocks_container.get_child(i)

      

       var color_r = child.modulate.r

       var color_g = child.modulate.g

       var color_b = child.modulate.b

       var color_a = child.modulate.a


       var opacity_tween = child.create_tween()

      

       opacity_tween.tween_property(child, "modulate", Color(color_r, color_g, color_b, color_a), 0.5)

       opacity_tween.tween_property(child, "modulate", Color(color_r, color_g, color_b, 0.0), 0.5)

       opacity_tween.tween_callback(_on_opacity_tween_completed.bind(child))

       opacity_tween.play()

  

func _on_opacity_tween_completed(child):

   if debug_mode: print("'%s' is being deleted!" % child.name)

  

   child.free()

  

   if object.blocks_container.get_child_count() == 0:

       if debug_mode: print("'%s' is being deleted!" % object.blocks_container.name)

      

       var path = String(object.blocks_container.name)

       var node = object.parent.get_node(path)

      

       node.free()

       self.queue_free()

The Collision Code

Here is the collision code for my fireball object (CharacterBody2D). The important part is after body.name == "Breakable. What we are doing here is deleting the tile and replacing it with the Breakable.tscn which then breaks itself into blocks:

var collision = null

var collided = false

func _physics_process(delta):

   if !collided:

       collision = move_and_collide(calc_velocity)

   if collision != null and !collided:

       collided = true

       var body = collision.get_collider()

      

       if body.is_in_group("enemies"):

           body.hit(HIT_POINTS)

       elif body.name == "Breakable":

           var tile = body.get_coords_for_body_rid(collision.get_collider_rid())

           body.erase_cell(0, tile)

          

           var scene = load("res://Scenes/Breakable.tscn")

           var scene_instance = scene.instantiate()

           scene_instance.set_position(position)

           scene_instance.initialize(get_parent())

           get_parent().call_deferred("add_child", scene_instance)

The Final Result

This could definitely be made more robust; however, it works fine for what I currently need.

Comments?

Email Us (Comments are held for moderation)

Latest Comments: