Commit
Theria
feat: walk through towers — they no longer block movement
modified CHANGELOG.md
@@ -25,6 +25,16 @@ protocol version.
## [Unreleased]
## [v0.4.2] — 2026-06-17
### Changed
- **Towers no longer block movement** — a hero walks (and dives) straight through a tower, as in
the genre, instead of being shoved around an invisible footprint. A forward tower sits right on
a lane waypoint, so this also clears the lane a wave and its escorting hero march down. Towers
still fight: their range and damage are unchanged. The nexuses and the jungle rock walls remain
solid; pathing and bot routing follow the same set, so they no longer detour around towers.
## [v0.4.1] — 2026-06-17
### Added
modified README.md
@@ -8,7 +8,7 @@
<p>
<a href="https://github.com/ajhahnde/Theria/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ajhahnde/Theria/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a>
<img src="https://img.shields.io/badge/version-v0.4.1-059669?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/version-v0.4.2-059669?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/status-pre--alpha-059669?style=flat-square" alt="Status">
<img src="https://img.shields.io/badge/engine-Godot%204.6-lightgrey?style=flat-square" alt="Godot 4.6">
<img src="https://img.shields.io/badge/license-Apache--2.0-lightgrey?style=flat-square" alt="License">
modified VERSION
@@ -1 +1 @@
v0.4.1
v0.4.2
modified project.godot
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="Theria"
config/version="0.4.1"
config/version="0.4.2"
run/main_scene="res://scenes/boot.tscn"
config/features=PackedStringArray("4.6")
config/icon="res://icon.svg"
modified src/sim/map_data.gd
@@ -122,17 +122,18 @@ const TOWER_SLOTS: Array[Vector2] = [
]
# === Collision obstacles ===================================================================
## The solid bodies a moving unit cannot enter and a path routes around: the structures and the
## The solid bodies a moving unit cannot enter and a path routes around: the nexuses and the
## jungle rock walls. Every obstacle is a circle ({center, radius}); a wall is a chain of
## overlapping circles. The river and the fine cosmetic scatter stay walkable. Derived purely
## from the geometry above (lanes, river, camps, towers, nexuses) and closed under the y = x
## from the geometry above (lanes, river, camps, nexuses) and closed under the y = x
## mirror, so collision is team-fair and shares one source of truth with the sim, the bots, the
## nav grid, the tests, and the decor that draws these same rocks. A unit's body radius is owned
## by the sim (SimCore.UNIT_RADIUS); the radii here are the bare obstacle footprints.
## A tower's and the nexus's solid footprint. A forward tower sits on a lane waypoint, so heroes
## route around it while lane creeps (which never collide) still file past — see `obstacles`.
const TOWER_RADIUS := 200.0
## The nexus's solid footprint. Towers do NOT block movement — a hero walks (and dives) straight
## through a tower as in the genre, and a forward tower sits right on a lane waypoint a wave files
## past — so only the nexuses and the jungle walls are obstacles; see `obstacles`. (A tower still
## fights: its combat range lives in SimCore, independent of any collision body.)
const NEXUS_RADIUS := 320.0
## Jungle rock walls: a wall of blocker rocks runs each side of every lane, set back
@@ -250,9 +251,10 @@ static func clamp_to_bounds(pos: Vector2) -> Vector2:
)
## The solid obstacle circles (each `{center: Vector2, radius: float}`): every team's towers and
## nexus, plus the jungle rock walls and camp pockets. Baked once and cached — the map is static.
## Closed under the y = x mirror, so the set is team-fair. Callers must treat it as read-only.
## The solid obstacle circles (each `{center: Vector2, radius: float}`): every team's nexus, plus
## the jungle rock walls and camp pockets. Towers are deliberately absent — they never block a body
## (genre dive-through). Baked once and cached — the map is static. Closed under the y = x mirror,
## so the set is team-fair. Callers must treat it as read-only.
static func obstacles() -> Array:
if _obstacles.is_empty():
_obstacles = _build_obstacles()
@@ -262,8 +264,6 @@ static func obstacles() -> Array:
static func _build_obstacles() -> Array:
var out: Array = []
for team in NEXUS_POSITIONS.size():
for slot in tower_positions(team):
out.append({"center": slot, "radius": TOWER_RADIUS})
out.append({"center": nexus_for_team(team), "radius": NEXUS_RADIUS})
for p in jungle_wall_points():
out.append({"center": p, "radius": WALL_RADIUS})
modified test/unit/test_bot_controller.gd
@@ -241,7 +241,7 @@ func test_a_brawler_closes_a_point_blank_enemy() -> void:
func test_a_brawler_routes_around_a_blocking_obstacle() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var center := MapData.tower_positions(0)[0]
var center := MapData.nexus_for_team(0)
var bot := _hero(sim, "snake", center + Vector2(700.0, 0.0))
var enemy_pos := center - Vector2(700.0, 0.0)
sim.add_entity(1, enemy_pos, 0.0, 600) # an enemy on the far side of the obstacle
modified test/unit/test_nav_grid.gd
@@ -25,7 +25,7 @@ func test_from_equals_to_returns_the_point() -> void:
func test_routes_around_a_blocking_obstacle_on_a_clear_path() -> void:
var nav := NavGrid.new()
var center := MapData.tower_positions(0)[0] # a real obstacle, open ground around it
var center := MapData.nexus_for_team(0) # a real obstacle, open ground around it
var from := center + Vector2(700.0, 0.0)
var to := center - Vector2(700.0, 0.0)
assert_false(nav.segment_clear(from, to), "the straight line must run through the obstacle")
@@ -45,7 +45,7 @@ func test_routes_around_a_blocking_obstacle_on_a_clear_path() -> void:
func test_target_inside_an_obstacle_resolves_to_free_ground() -> void:
var nav := NavGrid.new()
var center := MapData.tower_positions(0)[0]
var center := MapData.nexus_for_team(0)
var from := center + Vector2(1000.0, 0.0)
var path := nav.find_path(from, center) # aim straight at the obstacle's centre
assert_gt(path.size(), 0, "a blocked target still yields a path to its edge")
@@ -58,7 +58,7 @@ func test_target_inside_an_obstacle_resolves_to_free_ground() -> void:
func test_same_query_yields_an_identical_path() -> void:
var nav := NavGrid.new()
var center := MapData.tower_positions(0)[0]
var center := MapData.nexus_for_team(0)
var from := center + Vector2(700.0, 0.0)
var to := center - Vector2(700.0, 0.0)
var a := nav.find_path(from, to)
modified test/unit/test_prediction.gd
@@ -102,7 +102,7 @@ func test_apply_movement_holds_still_on_null_command() -> void:
func test_a_moving_hero_stops_at_an_obstacle_edge() -> void:
var center := MapData.tower_positions(0)[0]
var center := MapData.nexus_for_team(0)
var sim := SimCore.new()
sim.spawn_creeps = false
var hero := sim.add_hero(0, center + Vector2(600.0, 0.0), 320.0)
@@ -119,7 +119,7 @@ func test_a_moving_hero_stops_at_an_obstacle_edge() -> void:
func test_prediction_matches_the_server_through_an_obstacle() -> void:
# The decoded snapshot the client predicts on carries no is_hero flag, but the collision gate is
# the same "mobile, non-creep" predicate, so the replay collides exactly as the server does.
var start := MapData.tower_positions(0)[0] + Vector2(600.0, 0.0)
var start := MapData.nexus_for_team(0) + Vector2(600.0, 0.0)
var inputs: Array = []
for _i in 30:
inputs.append(_command(Vector2.LEFT))