ajhahn.de
← Theria
GDScript 436 lines
class_name MapData
extends RefCounted
## Static geometry for the 3v3 arena.
##
## The map is axially symmetric across the TL–BR diagonal — the line y = x: team 1's
## geometry is team 0's reflected across that axis, (x, y) → (y, x), which swaps the two
## bases so neither side has a positional edge. The simulation reads this layer as pure
## data — bounds, the team bases, the lane corridors, the river, 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. Sized to about 65% of a 5v5 map's
## side length — a tighter arena for 3v3.
const BOUNDS := Rect2(-4800.0, -4800.0, 9600.0, 9600.0)

## 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. Axially symmetric: index 1 is index 0 mirrored
## across the y = x axis.
const NEXUS_POSITIONS: Array[Vector2] = [
	Vector2(-3840.0, 3840.0),
	Vector2(3840.0, -3840.0),
]

## How far in front of the nexus a team's heroes spawn — a fountain pulled toward
## the map centre so a hero starts at its base without sitting on the nexus.
const FOUNTAIN_PULLBACK := 720.0

## Lateral gap between squadmates fanned across a base fountain, so a full team
## spawns side by side instead of stacked on one point.
const SQUAD_SPACING := 150.0

## Top corridor: out of team 0's base, bulging up the left side, into team 1's base. It is its
## own reflection across the y = x axis (its midpoint sits on the axis), so it is team-fair.
const LANE_TOP: Array[Vector2] = [
	Vector2(-3840.0, 3840.0),
	Vector2(-2640.0, -240.0),
	Vector2(-2160.0, -1440.0),
	Vector2(-1440.0, -2160.0),
	Vector2(-240.0, -2640.0),
	Vector2(3840.0, -3840.0),
]

