Half-Life 2 Inspired Glass Breaking Mechanic
Introduction
There's a node in the Half-Life 2 director's commentary that explains:
"When designing early levels where there are fewer mechanics in the player's toolkit, we found that it's just as engaging to spotlight new technical features instead, as we did here with breakable glass. For the shattering effect, glass was divided into a grid of small squares, allowing us to track which were intact or broken and to blow out larger sections once enough squares were no longer connected. Each square was rendered with a unique texture based on the status of its neighboring squares, creating the sharp, jagged edges between broken and intact glass. While breaking glass is now commonplace in games, it was something we hadn't seen much back when we were making Half-Life 2 and were incredibly happy with the level of fidelity and dynamism it brought to the game."
I've been amassing a small collection of game prototypes, or self-contained functionality demos, that I or others could use in future projects. I thought the glass breaking mechanic described here would be both versatile and engaging, so I decided to try implementing it myself.
Technical Overview
Since this is a prototype, we're going to be keeping the design deliberately minimal. We just need enough structure to start, and see what actually matters during implementation.
Requirements
We can extract a list of requirements just from the commentary node's description. We need:
- A pane of glass subdivided into sections
- Sections have 2 states, broken or intact
- The player can change the state of each section from intact to broken
- Larger sections will break themselves once enough squares are no longer connected
This list implies additional requirements, like player input and visual output, but we're going to limit the scope to just the glass pane system for now.
Design
Thankfully, the requirements list also gives us a pretty comprehensive design outline:
- Make a grid representing the glass pane, in which each element is a section of glass
- The value of each element is either Broken or Intact
- We need a function for transitioning each element from Intact to Broken
- This function also needs to check and enact the grid's connectivity rules, noting which cells are no longer connected to the frame of the window and transitioning them to Broken.
- Implement flood-fill algorithm to identify which clusters of intact cells are still connected to the window frame. This part is the core of the breaking functionality, so we'll cover it in more detail in the implementation section.
And after the core system has been implemented:
- Render each square with a unique texture based on the status of its neighbors
- Create a glue class responsible for translating player input to cell state changes
Implementation
GlassPane
The most obvious place to start is the 2D grid. I've always thought manipulating 2D arrays was annoying, so this time I'm going to make a reusable Grid class that simplifies the operations of initializing, setting, and getting grid elements. Now in all future projects, if I ever need 2D array functionality, I can reuse this class.
class_name Grid
var _cells:Array = []
var _width:int
var _height:int
func _init(w:int, h:int, initial_value:Variant) -> void:
_width = w
_height = h
for x in _width:
var list:Array[GridCell] = []
_cells.append(list)
for y in _height:
list.append(GridCell.new(Vector2i(x,y),initial_value))
func get_width() -> int:
return _width
func get_height() -> int:
return _height
func get_cellv(pos:Vector2i) -> GridCell:
return get_cell(pos.x, pos.y)
func get_cell(x:int, y:int) -> GridCell:
if x < 0 or x >= _width or y < 0 or y >= _height:
return null
return _cells[x][y]
...
Each glass square/element of the grid has 2 states: broken or intact. You could use a bool, but given the potential eventuality a third state might arise (Weakened? Reinforced?), an Enum would be more future-proof.
enum GLASS_STATE {BROKEN, INTACT}
Now we can make a GlassPane class that manages this grid and the breaking/state transition functionality.
class_name GlassPane
var grid:Grid
enum GLASS_STATE {BROKEN, INTACT}
signal state_changed
func _init(width:int, height) -> void:
grid = Grid.new(width, height, GLASS_STATE.INTACT)
func break_piece(x:int, y:int) -> void:
pass
Changing a piece of glass from intact to broken just involves setting the Enum value at its grid position.
grid.set_cell(x, y, GLASS_STATE.BROKEN)
Now, this is where things get technical. If an "island" of glass, a group of adjacent pieces, isn't anchored to something solid like the window's frame, we would expect it to fall and shatter.
Flood fill is an algorithm "that determines the area connected to a given node in a multi-dimensional array with some matching attribute." In our case, it allows us to mark all the intact pieces that are and aren't connected to an edge piece.
- Get all the frame/edge cells
- Apply flood-fill algorithm
- Initialize a grid to track visited cells
- Add all frame cells to to_visit list (frame cells aren't always connected to each other, so we need to visit each one individually)
- While to_visit isn't empty,
- Pop the current cell from the front of to_visit
- Set the cell as visited on the visitation grid
- Check all 4 of the cells neighbors
- If the neighbor is intact and unvisited, add it to to_visit
const VISITED = true
var visitation_grid = Grid.new(grid.get_width(), grid.get_height(), false)
var to_visit = _get_frame_cells()
while !to_visit.is_empty():
var current = to_visit.pop_front()
visitation_grid.set_cellv(current, VISITED)
# Check all 4 neighbors
for direction in Grid.neighbor_directions:
var neighbor_pos = current + direction
var neighbor_cell = grid.get_cellv(neighbor_pos)
var visited_cell = visitation_grid.get_cellv(neighbor_pos)
# If neighbor exists, is intact, and hasn't been visited yet
if neighbor_cell != null and neighbor_cell.value == GLASS_STATE.INTACT and visited_cell != null and visited_cell.value == false:
to_visit.append(neighbor_pos)
After we apply flood fill, we traverse the visitation grid and break (change the state of) all the pieces that were unvisited and are thus not connected to the window frame. Finally, we leverage Godot's observer pattern and emit a signal indicating the state of the entire window pane was changed.
# Break all intact cells that weren't visited (islands)
for cell in grid.get_all_cells():
var visited_cell = visitation_grid.get_cellv(cell.pos)
if cell.value == GLASS_STATE.INTACT and visited_cell.value == false:
grid.set_cell(cell.pos.x, cell.pos.y, GLASS_STATE.BROKEN)
emit_signal("state_changed")
And now we have a grid with cells that can be switched from intact to broken with automatic shattering behavior. But it'd be difficult to appreciate this in a game without being able to see it, so lets make it 3D.
Visuals
To visualize the window in 3D, we need a class that mediates between the GlassPane class and grid of textures. Revisiting the earlier design points, we need to align the sprite squares in a grid to match the GlassPane grid, and render each square with a unique texture based on the status of its neighbors.
Sprite Grid
Aligning squares in a grid is pretty simple:
for x in grid_width:
for y in grid_height:
sprite.position = Vector3(x * sprite_size, y * sprite_size, 0)
Finding the sprite size gets a little complicated because we need to consider the resolution of the texture png, the sprite to meter ratio, and the density of sprites to meters.
In the Godot Engine, pixels are 0.01 meters, and I arbitrarily chose to make each png 8x8px (to make drawing 16 of them easier). Therefore, the default sprite/meter density is:
meters / sprite_resolution = 1m / 8px = 12.5 pixels per meter
But using an export variable, we can change the window to any desired density. So, to calculate the sprite size based on density:
sprite_size = 1 / sprites_per_meter
And then add that to our alignment loop:
for x in grid_width:
for y in grid_height:
sprite.pixel_size = sprite_size / SPRITE_PIXELS
sprite.position = Vector3(x * sprite_size, y * sprite_size, 0)
Neighbor-Based Texturing
Now for the neighbor status tracking. Each square has 4 neighbors, each with 2 states (broken or intact), so we have 16 total combinations of neighbor status.
Combination | Top | Right | Bottom | Left |
---|---|---|---|---|
0 | Broken | Broken | Broken | Broken |
1 | Broken | Broken | Broken | Intact |
2 | Broken | Broken | Intact | Broken |
3 | Broken | Broken | Intact | Intact |
4 | Broken | Intact | Broken | Broken |
5 | Broken | Intact | Broken | Intact |
6 | Broken | Intact | Intact | Broken |
7 | Broken | Intact | Intact | Intact |
8 | Intact | Broken | Broken | Broken |
9 | Intact | Broken | Broken | Intact |
10 | Intact | Broken | Intact | Broken |
11 | Intact | Broken | Intact | Intact |
12 | Intact | Intact | Broken | Broken |
13 | Intact | Intact | Broken | Intact |
14 | Intact | Intact | Intact | Broken |
15 | Intact | Intact | Intact | Intact |
The most convenient way to track the combination of each texture is a bitmask.
var dir = {
Vector2i.UP: 1,
Vector2i.DOWN: 2,
Vector2i.LEFT: 4,
Vector2i.RIGHT: 8,
}
The bitmask value represents the intact-ness of the cell's neighbors - a value of 15 would imply that the up, down, left and right (1 + 2 + 4 + 8 = 15) neighbors are all intact, so we'd want sprite 15 to be unbroken glass. A value of 10 would mean that right and bottom neighbors are intact while the left and top neighbors are broken, so it should look diagonally jagged.
Also worth noting that the bitmask isn't actually a bit, its an int. This is purely for convenience; it makes it easier to keep track of the state value while debugging, makes the math operations easier, and it makes it easier to access the file path. I intend on making it an actual bitmask in the future.
Then we need to actually make the broken glass textures. I use Paint.net.
Not sure why I went with pink, but it provides nice contrast so I stuck with it.
Since the state value is an int, retrieving the corresponding texture is as simple as casting it to a string and inserting it into the texture file path.
func _texture(flag:int) -> Texture:
return load("res://Textures/" + str(flag) + ".png")
To update the state, we visit each of the neighbors. If the neighbor is broken, we subtract that direction value from the bitmask. After all the neighbors have been visited, we retrieve the new texture.
for direction in Grid.neighbor_directions:
var neighbor_pos = cell.pos + direction
# Check if neighbor is out of bounds or broken
var neighbor_cell = pane.grid.get_cellv(neighbor_pos)
# If there's no neighbor, its not broken on that side, its just the edge of the window
if neighbor_cell == null:
continue
if neighbor_cell.value == GlassPane.GLASS_STATE.BROKEN:
flag -= dir[direction]
var sprite = sprite_grid.get_cellv(cell.pos).value
sprite.texture = _texture(flag)
To know when we need to update the textures, we connect the GlassPane signal from earlier:
pane.state_changed.connect(_state_changed)
As mentioned before, the GlassPane class doesn't emit signals for individual tile changes, it only signals that the state of the grid has changed. Therefore, even if only a single piece of glass is broken, we have to update the entire grid of sprites. This may be inefficient for our worst case (only one piece is changed), but is more performant for the average case (multiple pieces are broken are in the same frame). If an island of glass is broken, several pieces break in the same frame. If we were to update each sprite as the island was breaking, we'd end up redundantly visiting and updating the states of multiple sprites that are about to be broken in the same frame anyway.
Player Input
Comparing this approach to the MVC pattern, we have the model and view classes, and just need a way for a character controller to interface with the GlassPane. At its most basic, the controller needs to be something that calls:
func break_piece(x:int, y:int) -> void:
on a GlassPane object. Since I originally pulled this idea from Half-Life 2, I decided to go with a basic first person controller. I pulled the movement functionality from a previous project, and added an un-reusable glue class that mediates between the player's input, look position, and glass pane visual. It attaches to a RayCast3D that's a child of the player camera, and converts the global position to a glass grid position.
func break_glass(global_pos: Vector3) -> void:
var local_pos = to_local(global_pos)
var sprite_size = get_sprite_size()
var grid_x = int(floor((local_pos.x + sprite_size * 0.5) / sprite_size))
var grid_y = int(floor((local_pos.y + sprite_size * 0.5) / sprite_size))
if grid_x < 0 or grid_x >= get_grid_width() or grid_y < 0 or grid_y >= get_grid_height():
print("Position out of bounds: ", grid_x, ", ", grid_y)
return
pane.break_piece(grid_x, grid_y)
Putting it all together
To see if this all works, we set up a quick 3D scene, complete with our visual, player character, and some other presentation elements, like a very robust and rustic looking wall with a frame to fit a window in.
While looking through the Quixel marketplace for the wall asset, I got distracted by the material library. I thought the denim material was cool but realized I'd probably never have a real reason to use it, so I decided to add a denim cube to our scene.
Running the scene and using LeftMouse while looking at the window gives us this result:
Not quite right! We would expect the upper neighbor to be broken on the bottom and the bottom neighbor to be broken on the top, but it appears to be the other way around. Thankfully, this is just a peculiarity in Godot's coordinate space. Vector2i.UP is actually Vector2i(0, -1), because in Godot's 2D space, -y is up and +y is down. But since we're in 3D space, -y is down and +y is up, so we need to switch the values in the dictionary. I thought keeping the constant names while swapping the ints was a bit confusing:
Vector2i.UP: 2,
Vector2i.DOWN: 1,
So I decided to just write out the explicit values instead.
var dir = {
Vector2i(0, 1): 1, # Flipped up and down for 3D space
Vector2i(0, -1): 2,
Vector2i.LEFT: 4,
Vector2i.RIGHT: 8,
}
Checking again:
Much better! Now lets check the island-shattering functionality (aiming without a reticle is harder than you might think):
And lastly, highlighting that pieces still attached to the frame don't shatter:
Reflection/Conclusion
While I'm happy with the way this came out, there's always room for improvement.
In future iterations, I want to emphasize performance. I think preoptimizing is bad and always try to avoid it, but given that this is supposed to an "oh, neat" feature that probably won't be consciously recognized by most players, its performance footprint needs to be as minimal as possible. I think redoing it in C# or even C++ would provide the biggest benefit. Outside of that, the visual refresh system still bothers me. I think I made the right choice by having all sprites update at once instead of individually to avoid redundant checks, but I still think there's got to be a better way to do it, so I hope to revisit it in the future.
That said, revisiting the sprite system might not be necessary because I'd also like to make the visuals more dynamic and procedural. The marching squares algorithm is something I came across while making this, and I think it would fit this system well. Or just skip that entirely and go straight to a physics simulation. A lot of avenues to explore!
Another improvement would involve moving from flood search to a more sophisticated connectivity system. In its current state, an entire window could be supported in air by a tiny piece of glass, as long as its connected to a frame. Figuring out a weight/strength comparison system could definitely lend some realism to these windows.
Overall, I'd say this was a good exercise in software development principles and problem solving. And I got a very versatile Grid class out of it that I intend to use in future projects. And a cool wall. And some denim.
Top comments (0)