Commit
Theria
feat: server-authoritative collision + click-to-move auto-pathing
Towers, nexus, and the jungle rock walls become solid bodies a hero slides along; lane creeps and the river stay open. A move order now routes the hero around obstacles instead of into them, and the bots approach through the same routing. Obstacles are circles derived from the shared map geometry (mirrored for fairness) and drive one source of truth for collision, the nav grid, and the boulders the jungle is drawn from. Collision resolves in the shared apply_movement on the same wire-carried predicate the client identifies its hero by, so a predicted route reconciles exactly with the server. Routing is a deterministic grid A*, cached and refreshed a few times a second rather than every tick, so a bot match replays identically and the frame stays cheap.
modified CHANGELOG.md
@@ -36,10 +36,18 @@ protocol version.
- All of the scattered decor is **mirrored across the map's symmetry axis**, so the two teams'
jungle halves are exact reflections and neither side has more cover.
- A first pass of **jungle structure**: rock walls flank each lane and ring each camp, broken by
openings, so the jungle reads as distinct corridors rather than one open field. (The walls are
presentation for now — they do not yet block movement.)
openings, so the jungle reads as distinct corridors rather than one open field.
- The tall **canopy fades to its outline** over the player's hero as the camera passes through it,
so a unit under a palm stays visible. All of it is toon-shaded to match the field units.
- **Collision**: heroes can no longer walk through the towers, the nexus, or the jungle rock walls
— the structures and walls are now solid bodies a hero slides along. Lane creeps still march
their corridors freely, and the river stays open. The rocks you see are the rocks that block: the
collision shapes and the boulders the jungle is drawn from come from one shared layout, mirrored
across the map axis so neither team has more cover.
- **Click-to-move auto-pathing**: a move order now routes the hero *around* the obstacles instead of
walking it into a wall, threading the gank gaps and rounding the towers — and the bots approach
through the same routing. Pathing runs on a deterministic grid in the simulation, so an online
hero's predicted route still reconciles exactly with the server and a bot match replays identically.
## [v0.3.4] — 2026-06-16
modified src/bot/bot_controller.gd
@@ -28,6 +28,11 @@ enum Difficulty { EASY, NORMAL, HARD }
## Stop advancing once within this many world units of the target.
const STOP_RANGE := 60.0
## Ticks a cached approach route is reused before recomputing. Routing every tick is far too
## expensive (a full A* per bot per frame), and a route a fraction of a second old still steers a
## bot fine toward a moving target.
const ROUTE_REFRESH_TICKS := 15
## The ability bar is four slots (0..3) per form; the bot scans them in order so its
## pick is deterministic by slot rather than by dictionary iteration order.
const SLOT_COUNT := 4
@@ -71,6 +76,10 @@ const DIFFICULTY_NAMES := {
## setting (EASY by default, so practice is winnable).
var difficulty: int = Difficulty.HARD
## Per-bot cached approach route: bot id -> {path, index, tick}. Carried across ticks; a pure
## function of the (deterministic) state sequence, so a bot match still replays identically.
var _routes: Dictionary = {}
func decide(state: SimState, bot_id: int) -> InputCommand:
var command := InputCommand.new()
@@ -83,14 +92,43 @@ func decide(state: SimState, bot_id: int) -> InputCommand:
if bot.is_hero and bot.stance == AbilityData.STANCE_KITE:
_kite_move(command, bot, target, state.tick)
else:
var offset := target.position - bot.position
if offset.length() > STOP_RANGE:
command.move_dir = offset.normalized()
if bot.position.distance_to(target.position) > STOP_RANGE:
command.move_dir = _approach_dir(bot, target, state.tick)
if bot.is_hero:
_choose_cast(command, bot, target, state.tick)
return command
## The direction to advance on a target: straight at it in the open (the common case, exactly the
## old `offset.normalized()`), or along a routed path around the obstacles when the straight line is
## blocked. The route is cached per bot and refreshed only every ROUTE_REFRESH_TICKS — an A* every
## frame per bot is what dropped the game to a slideshow, and a third-of-a-second-old route still
## steers fine toward a moving target. Deterministic (the nav grid is pure and the cache is a
## function of the replayable state), so a bot match still replays identically.
func _approach_dir(bot: SimEntity, target: SimEntity, tick: int) -> Vector2:
var to_target := target.position - bot.position
if to_target.length() < 0.0001:
return Vector2.ZERO
var nav := NavGrid.shared()
if nav.segment_clear(bot.position, target.position):
_routes.erase(bot.id) # clear line — drop any detour and head straight in
return to_target.normalized()
var route: Dictionary = _routes.get(bot.id, {})
if route.is_empty() or tick - int(route["tick"]) >= ROUTE_REFRESH_TICKS:
route = {"path": nav.find_path(bot.position, target.position), "index": 0, "tick": tick}
_routes[bot.id] = route
var path: PackedVector2Array = route["path"]
var idx: int = route["index"]
while idx < path.size() and bot.position.distance_to(path[idx]) <= STOP_RANGE:
idx += 1
route["index"] = idx
if idx < path.size():
var leg := path[idx] - bot.position
if leg.length() > 0.0001:
return leg.normalized()
return to_target.normalized()
## Maps a difficulty name — the `--bot-difficulty` value and the menu's metadata — to a
## level, falling back to EASY (the winnable practice default) for an unknown name.
static func difficulty_from_name(level_name: String) -> int:
@@ -262,13 +300,13 @@ func _kite_move(command: InputCommand, bot: SimEntity, target: SimEntity, tick:
var band := _kite_band(bot)
if band == Vector2.ZERO:
if dist > STOP_RANGE:
command.move_dir = to_enemy / dist
command.move_dir = _approach_dir(bot, target, tick)
return
if dist < band.x:
if _may_step_back(bot, tick):
command.move_dir = -to_enemy / dist
command.move_dir = -to_enemy / dist # backing off — straight away, slid off a wall behind
elif dist > band.y:
command.move_dir = to_enemy / dist
command.move_dir = _approach_dir(bot, target, tick)
## Whether `tick` is one of this kiter's retreat beats — the footwork handicap that lets a
modified src/client/jungle_decor.gd
@@ -45,17 +45,15 @@ const SPAWN_CLEAR := 380.0
## the jungle, so the growth thins toward the travelled lanes.
const JUNGLE_FULL := 1050.0
## Jungle walls (LoL-style paths): a wall of mixed boulders runs each side of every lane, set
## back LANE_WALL_OFFSET past the path and stepped at WALL_STEP, broken by a GAP_SPAN opening
## every GAP_PERIOD (the gank gaps), so the jungle reads as corridors a unit threads rather than
## one open field. Each camp gets a rock ring POCKET_RADIUS out with one entrance. (Presentation
## only for now — solid blocking waits on the deferred navigation slice; a unit still walks
## through them until then.)
const LANE_WALL_OFFSET := LANE_HALF + 320.0
const WALL_STEP := 150.0
const GAP_PERIOD := 1500.0
const GAP_SPAN := 340.0
const POCKET_RADIUS := 360.0
## Jungle walls (LoL-style paths): a boulder is drawn on every blocker point MapData lays down —
## the lane-flanking walls (broken by the gank gaps) and the camp pockets — so the rocks you see
## are exactly the rocks that block. The layout, its offsets, gaps, and the y = x mirror all live in
## MapData (the shared collision source); this file only dresses each point. Each visual boulder is
## sized a little larger than its collision footprint (MapData.WALL_RADIUS) so the solid circle
## always sits within the rock a player sees.
const BLOCKER_ROCK_MIN := 120.0 # ≥ MapData.WALL_RADIUS + the jitter below, so the rock covers it
const BLOCKER_ROCK_MAX := 175.0
const BLOCKER_JITTER := 20.0
## The central landmark: a wide low mound at the map centre crowned by one grand tree and ringed
## with standing stones, so the middle of the symmetric map is unmistakable. Its footprint is
@@ -102,7 +100,7 @@ static func build(parent: Node3D) -> ShaderMaterial:
var fade_side := _new_surface()
_build_wall(solid_side, fade_side, rng)
_build_terrain(solid_axis, solid_side, fade_axis, rng)
_build_lane_walls(solid_side, rng)
_build_blockers(solid_axis, rng)
_build_plants(solid_side, fade_side, rng)
_build_camps(solid_axis, solid_side, rng)
_emit(parent, solid_mat, solid_axis, false)
@@ -314,7 +312,6 @@ static func _build_camps(axis: SurfaceTool, side: SurfaceTool, rng: RandomNumber
var p := camp + Vector2(cos(a), sin(a)) * rng.randf_range(150.0, 195.0)
_rock(st, _w(p), rng.randf_range(55.0, 95.0), rng.randf_range(70.0, 140.0), rng)
_totem(st, _w(camp), rng)
_camp_pocket(st, camp, rng)
## A carved totem post: stacked wood blocks on a base, a bright carved cap, and a banner cloth
@@ -332,71 +329,24 @@ static func _totem(st: SurfaceTool, foot: Vector3, rng: RandomNumberGenerator) -
# === jungle walls (LoL-style paths) ========================================================
## Lays the lane-flanking rock walls for every lane, both sides. Only team 0's half is built (a
## wall boulder is placed where its point is on that side of the y = x axis); the caller's mirror
## reflects the rest. Each lane is its own reflection across the axis, so the halves still line up.
static func _build_lane_walls(st: SurfaceTool, rng: RandomNumberGenerator) -> void:
for lane in MapData.LANES:
_lane_wall(st, lane, 1.0, rng)
_lane_wall(st, lane, -1.0, rng)
## Walks one lane corridor and lays a wall of boulders offset to one side (`sign`), stepping along
## each segment and pushing out along the segment normal. Skips a gap every GAP_PERIOD (the gank
## openings), the river crossings, anything near a structure, and team 1's half.
static func _lane_wall(
st: SurfaceTool, lane: Array, sign: float, rng: RandomNumberGenerator
) -> void:
var travelled := 0.0
for i in lane.size() - 1:
var a: Vector2 = lane[i]
var b: Vector2 = lane[i + 1]
var seg := b - a
var length := seg.length()
if length < 1.0:
continue
var dir := seg / length
var normal := Vector2(-dir.y, dir.x) * sign
var t := 0.0
while t < length:
var p := a + dir * t + normal * LANE_WALL_OFFSET
t += WALL_STEP
travelled += WALL_STEP
if fmod(travelled, GAP_PERIOD) < GAP_SPAN: # a gank opening
continue
if p.y < p.x: # team 1's half — the mirror fills it
continue
if _near_river(p) < 280.0 or _blocked(p, 240.0):
continue
_rock_wall_point(st, p, rng)
## A rough rock ring walling a camp, open toward the map centre so there is one entrance. The
## opening direction lies along the camp→centre line, which is mirror-invariant for an on-axis
## camp, so the pocket stays symmetric.
static func _camp_pocket(st: SurfaceTool, camp: Vector2, rng: RandomNumberGenerator) -> void:
var gap_dir := -camp.normalized() if camp.length() > 0.1 else Vector2.RIGHT
var count := 18
for i in count:
var ang := TAU * float(i) / float(count)
var d := Vector2(cos(ang), sin(ang))
if d.dot(gap_dir) > 0.6: # the entrance opening
continue
var p := camp + d * (POCKET_RADIUS + rng.randf_range(-30.0, 30.0))
if _near_lane(p) < 200.0 or _near_river(p) < 200.0 or _blocked(p, 200.0):
continue
_rock_wall_point(st, p, rng)
## One stretch of wall: a boulder at `p`, mixed big or small with a little jitter, sized so a run
## of them at WALL_STEP spacing overlaps into a continuous rocky wall.
static func _rock_wall_point(st: SurfaceTool, p: Vector2, rng: RandomNumberGenerator) -> void:
var jitter := Vector2(rng.randf_range(-40.0, 40.0), rng.randf_range(-40.0, 40.0))
var foot := _w(p + jitter)
if rng.randf() < 0.45:
_rock(st, foot, rng.randf_range(120.0, 180.0), rng.randf_range(150.0, 250.0), rng)
else:
_rock(st, foot, rng.randf_range(55.0, 95.0), rng.randf_range(75.0, 135.0), rng)
## Draws a boulder on every blocker point MapData lays down — the lane-flanking walls (with their
## gank gaps) and the camp pockets, both halves already mirrored in the data. Drawn once onto the
## non-mirrored `axis` batch (the points carry their own y = x partners), so what renders matches
## the collision set one-for-one. Each rock is sized a little over MapData.WALL_RADIUS, with only a
## small jitter, so the solid circle always sits inside the boulder a player sees.
static func _build_blockers(st: SurfaceTool, rng: RandomNumberGenerator) -> void:
for p in MapData.jungle_wall_points():
var jitter := Vector2(
rng.randf_range(-BLOCKER_JITTER, BLOCKER_JITTER),
rng.randf_range(-BLOCKER_JITTER, BLOCKER_JITTER),
)
_rock(
st,
_w(p + jitter),
rng.randf_range(BLOCKER_ROCK_MIN, BLOCKER_ROCK_MAX),
rng.randf_range(160.0, 260.0),
rng,
)
# === plant builders ========================================================================
modified src/client/player_input.gd
@@ -21,6 +21,11 @@ const STOP_KEY := KEY_S
## one" rather than "walk here" — its footprint plus a little slop.
const ENEMY_PICK_RADIUS := 90.0
## How many ticks a chase's routed direction is reused before recomputing — an A* every tick while
## attack-moving a target behind a wall is too costly, and a few-tick-old direction still tracks a
## moving enemy fine.
const CHASE_REFRESH_TICKS := 10
## The standing click-to-move destination (a sim point); `has_move_target` gates it. Read by
## the presenter to draw the destination marker.
var move_target: Vector2 = Vector2.ZERO
@@ -29,6 +34,15 @@ var has_move_target: bool = false
## strikes it (LoL-style attack-on-click). 0 means the last order was a plain ground move.
var attack_target_id: int = 0
## The routed path behind the standing move order — the NavGrid waypoints from the hero to
## `move_target`, bending around the jungle walls and towers, walked one leg at a time via
## `_path_index`. Empty when there is no move order or the hero is closing on an attack target.
var _path: PackedVector2Array = PackedVector2Array()
var _path_index: int = 0
var _chase_dir_cache: Vector2 = Vector2.ZERO
var _chase_cooldown: int = 0
var _camera: Camera3D = null
@@ -48,7 +62,7 @@ func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -
_halt()
return command
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
_issue_order(state, team, _mouse_world_point())
_issue_order(state, hero, team, _mouse_world_point())
if Input.is_physical_key_pressed(STOP_KEY):
_halt()
command.move_dir = _move_dir(hero, state)
@@ -60,15 +74,30 @@ func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -
## Resolves a right-click into an order: clicking on an enemy attacks it (the hero closes to
## attack range, then the combat step strikes it), clicking open ground walks there. One button
## both moves and engages — lighter than LoL's separate attack-move key.
func _issue_order(state: SimState, team: int, point: Vector2) -> void:
func _issue_order(state: SimState, hero: SimEntity, team: int, point: Vector2) -> void:
var enemy := _enemy_under(state, team, point)
if enemy != 0:
attack_target_id = enemy
has_move_target = false
_path = PackedVector2Array()
else:
attack_target_id = 0
move_target = point
has_move_target = true
_set_path(hero, point)
## Routes the standing move order around the obstacles: asks the nav grid for a path to `point` and
## stores it to walk leg by leg. Falls back to a straight line to the click when the grid finds none
## (or the hero has not spawned yet), so a move order always produces motion.
func _set_path(hero: SimEntity, point: Vector2) -> void:
_path_index = 0
if hero == null:
_path = PackedVector2Array([point])
return
_path = NavGrid.shared().find_path(hero.position, point)
if _path.is_empty():
_path = PackedVector2Array([point])
## Cancels the standing order so the hero plants where it stands — the move target and the
@@ -77,6 +106,8 @@ func _issue_order(state: SimState, team: int, point: Vector2) -> void:
func _halt() -> void:
has_move_target = false
attack_target_id = 0
_path = PackedVector2Array()
_path_index = 0
## This tick's movement direction: closing on the attack target when one is set, else the
@@ -86,15 +117,29 @@ func _move_dir(hero: SimEntity, state: SimState) -> Vector2:
return _chase_dir(hero, state)
if not has_move_target or hero == null:
return Vector2.ZERO
# Unit vector toward the target while far; within a single tick's reach, a sub-unit vector
# that lands the hero exactly on it (apply_movement scales a move_dir under length 1 down),
# so it stops on the point rather than overshooting. Then the target is cleared.
var to_target := move_target - hero.position
var step := hero.current_move_speed() * SimCore.TICK_DELTA
if step <= 0.0 or to_target.length() <= step:
has_move_target = false
return to_target / step if step > 0.0 else Vector2.ZERO
return to_target.normalized()
return _follow_path(hero)
## This tick's direction along the routed path: head for the current waypoint, advancing to the next
## as each is reached, and on the final leg return a sub-unit vector that lands the hero exactly on
## the destination (apply_movement scales a move_dir under length 1 down) before clearing the order.
## Mirrors the creep waypoint-follow in SimCore — an empty or exhausted path stops the hero.
func _follow_path(hero: SimEntity) -> Vector2:
while _path_index < _path.size():
var to_target := _path[_path_index] - hero.position
if _path_index < _path.size() - 1:
if to_target.length() <= SimCore.WAYPOINT_ARRIVE_RADIUS:
_path_index += 1
continue
return to_target.normalized()
# the final waypoint — stop exactly on it
var step := hero.current_move_speed() * SimCore.TICK_DELTA
if step <= 0.0 or to_target.length() <= step:
has_move_target = false
return to_target / step if step > 0.0 else Vector2.ZERO
return to_target.normalized()
has_move_target = false
return Vector2.ZERO
## Movement toward the attack target: close until the hero is inside its own attack range —
@@ -109,7 +154,19 @@ func _chase_dir(hero: SimEntity, state: SimState) -> Vector2:
var reach := hero.attack_range if hero.attack_range > 0.0 else SimCore.HERO_RANGE
if to_target.length() <= reach:
return Vector2.ZERO
return to_target.normalized()
# Straight line clear: close directly. Blocked: route around it, but refresh the routed direction
# only every CHASE_REFRESH_TICKS so the A* runs a few times a second, not every frame.
var nav := NavGrid.shared()
if nav.segment_clear(hero.position, target.position):
return to_target.normalized()
_chase_cooldown -= 1
if _chase_cooldown <= 0:
var path := nav.find_path(hero.position, target.position)
_chase_dir_cache = (
(path[0] - hero.position).normalized() if path.size() > 0 else to_target.normalized()
)
_chase_cooldown = CHASE_REFRESH_TICKS
return _chase_dir_cache
## The id of an enemy under `point` (within a click's slop of its body), or 0 for open ground —
modified src/sim/map_data.gd
@@ -121,6 +121,47 @@ const TOWER_SLOTS: Array[Vector2] = [
Vector2(3720.0, 840.0),
]
# === Collision obstacles ===================================================================
## The solid bodies a moving unit cannot enter and a path routes around: the structures 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
## 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
const NEXUS_RADIUS := 320.0
## Jungle rock walls: a wall of blocker rocks runs each side of every lane, set back
## LANE_WALL_OFFSET past the path centre, a rock every WALL_STEP (radius WALL_RADIUS, sized so a
## run of them overlaps into a continuous wall even inflated by a unit's body), broken by a
## WALL_GAP_SPAN opening every WALL_GAP_PERIOD — the gank gaps a unit threads. No rock lands
## within WALL_RIVER_CLEAR of the river (the lane fords stay open) or WALL_STRUCT_CLEAR of a
## structure (a tower keeps its own clearance). These mirror the decor that draws them.
const WALL_RADIUS := 95.0
const LANE_WALL_OFFSET := 435.0 # LANE half-width (115) + a 320 setback
const WALL_STEP := 150.0
const WALL_GAP_PERIOD := 1500.0
const WALL_GAP_SPAN := 340.0
const WALL_RIVER_CLEAR := 300.0
const WALL_STRUCT_CLEAR := 360.0
const WALL_SPAWN_CLEAR := 700.0 # no wall rock near a base fountain — the team spawns free
## A neutral camp's rock pocket: a ring of blocker rocks CAMP_POCKET_RADIUS out, left open over
## the CAMP_POCKET_GAP arc facing the map centre so the camp has one entrance. Each ring point is
## paired with its y = x mirror, so the pockets stay team-fair like the camps they wall.
const CAMP_POCKET_RADIUS := 360.0
const CAMP_POCKET_POINTS := 12
const CAMP_POCKET_GAP := 0.5 # cos-threshold of the entrance arc toward the centre
const CAMP_FEATURE_CLEAR := 200.0 # no pocket rock on a lane or in the river
## Lazily-built cache of the obstacle circles — the map is static, so this is baked once and
## reused by the per-tick collision, the nav grid, and the tests.
static var _obstacles: Array = []
## Reflection across the diagonal axis y = x — the map's mirror. An involution (its own inverse)
## that swaps the two bases, so team 1's geometry is team 0's mirrored.
@@ -202,3 +243,176 @@ static func clamp_to_bounds(pos: Vector2) -> Vector2:
clampf(pos.x, BOUNDS.position.x, BOUNDS.end.x),
clampf(pos.y, BOUNDS.position.y, BOUNDS.end.y),
)
## 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.
static func obstacles() -> Array:
if _obstacles.is_empty():
_obstacles = _build_obstacles()
return _obstacles
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})
return out
## The rock centres of the jungle walls and camp pockets — the blocker layout, shared by the sim
## (wrapped into WALL_RADIUS circles in `obstacles`) and the decor (which draws a boulder on each).
## Generated on team 0's side of the y = x axis and mirrored, so the layout is exactly symmetric.
static func jungle_wall_points() -> PackedVector2Array:
var pts := PackedVector2Array()
for lane in LANES:
_lane_wall_points(pts, lane, 1.0)
_lane_wall_points(pts, lane, -1.0)
for camp in JUNGLE_CAMPS:
if camp.y < camp.x:
continue # team 1's side — the mirror below fills it
_camp_pocket_points(pts, camp)
return pts
## Lays a wall of blocker points down one side (`sign`) of a lane corridor: stepping along each
## segment, pushed out LANE_WALL_OFFSET along the segment normal, skipping the gank gaps, the
## river crossings, and structure clearances. Each kept point (on team 0's half) is paired with
## its mirror, so the wall is symmetric across y = x.
static func _lane_wall_points(out: PackedVector2Array, lane: Array, sign: float) -> void:
var travelled := 0.0
for i in lane.size() - 1:
var a: Vector2 = lane[i]
var b: Vector2 = lane[i + 1]
var seg := b - a
var length := seg.length()
if length < 1.0:
continue
var dir := seg / length
var normal := Vector2(-dir.y, dir.x) * sign
var t := 0.0
while t < length:
var p := a + dir * t + normal * LANE_WALL_OFFSET
t += WALL_STEP
travelled += WALL_STEP
if fmod(travelled, WALL_GAP_PERIOD) < WALL_GAP_SPAN:
continue # a gank opening
if p.y < p.x:
continue # team 1's half — paired by the mirror below
if _dist_to_polyline(p, RIVER) < WALL_RIVER_CLEAR:
continue
if _dist_to_structures(p) < WALL_STRUCT_CLEAR:
continue
if _dist_to_spawns(p) < WALL_SPAWN_CLEAR:
continue
out.append(p)
out.append(mirror(p))
## Rings a neutral camp with blocker points CAMP_POCKET_RADIUS out, leaving the arc toward the map
## centre open as the single entrance, and skipping any point on a lane or in the river. Each kept
## point is paired with its mirror so an off-axis camp's pocket and its partner camp's match, and
## an on-axis camp's pocket comes out symmetric.
static func _camp_pocket_points(out: PackedVector2Array, camp: Vector2) -> void:
var gap_dir := -camp.normalized() if camp.length() > 0.1 else Vector2.RIGHT
for i in CAMP_POCKET_POINTS:
var ang := TAU * float(i) / float(CAMP_POCKET_POINTS)
var d := Vector2(cos(ang), sin(ang))
if d.dot(gap_dir) > CAMP_POCKET_GAP:
continue # the entrance opening toward the centre
var p := camp + d * CAMP_POCKET_RADIUS
if _dist_to_lanes(p) < CAMP_FEATURE_CLEAR or _dist_to_polyline(p, RIVER) < CAMP_FEATURE_CLEAR:
continue
if _dist_to_spawns(p) < WALL_SPAWN_CLEAR:
continue
out.append(p)
out.append(mirror(p))
## Whether a unit of the given body radius standing at `p` would overlap any obstacle — used by the
## chase router and the nav-grid bake to test a point for free space.
static func point_blocked(p: Vector2, body_radius: float) -> bool:
for o in obstacles():
if p.distance_to(o["center"]) < o["radius"] + body_radius:
return true
return false
## Resolves a desired move out of the obstacles: given the step from `from` to `to`, pushes `to`
## back to the surface of any obstacle it would enter, keeping the tangential slide along it. A few
## passes settle the overlapping circles of a wall and its corners. Pure and deterministic — the
## same math the server and a predicting client both run, so reconciliation lands exactly. `to` is
## assumed already inside the map bounds.
static func slide(from: Vector2, to: Vector2, body_radius: float) -> Vector2:
var pos := to
for _pass in 4:
var moved := false
for o in obstacles():
var center: Vector2 = o["center"]
var min_dist: float = o["radius"] + body_radius
var offset := pos - center
var dist := offset.length()
if dist >= min_dist:
continue
if dist > 0.0001:
pos = center + offset / dist * min_dist # out to the surface, keeping the slide
else:
var away := (from - center)
away = away.normalized() if away.length() > 0.0001 else Vector2.RIGHT
pos = center + away * min_dist
moved = true
if not moved:
break
return pos
## The shortest distance from `p` to any of the map's structures (towers and nexuses) — the
## clearance the wall generator keeps so a rock never swallows a building.
static func _dist_to_structures(p: Vector2) -> float:
var best := INF
for team in NEXUS_POSITIONS.size():
for slot in tower_positions(team):
best = minf(best, p.distance_to(slot))
best = minf(best, p.distance_to(nexus_for_team(team)))
return best
## The shortest distance from `p` to either team's base fountain — the clearance the walls keep so
## no rock ever boxes a team in at spawn.
static func _dist_to_spawns(p: Vector2) -> float:
var best := INF
for team in NEXUS_POSITIONS.size():
best = minf(best, p.distance_to(spawn_for_team(team)))
return best
## The shortest distance from `p` to any lane corridor — the clearance the camp pockets keep so a
## pocket rock never lands on a travelled lane.
static func _dist_to_lanes(p: Vector2) -> float:
var best := INF
for lane in LANES:
best = minf(best, _dist_to_polyline(p, lane))
return best
## The shortest distance from `p` to a polyline (a lane or the river), as the minimum over its
## segments. A point-to-segment distance, projected and clamped to each segment.
static func _dist_to_polyline(p: Vector2, poly: Array) -> float:
var best := INF
for i in poly.size() - 1:
best = minf(best, _dist_to_segment(p, poly[i], poly[i + 1]))
return best
static func _dist_to_segment(p: Vector2, a: Vector2, b: Vector2) -> float:
var ab := b - a
var len_sq := ab.length_squared()
if len_sq < 0.0001:
return p.distance_to(a)
var t := clampf((p - a).dot(ab) / len_sq, 0.0, 1.0)
return p.distance_to(a + ab * t)
added src/sim/nav_grid.gd
@@ -0,0 +1,320 @@
class_name NavGrid
extends RefCounted
## Deterministic grid pathfinding over the arena's obstacles — the routing behind click-to-move
## and the bots' approach, so a unit threads the jungle walls and rounds the towers instead of
## walking into them.
##
## A coarse occupancy grid is baked once from MapData.obstacles() (the map is static), a cell
## marked blocked when a unit centred there would overlap an obstacle. `find_path` runs an
## 8-connected A* with integer costs and an index tie-break, then a line-of-sight string-pull, so
## the same call yields a byte-identical path on every machine. Pure GDScript — no engine, render,
## or NavigationServer coupling — so it lives in the sim layer beside the rest of the deterministic
## core: a bot's route is part of the replayable simulation, and a client predicts its own with the
## same math. Built around MapData and SimCore.UNIT_RADIUS, the same footprint the collision uses,
## so a routed path and the movement resolve agree.
## Grid cell size in world units. Fine enough to thread the wall gaps (~490 between the rocks that
## bound a gank opening), coarse enough that a full-map search stays cheap.
const CELL := 128.0
## Integer step costs (orthogonal / diagonal ≈ 1 : √2) — integers keep A* fully deterministic.
const COST_ORTHO := 10
const COST_DIAG := 14
## A stand-in for an unreached g-score; larger than any real path cost on this grid.
const INF_COST := 1 << 30
## The eight grid neighbours as (dcol, drow, step-cost) — precomputed so the A* inner loop allocates
## nothing per cell.
const NEIGHBOURS: Array[Vector3i] = [
Vector3i(-1, -1, COST_DIAG),
Vector3i(0, -1, COST_ORTHO),
Vector3i(1, -1, COST_DIAG),
Vector3i(-1, 0, COST_ORTHO),
Vector3i(1, 0, COST_ORTHO),
Vector3i(-1, 1, COST_DIAG),
Vector3i(0, 1, COST_ORTHO),
Vector3i(1, 1, COST_DIAG),
]
static var _shared: NavGrid = null
var _cols: int = 0
var _rows: int = 0
var _origin: Vector2 = Vector2.ZERO
var _blocked: PackedByteArray = PackedByteArray()
# Scratch reused across an A* run, sized to the grid in `_init`.
var _g: PackedInt32Array = PackedInt32Array()
var _came: PackedInt32Array = PackedInt32Array()
var _closed: PackedByteArray = PackedByteArray()
var _heap_f: PackedInt32Array = PackedInt32Array()
var _heap_i: PackedInt32Array = PackedInt32Array()
func _init() -> void:
var bounds := MapData.BOUNDS
_origin = bounds.position
_cols = int(ceil(bounds.size.x / CELL))
_rows = int(ceil(bounds.size.y / CELL))
_bake()
## The lazily-baked shared grid. The layout is a pure function of MapData's static geometry, so one
## bake serves the whole process and stays deterministic.
static func shared() -> NavGrid:
if _shared == null:
_shared = NavGrid.new()
return _shared
## Marks every cell a unit could not stand in (its centre, inflated by the body radius, overlaps an
## obstacle). Stamps each obstacle's bounding box rather than testing every cell against every
## obstacle, so the bake is a few thousand checks instead of millions. The grid uses the same
## SimCore.UNIT_RADIUS the collision does, so a free cell is a spot the resolve also accepts.
func _bake() -> void:
var total := _cols * _rows
_blocked.resize(total)
_blocked.fill(0)
for o in MapData.obstacles():
var center: Vector2 = o["center"]
var reach: float = o["radius"] + SimCore.UNIT_RADIUS
var min_col := clampi(int((center.x - reach - _origin.x) / CELL), 0, _cols - 1)
var max_col := clampi(int((center.x + reach - _origin.x) / CELL), 0, _cols - 1)
var min_row := clampi(int((center.y - reach - _origin.y) / CELL), 0, _rows - 1)
var max_row := clampi(int((center.y + reach - _origin.y) / CELL), 0, _rows - 1)
for row in range(min_row, max_row + 1):
for col in range(min_col, max_col + 1):
var i := row * _cols + col
if _blocked[i] == 0 and _cell_center(i).distance_to(center) < reach:
_blocked[i] = 1
## A path of world waypoints from `from` to `to` that avoids the obstacles, or an empty array when
## the goal is unreachable (the caller then falls back to a direct move). The returned points lead
## from the first turn to the destination — the hero, already at `from`, walks toward waypoint 0.
## A clear straight line short-circuits to the single point `to`; otherwise A* routes the grid and a
## line-of-sight pass pulls the staircase taut. If `to` sits in an obstacle the route ends at the
## nearest free cell, but a clear final leg still lands on the real `to`.
func find_path(from: Vector2, to: Vector2) -> PackedVector2Array:
if segment_clear(from, to):
var direct := PackedVector2Array()
direct.append(to)
return direct
var start_i := _nearest_free(_cell_of(from))
var goal_i := _nearest_free(_cell_of(to))
if start_i < 0 or goal_i < 0:
return PackedVector2Array()
if not _astar(start_i, goal_i):
return PackedVector2Array()
var cells := _reconstruct(start_i, goal_i)
# Turn the cell chain (minus the start cell the unit already occupies) into world points,
# landing the last leg on the real `to` when its cell was free rather than the cell centre.
var raw := PackedVector2Array()
for k in range(1, cells.size()):
raw.append(_cell_center(cells[k]))
if raw.size() > 0 and _cell_of(to) == goal_i:
raw[raw.size() - 1] = to
return _smooth(from, raw)
# --- A* ------------------------------------------------------------------------------------
## Runs the search from `start_i` to `goal_i`, filling `_came`. Returns whether the goal was
## reached. Lazy heap with a `_closed` set: the first time a cell is popped it has its lowest f
## (the octile heuristic is consistent), so it is finalised once; later stale heap entries are
## skipped. Costs and the heuristic are integers and the heap breaks ties by cell index, so the
## search is deterministic.
func _astar(start_i: int, goal_i: int) -> bool:
var total := _cols * _rows
_g = PackedInt32Array()
_g.resize(total)
_g.fill(INF_COST)
_came = PackedInt32Array()
_came.resize(total)
_came.fill(-1)
_closed = PackedByteArray()
_closed.resize(total)
_heap_f = PackedInt32Array()
_heap_i = PackedInt32Array()
_g[start_i] = 0
_heap_push(_heuristic(start_i, goal_i), start_i)
while _heap_i.size() > 0:
var cur := _heap_pop()
if cur == goal_i:
return true
if _closed[cur] == 1:
continue
_closed[cur] = 1
var col := cur % _cols
var row := cur / _cols
for nb in NEIGHBOURS:
var nc := col + nb.x
var nr := row + nb.y
if nc < 0 or nc >= _cols or nr < 0 or nr >= _rows:
continue
var n := nr * _cols + nc
if _blocked[n] == 1 or _closed[n] == 1:
continue
if nb.x != 0 and nb.y != 0:
# No corner-cutting: a diagonal needs both flanking orthogonals open, so a unit
# never squeezes through a one-cell diagonal slit in a wall.
if _blocked[row * _cols + nc] == 1 or _blocked[nr * _cols + col] == 1:
continue
var tentative := _g[cur] + nb.z
if tentative < _g[n]:
_g[n] = tentative
_came[n] = cur
_heap_push(tentative + _heuristic(n, goal_i), n)
return false
## The octile heuristic between two cells, in the same integer units as the step costs and
## admissible/consistent for 8-connected movement — `10·(dx+dy) − 6·min(dx,dy)`.
func _heuristic(a: int, b: int) -> int:
var dx: int = absi(a % _cols - b % _cols)
var dy: int = absi(a / _cols - b / _cols)
return COST_ORTHO * (dx + dy) - (2 * COST_ORTHO - COST_DIAG) * mini(dx, dy)
## Walks `_came` back from the goal to the start, returning the cell chain start→goal.
func _reconstruct(start_i: int, goal_i: int) -> PackedInt32Array:
var rev := PackedInt32Array()
var cur := goal_i
while cur != -1:
rev.append(cur)
if cur == start_i:
break
cur = _came[cur]
var out := PackedInt32Array()
for k in range(rev.size() - 1, -1, -1):
out.append(rev[k])
return out
# --- min-heap (f, index) -------------------------------------------------------------------
# A binary min-heap ordered by f-score, breaking ties by the lower cell index so the search is
# deterministic. Kept as two parallel packed arrays to avoid per-entry allocation.
func _heap_push(f: int, idx: int) -> void:
_heap_f.append(f)
_heap_i.append(idx)
var c := _heap_f.size() - 1
while c > 0:
var p := (c - 1) >> 1
if _heap_less(c, p):
_heap_swap(c, p)
c = p
else:
break
func _heap_pop() -> int:
var top := _heap_i[0]
var last := _heap_f.size() - 1
_heap_f[0] = _heap_f[last]
_heap_i[0] = _heap_i[last]
_heap_f.remove_at(last)
_heap_i.remove_at(last)
var n := _heap_f.size()
var c := 0
while true:
var l := 2 * c + 1
var r := 2 * c + 2
var smallest := c
if l < n and _heap_less(l, smallest):
smallest = l
if r < n and _heap_less(r, smallest):
smallest = r
if smallest == c:
break
_heap_swap(c, smallest)
c = smallest
return top
## Whether heap entry `a` orders before `b`: lower f first, then the lower cell index — the
## deterministic tie-break that pins one path among equal-cost routes.
func _heap_less(a: int, b: int) -> bool:
if _heap_f[a] != _heap_f[b]:
return _heap_f[a] < _heap_f[b]
return _heap_i[a] < _heap_i[b]
func _heap_swap(a: int, b: int) -> void:
var tf := _heap_f[a]
_heap_f[a] = _heap_f[b]
_heap_f[b] = tf
var ti := _heap_i[a]
_heap_i[a] = _heap_i[b]
_heap_i[b] = ti
# --- geometry helpers ----------------------------------------------------------------------
## The cell index containing a world point, clamped into the grid so an out-of-bounds point maps to
## the nearest edge cell.
func _cell_of(p: Vector2) -> int:
var col := clampi(int((p.x - _origin.x) / CELL), 0, _cols - 1)
var row := clampi(int((p.y - _origin.y) / CELL), 0, _rows - 1)
return row * _cols + col
func _cell_center(i: int) -> Vector2:
var col := i % _cols
var row := i / _cols
return _origin + Vector2((float(col) + 0.5) * CELL, (float(row) + 0.5) * CELL)
## The nearest free cell to `i`, searched in rings of growing Chebyshev radius (deterministic
## row-then-column order within a ring). Returns `i` itself when already free, or -1 if the whole
## grid is blocked (it never is).
func _nearest_free(i: int) -> int:
if _blocked[i] == 0:
return i
var col := i % _cols
var row := i / _cols
var max_r := maxi(_cols, _rows)
for r in range(1, max_r + 1):
for dr in range(-r, r + 1):
for dc in range(-r, r + 1):
if absi(dr) != r and absi(dc) != r:
continue # only the ring's perimeter
var nc := col + dc
var nr := row + dr
if nc < 0 or nc >= _cols or nr < 0 or nr >= _rows:
continue
if _blocked[nr * _cols + nc] == 0:
return nr * _cols + nc
return -1
## A line-of-sight string-pull over the raw waypoints: from the unit's position, greedily keep the
## farthest point still reachable in a clear straight line, so the cell-stepped path is pulled into
## a few long legs instead of a staircase.
func _smooth(from: Vector2, pts: PackedVector2Array) -> PackedVector2Array:
var out := PackedVector2Array()
var anchor := from
var i := 0
while i < pts.size():
var j := i
var k := i
while k < pts.size() and segment_clear(anchor, pts[k]):
j = k
k += 1
out.append(pts[j])
anchor = pts[j]
i = j + 1
return out
## Whether the straight segment a→b stays on free cells, sampled at half-cell steps (obstacles span
## several cells, so nothing slips between samples). Reads the baked grid — an O(1) lookup per
## sample instead of testing every obstacle — so it is cheap enough to call each tick.
func segment_clear(a: Vector2, b: Vector2) -> bool:
var delta := b - a
var steps := maxi(1, int(ceil(delta.length() / (CELL * 0.5))))
for s in steps + 1:
if _blocked[_cell_of(a + delta * (float(s) / float(steps)))] == 1:
return false
return true
added src/sim/nav_grid.gd.uid
@@ -0,0 +1 @@
uid://qn16qwcou3hf
modified src/sim/sim_core.gd
@@ -11,6 +11,12 @@ extends RefCounted
const TICK_RATE := 60
const TICK_DELTA := 1.0 / TICK_RATE
## A hero's body radius for collision — how far its centre is kept from an obstacle's surface. One
## shared value for v1: the mobile non-creep units (heroes) collide as they move; lane creeps march
## uncollided so a wave is never jammed on its own forward tower, and structures never move. Read by
## `apply_movement` here and by the nav grid, so a routed path and the resolve agree.
const UNIT_RADIUS := 40.0
## Tower combat tuning. A tower out-ranges and chips a unit that wanders into it,
## but takes many shots to kill — pressure, not an instant wall.
const TOWER_HP := 1000
@@ -215,8 +221,17 @@ static func apply_movement(entity: SimEntity, command: InputCommand) -> void:
move_dir = command.move_dir
if move_dir.length() > 1.0:
move_dir = move_dir.normalized()
var from := entity.position
entity.position += move_dir * entity.current_move_speed() * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
# Resolve a moving unit out of the solid obstacles, keeping the tangential slide along them. The
# gate is the same "mobile, non-creep" predicate the client identifies its hero by (main.gd
# `_local_hero`), so the decoded snapshot the client predicts on — which carries no is_hero flag —
# runs byte-identical math to the server and reconciliation lands exactly. Lane creeps march
# uncollided (so a wave never jams on its own forward tower) and a still unit is never shoved off
# its spot — collision resolves movement, not placement.
if move_dir != Vector2.ZERO and not entity.is_structure and not entity.is_creep:
entity.position = MapData.slide(from, entity.position, UNIT_RADIUS)
## On a wave tick, spawns one creep wave per team per lane. Driven off
modified test/unit/test_bot_controller.gd
@@ -236,3 +236,23 @@ func test_a_brawler_closes_a_point_blank_enemy() -> void:
sim.add_entity(1, Vector2(150.0, 0.0), 0.0, 600)
var command := _bot().decide(sim.state, id)
assert_gt(command.move_dir.x, 0.0, "a brawler closes the gap rather than kiting away")
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 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
var bot_pos := sim.state.get_entity(bot).position
var nav := NavGrid.shared()
assert_false(nav.segment_clear(bot_pos, enemy_pos), "the straight line runs through the obstacle")
var command := _bot().decide(sim.state, bot)
var straight := (enemy_pos - bot_pos).normalized()
assert_gt(command.move_dir.length(), 0.0, "the bot advances on the enemy")
assert_gt(command.move_dir.distance_to(straight), 0.01, "it steers around, not straight in")
assert_true(
nav.segment_clear(bot_pos, bot_pos + command.move_dir * 300.0),
"the step it takes is onto clear ground",
)
modified test/unit/test_map_data.gd
@@ -170,3 +170,48 @@ func test_each_team_holds_four_towers() -> void:
# Two towers ring the nexus and two stand forward down the lanes — four a side, mirrored.
assert_eq(MapData.tower_positions(0).size(), 4, "each team fields four towers")
assert_eq(MapData.tower_positions(1).size(), 4, "both teams field the same count")
# --- collision obstacles ---------------------------------------------------------------------
func test_obstacles_are_mirror_symmetric() -> void:
var obs := MapData.obstacles()
assert_gt(obs.size(), 0, "the map has collision obstacles")
for o in obs:
var mirrored: Vector2 = MapData.mirror(o["center"])
var found := false
for q in obs:
if q["center"].distance_to(mirrored) < 0.01 and absf(q["radius"] - o["radius"]) < 0.01:
found = true
break
assert_true(found, "every obstacle has its y = x mirror in the set — collision is team-fair")
func test_spawns_and_camp_centres_are_walkable() -> void:
for team in MapData.NEXUS_POSITIONS.size():
assert_false(
MapData.point_blocked(MapData.spawn_for_team(team), SimCore.UNIT_RADIUS),
"a team spawns on free ground, never walled in",
)
for camp in MapData.JUNGLE_CAMPS:
assert_false(
MapData.point_blocked(camp, SimCore.UNIT_RADIUS),
"a camp's interior stays clear of its own pocket rocks",
)
func test_slide_pushes_a_unit_out_of_an_obstacle_and_leaves_free_ground_alone() -> void:
var obs := MapData.obstacles()
var center: Vector2 = obs[0]["center"]
var radius: float = obs[0]["radius"]
# Driven straight at the obstacle's centre, the unit is stopped outside it.
var from := center + Vector2(radius + 500.0, 0.0)
var resolved := MapData.slide(from, center, SimCore.UNIT_RADIUS)
assert_gt(resolved.distance_to(center), radius, "slide keeps a unit out of the obstacle it enters")
# A move that ends on open ground (the map centre is clear) is returned untouched.
assert_eq(
MapData.slide(Vector2.ZERO, Vector2(5.0, 0.0), SimCore.UNIT_RADIUS),
Vector2(5.0, 0.0),
"slide leaves a clear destination unchanged",
)
added test/unit/test_nav_grid.gd
@@ -0,0 +1,74 @@
extends GutTest
## The deterministic grid pathfinder. These pin the routing contract the client and the bots lean
## on: a clear line is a straight shot, a blocked line routes around the obstacle on a
## collision-free path, a target inside an obstacle resolves to free ground, and the same query
## always yields the same path (so a bot's route replays identically). Pure data checks — no engine
## coupling, the same discipline as the rest of the sim tests.
func test_clear_line_is_a_direct_single_waypoint() -> void:
var nav := NavGrid.new()
var from := Vector2(0.0, 0.0)
var to := Vector2(200.0, 0.0) # open ground near the map centre
var path := nav.find_path(from, to)
assert_eq(path.size(), 1, "a clear line needs no intermediate waypoints")
assert_eq(path[0], to, "the single waypoint is the destination itself")
func test_from_equals_to_returns_the_point() -> void:
var nav := NavGrid.new()
var p := Vector2(0.0, 0.0)
var path := nav.find_path(p, p)
assert_eq(path.size(), 1, "a zero-length move is one waypoint")
assert_eq(path[0], p)
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 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")
var path := nav.find_path(from, to)
assert_gt(path.size(), 0, "a routable goal yields a path")
# Every leg of the realised route (from the unit's position through the waypoints) is clear.
var prev := from
for w in path:
assert_true(nav.segment_clear(prev, w), "each leg of the route avoids the obstacles")
prev = w
assert_lt(
path[path.size() - 1].distance_to(to),
NavGrid.CELL * 2.0,
"the route ends on the requested destination",
)
func test_target_inside_an_obstacle_resolves_to_free_ground() -> void:
var nav := NavGrid.new()
var center := MapData.tower_positions(0)[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")
var last := path[path.size() - 1]
assert_false(
MapData.point_blocked(last, SimCore.UNIT_RADIUS),
"the route ends on free ground, not inside the obstacle",
)
func test_same_query_yields_an_identical_path() -> void:
var nav := NavGrid.new()
var center := MapData.tower_positions(0)[0]
var from := center + Vector2(700.0, 0.0)
var to := center - Vector2(700.0, 0.0)
var a := nav.find_path(from, to)
var b := nav.find_path(from, to)
assert_eq(a, b, "pathfinding is deterministic — the same query is byte-identical")
func test_each_spawn_can_route_toward_the_enemy_base() -> void:
# The map stays traversable: a team can always route from its base to the enemy's.
var nav := NavGrid.new()
for team in MapData.NEXUS_POSITIONS.size():
var path := nav.find_path(MapData.spawn_for_team(team), MapData.spawn_for_team(1 - team))
assert_gt(path.size(), 0, "a team can route from its base toward the enemy base")
added test/unit/test_nav_grid.gd.uid
@@ -0,0 +1 @@
uid://b5y1rhg7vou11
modified test/unit/test_prediction.gd
@@ -98,6 +98,50 @@ func test_apply_movement_holds_still_on_null_command() -> void:
assert_eq(entity.position, Vector2(10.0, -5.0), "a null command moves nothing")
# --- collision: a moving unit is blocked, and prediction matches the server through it ----------
func test_a_moving_hero_stops_at_an_obstacle_edge() -> void:
var center := MapData.tower_positions(0)[0]
var sim := SimCore.new()
sim.spawn_creeps = false
var hero := sim.add_hero(0, center + Vector2(600.0, 0.0), 320.0)
for _i in 300:
sim.step({hero: _command(Vector2.LEFT)}) # drive straight at the obstacle
var pos := sim.state.get_entity(hero).position
assert_false(
MapData.point_blocked(pos, SimCore.UNIT_RADIUS),
"a hero driven into an obstacle never ends up inside it",
)
assert_gt(pos.x, center.x, "it is stopped on its approach side, not pushed through")
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 inputs: Array = []
for _i in 30:
inputs.append(_command(Vector2.LEFT))
var acked := 15
var server := SimCore.new()
server.spawn_creeps = false
var hero_id := server.add_hero(0, start, 320.0)
for i in acked:
server.step({hero_id: inputs[i]})
var snapshot := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(server.state, acked))
var predicted := snapshot.get_entity(hero_id)
for i in range(acked, inputs.size()):
SimCore.apply_movement(predicted, inputs[i])
for i in range(acked, inputs.size()):
server.step({hero_id: inputs[i]})
assert_eq(
predicted.position,
server.state.get_entity(hero_id).position,
"prediction with collision lands exactly on the authoritative position",
)
func _command(dir: Vector2) -> InputCommand:
var command := InputCommand.new()
command.move_dir = dir