## Bottom corridor: out of team 0's base, bulging down the right side, into team 1's base. Like
## the top corridor it is its own reflection across the y = x axis, so the two teams meet it
## the same way.
const LANE_BOTTOM: Array[Vector2] = [
	Vector2(-3840.0, 3840.0),
	Vector2(1200.0, 3720.0),
	Vector2(2280.0, 3480.0),
	Vector2(3000.0, 3000.0),
	Vector2(3480.0, 2280.0),
	Vector2(3720.0, 1200.0),
	Vector2(3840.0, -3840.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. Each corridor is its own reflection across the y = x axis, so walking it forward
## (team 0) and reversed (team 1) are mirror experiences. 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 ground between the lanes. Closed under the axis
## mirror (every off-axis camp has a partner across y = x; a camp on the axis is its own), so the
## neutral layer stays team-fair: each team reaches the same camps the same way.
const JUNGLE_CAMPS: Array[Vector2] = [
	Vector2(1080.0, 1080.0),
	Vector2(150.0, 2450.0),
	Vector2(-3360.0, -3360.0),
	Vector2(2450.0, 150.0),
	Vector2(1550.0, -1700.0),
	Vector2(-1700.0, 1550.0),
]

## The river: a single watercourse that meanders across the middle of the map, its apex sitting
## on the y = x axis. Stored as a polyline; reflecting it across the axis and reversing maps it
## onto itself, so it divides the map evenly and neither team has more water in its half. Any
## barrier rule that makes the banks block movement is a later step; for now this is just the
## geometry the map is drawn from.
const RIVER: Array[Vector2] = [
	Vector2(720.0, -4800.0),
	Vector2(-200.0, -3900.0),
	Vector2(-550.0, -3550.0),
	Vector2(-750.0, -3200.0),
	Vector2(-850.0, -2800.0),
	Vector2(-800.0, -2400.0),
	Vector2(-550.0, -2100.0),
	Vector2(-300.0, -1800.0),
	Vector2(-200.0, -1500.0),
	Vector2(-200.0, -1100.0),
	Vector2(-300.0, -750.0),
	Vector2(-500.0, -500.0),
	Vector2(-750.0, -300.0),
	Vector2(-1100.0, -200.0),
	Vector2(-1500.0, -200.0),
	Vector2(-1800.0, -300.0),
	Vector2(-2100.0, -550.0),
	Vector2(-2400.0, -800.0),
	Vector2(-2800.0, -850.0),
	Vector2(-3200.0, -750.0),
	Vector2(-3550.0, -550.0),
	Vector2(-3900.0, -200.0),
	Vector2(-4800.0, 720.0),
]

## Every defensive tower slot on the map, as one axially-symmetric set (each off-axis slot has a
## partner across y = x). Per team: two towers ringing the nexus and two forward towers down the
## lanes — four towers a side. `tower_positions` hands a team the slots on its own side of the
## axis, so the two teams field mirror-image defences without either holding an extra tower.
## (The two forward slots are where a future "ford" river-crossing could replace a tower — that
## idea is deferred; for now they are plain towers.)
const TOWER_SLOTS: Array[Vector2] = [
	Vector2(2520.0, -3480.0),
	Vector2(-3480.0, 2520.0),
	Vector2(-2520.0, 3840.0),
	Vector2(3840.0, -2520.0),
	Vector2(-2640.0, -240.0),
	Vector2(-240.0, -2640.0),
	Vector2(840.0, 3720.0),
	Vector2(3720.0, 840.0),
]

# === Collision obstacles ===================================================================
## 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, 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.

## 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
## 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 = []

## Lazily-built cache of the sight-blocking circles (the jungle walls only) — baked once and reused
## by the per-team vision pass. Separate from `_obstacles` because vision and collision block on
## different sets: a tower stops a body but not a sight line.
static var _vision_blockers: 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.
static func mirror(p: Vector2) -> Vector2:
	return Vector2(p.y, p.x)


## A team's hero spawn: its base fountain, set just in front of the nexus toward
## the map centre. Derived from `NEXUS_POSITIONS`, so the two teams' spawns mirror
## across the y = x axis like the rest of the map.
static func spawn_for_team(team: int) -> Vector2:
	var nexus := nexus_for_team(team)
	return nexus - nexus.normalized() * FOUNTAIN_PULLBACK


static func nexus_for_team(team: int) -> Vector2:
	return NEXUS_POSITIONS[team % NEXUS_POSITIONS.size()]


## A squadmate's spawn within its team's roster of `count`, fanned laterally across the base
## fountain so the team starts side by side rather than stacked. `index` runs 0..count-1; the fan
## is centred on the fountain and laid out along the axis perpendicular to the base→centre
## direction. Team 1's seats are team 0's mirrored across y = x, so neither side has an edge.
static func squad_spawn(team: int, index: int, count: int) -> Vector2:
	var seat := _squad_seat_team0(index, count)
	return seat if team % 2 == 0 else mirror(seat)


## Team 0's squad seat for `index` of `count`, the bare geometry the mirror is taken from.
static func _squad_seat_team0(index: int, count: int) -> Vector2:
	var fountain := spawn_for_team(0)
	if count <= 1:
		return fountain
	var inward := -nexus_for_team(0).normalized()  # base toward the map centre
	var lateral := Vector2(-inward.y, inward.x)  # perpendicular to the inward axis
	var offset := float(index) - float(count - 1) * 0.5
	return clamp_to_bounds(fountain + lateral * (offset * SQUAD_SPACING))


## The tower slots for `team`: the stored slots on team 0's side of the y = x axis, handed back
## as-is for team 0 and mirrored for team 1, so the two teams' defences are reflections of each
## other. Returns a fresh copy so callers cannot mutate the stored geometry.
static func tower_positions(team: int) -> PackedVector2Array:
	return _team_side(TOWER_SLOTS, team)


## The members of an axially-symmetric point set that fall on team 0's side of the y = x axis
## (where y > x), handed back as-is for team 0 and mirrored for team 1. A point on the axis
## itself belongs to neither side and is dropped — these are per-team defences, not neutral.
static func _team_side(points: Array[Vector2], team: int) -> PackedVector2Array:
	var out := PackedVector2Array()
	for p in points:
		if p.y > p.x:  # team 0's side of the diagonal
			out.append(p if team % 2 == 0 else mirror(p))
	return out


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


## The river polyline, as a fresh copy so callers cannot mutate the stored course.
static func river_polyline() -> PackedVector2Array:
	return PackedVector2Array(RIVER)


static func clamp_to_bounds(pos: Vector2) -> Vector2:
	return 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 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()
	return _obstacles


static func _build_obstacles() -> Array:
	var out: Array = []
	for team in NEXUS_POSITIONS.size():
		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 sight-blocking geometry for fog of war: the jungle rock walls and camp pockets only — a
## tower or the nexus blocks a body but never a sight line, so the structures are deliberately left
## out (you can see past your own buildings, as in the genre). Each a `{center, radius}` circle, the
## same wall footprints `obstacles` uses, mirrored across the axis so vision is team-fair, and baked
## once (the map is static). Read by Vision for line-of-sight occlusion; callers treat it read-only.
static func vision_blockers() -> Array:
	if _vision_blockers.is_empty():
		for p in jungle_wall_points():
			_vision_blockers.append({"center": p, "radius": WALL_RADIUS})
	return _vision_blockers


## 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)