ajhahn.de
← Theria commits

Commit

Theria

feat: add lane and jungle geometry to the arena map

ajhahnde · Jun 2026 · 76f2839bb94ce4db04d46d748384cb5441d5a16a · parent: d396059 · view on GitHub →

modified src/sim/map_data.gd
@@ -2,24 +2,90 @@ class_name MapData
extends RefCounted
## Static geometry for the 3v3 arena.
##
## v0.1 walking skeleton: a single bounded play field with one spawn per team.
## Lane and jungle layout layers on later as data; the only contract the
## simulation relies on now is the playable bounds and the team spawns.
## The map is point-symmetric through the origin: team 1's geometry is team 0's
## negated, so every structure has a mirrored counterpart and neither side has a
## positional edge. The simulation reads this layer as pure data — bounds, the
## team bases, the lane corridors, and the neutral jungle camps — with no engine
## or render coupling, so bots, tests, and (later) the netcode share one source
## of truth.
## Playable area, in world units, centred on the origin.
const BOUNDS := Rect2(-2000.0, -2000.0, 4000.0, 4000.0)
## Spawn position for each team, indexed by team id.
## The nexus position for each team, indexed by team id. The nexus is the win
## condition (destroyed to end the match) and the inner anchor both lane
## corridors converge on at a base. Point-symmetric: index 1 is index 0 negated.
const NEXUS_POSITIONS: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(1600.0, -1600.0),
]
## Hero spawn position for each team, indexed by team id.
##
## These are the v0.1 walking-skeleton spawns near the centre, kept stable so the
## skeleton demo is undisturbed by this layer. Aligning spawns to a fountain at
## each base is a later slice (it lands with the client camera that frames the
## full map); the base anchor for that work is `NEXUS_POSITIONS`.
const TEAM_SPAWNS: Array[Vector2] = [
Vector2(-360.0, -200.0),
Vector2(360.0, 200.0),
]
## Top corridor: out of team 0's base, up the left edge, across the top.
const LANE_TOP: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(-1600.0, -1600.0),
Vector2(1600.0, -1600.0),
]
## Bottom corridor: out of team 0's base, across the bottom, up the right edge.
const LANE_BOTTOM: Array[Vector2] = [
Vector2(-1600.0, 1600.0),
Vector2(1600.0, 1600.0),
Vector2(1600.0, -1600.0),
]
## The lane corridors, each a polyline stored from team 0's nexus to team 1's
## nexus. Both teams push both corridors in opposite directions; `lane_path`
## orients a corridor for a given team's travel. The two corridors are point
## reflections of each other (negate-and-reverse maps one onto the other), so the
## map stays mirror-fair. v0.1 is two lanes plus the jungle — there is no mid.
const LANES: Array[Array] = [LANE_TOP, LANE_BOTTOM]
## Neutral jungle camp positions in the open band between the lanes. Closed under
## negation (every camp has a mirrored partner; the centre camp is its own), so
## the neutral layer is symmetric like the rest of the map.
const JUNGLE_CAMPS: Array[Vector2] = [
Vector2(0.0, 0.0),
Vector2(-500.0, -500.0),
Vector2(500.0, 500.0),
Vector2(500.0, -500.0),
Vector2(-500.0, 500.0),
]
static func spawn_for_team(team: int) -> Vector2:
return TEAM_SPAWNS[team % TEAM_SPAWNS.size()]
static func nexus_for_team(team: int) -> Vector2:
return NEXUS_POSITIONS[team % NEXUS_POSITIONS.size()]
static func lane_count() -> int:
return LANES.size()
## A lane corridor's waypoints oriented for `team`'s travel: team 0 walks the
## stored order (its nexus first), team 1 walks it reversed (its nexus first).
## Returns a fresh copy so callers cannot mutate the stored geometry.
static func lane_path(lane: int, team: int) -> PackedVector2Array:
var path := PackedVector2Array(LANES[lane])
if team % 2 == 1:
path.reverse()
return path
static func clamp_to_bounds(pos: Vector2) -> Vector2:
return Vector2(
clampf(pos.x, BOUNDS.position.x, BOUNDS.end.x),
added test/unit/test_map_data.gd
@@ -0,0 +1,62 @@
extends GutTest
## Geometry contracts for the 3v3 arena. These pin the map's mirror-fairness and
## the lane orientation the creep layer will depend on. Pure data checks — no
## engine or render coupling, the same discipline as the sim-core tests.
func test_nexuses_are_point_symmetric_and_in_bounds() -> void:
var n0 := MapData.nexus_for_team(0)
var n1 := MapData.nexus_for_team(1)
assert_eq(n1, -n0, "team 1's nexus must be team 0's nexus negated")
assert_eq(MapData.clamp_to_bounds(n0), n0, "nexus 0 must sit inside the bounds")
assert_eq(MapData.clamp_to_bounds(n1), n1, "nexus 1 must sit inside the bounds")
func test_lane_path_orients_from_each_team_nexus_to_the_enemy_nexus() -> void:
for lane in MapData.lane_count():
var team0 := MapData.lane_path(lane, 0)
var team1 := MapData.lane_path(lane, 1)
assert_eq(team0[0], MapData.nexus_for_team(0), "team 0 starts at its own nexus")
assert_eq(team0[team0.size() - 1], MapData.nexus_for_team(1), "team 0 ends at the enemy nexus")
assert_eq(team1[0], MapData.nexus_for_team(1), "team 1 starts at its own nexus")
assert_eq(team1[team1.size() - 1], MapData.nexus_for_team(0), "team 1 ends at the enemy nexus")
func test_lane_path_for_each_team_is_the_reverse_of_the_other() -> void:
for lane in MapData.lane_count():
var forward := MapData.lane_path(lane, 0)
var reversed := MapData.lane_path(lane, 1)
reversed.reverse()
assert_eq(forward, reversed, "a corridor is one path walked in opposite directions")
func test_lane_path_returns_a_fresh_copy() -> void:
var first := MapData.lane_path(0, 0)
first.reverse()
var second := MapData.lane_path(0, 0)
assert_ne(first, second, "mutating a returned path must not alter the stored geometry")
func test_lanes_are_point_reflections_of_each_other() -> void:
# Negate-and-reverse must map the set of corridors onto itself, so the two
# lanes are mirror images and neither team has a shorter or safer route.
var canon: Array[PackedVector2Array] = []
for lane in MapData.lane_count():
canon.append(MapData.lane_path(lane, 0))
for path in canon:
var mirrored := PackedVector2Array()
for i in range(path.size() - 1, -1, -1):
mirrored.append(-path[i])
var found := false
for other in canon:
if other == mirrored:
found = true
break
assert_true(found, "every lane's point reflection must also be a lane")
func test_jungle_camps_are_closed_under_negation_and_in_bounds() -> void:
var camps := MapData.JUNGLE_CAMPS
for camp in camps:
assert_eq(MapData.clamp_to_bounds(camp), camp, "every camp must sit inside the bounds")
assert_true(camps.has(-camp), "every camp must have a mirrored partner (negated)")
added test/unit/test_map_data.gd.uid
@@ -0,0 +1 @@
uid://2vpw2v2e1ff