saumwacht-godot/scripts/luneraria/core/lune_map.gd
Martin Stickelbroeck f53859a298 M2: Helden, Items, Talente, Angriffschampions und Bossgrotte
Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:33:11 +02:00

176 lines
7.4 KiB
GDScript

class_name LuneMap
extends RefCounted
## Map data in ground coordinates Vector2(x, z): Blau (team 0) in the south-west,
## Rot (team 1) in the north-east. Rot is the mirror image of Blau across x = z
## (mirror(v) = Vector2(v.y, v.x)), Bottom is Top mirrored across the Mid line.
## Both hold by construction, so strecken, towers and spawns are functionally equal.
const LANES: Array[String] = ["top", "mid", "bot"]
const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"}
# First halves from the Blau end to the centre line; the Rot half is mirrored.
const TOP_HALF: Array[Vector2] = [Vector2(-24, 22), Vector2(-26, 6), Vector2(-26.5, -12), Vector2(-25, -25)]
const MID_HALF: Array[Vector2] = [Vector2(-21, 21), Vector2(-11, 10), Vector2(0, 0)]
const CORE_BLUE := Vector2(-30, 30)
const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-28.8, 24.6), Vector2(-24.6, 28.8)]
## Neutral boss grotto, north-west of Mid and on the symmetry axis (M2).
const GROTTO := Vector2(-11.5, -11.5)
const HALF_EXTENT: float = 36.0
var balance: LuneBalance
var lanes: Dictionary = {}
var buildings: Array[Dictionary] = []
class LanePath:
extends RefCounted
var points: PackedVector2Array
var cumulative: PackedFloat32Array
var length: float = 0.0
func _init(source: Array[Vector2]) -> void:
points = PackedVector2Array(source)
cumulative.append(0.0)
for i: int in range(1, points.size()):
length += points[i].distance_to(points[i - 1])
cumulative.append(length)
func point_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return points[i - 1].lerp(points[i], clampf((d - cumulative[i - 1]) / span, 0.0, 1.0))
return points[points.size() - 1]
func tangent_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
return (points[i] - points[i - 1]).normalized()
return Vector2.RIGHT
## Point and unit tangent at s in one pass.
func sample(s: float) -> Array:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return [a.lerp(b, clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)), (b - a) / span]
return [points[points.size() - 1], Vector2.RIGHT]
func project(position: Vector2) -> float:
var best: float = INF
var best_s: float = 0.0
for i: int in range(1, points.size()):
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var ab: Vector2 = b - a
var t: float = clampf((position - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0)
var d: float = position.distance_squared_to(a + ab * t)
if d < best:
best = d
best_s = cumulative[i - 1] + ab.length() * t
return best_s
static func mirror(v: Vector2) -> Vector2:
return Vector2(v.y, v.x)
static func mirror_mid(v: Vector2) -> Vector2:
return Vector2(-v.y, -v.x)
static func full_lane(half: Array[Vector2]) -> Array[Vector2]:
var result: Array[Vector2] = half.duplicate()
for i: int in range(half.size() - 2, -1, -1):
result.append(mirror(half[i]))
return result
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
var bot_half: Array[Vector2] = []
for point: Vector2 in TOP_HALF:
bot_half.append(mirror_mid(point))
lanes["top"] = LanePath.new(full_lane(TOP_HALF))
lanes["mid"] = LanePath.new(full_lane(MID_HALF))
lanes["bot"] = LanePath.new(full_lane(bot_half))
_build_buildings()
## Lane coordinate s for a team's own marching progress p (0 at its own base).
func s_for(team: int, lane: String, progress: float) -> float:
var path: LanePath = lanes[lane]
return progress if team == 0 else path.length - progress
func progress_for(team: int, lane: String, s: float) -> float:
var path: LanePath = lanes[lane]
return s if team == 0 else path.length - s
## Blau frame point, mirrored for Rot so both sides are exact images.
func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2:
var path: LanePath = lanes[lane]
var sampled: Array = path.sample(progress)
var tangent: Vector2 = sampled[1]
var blue: Vector2 = sampled[0] + Vector2(-tangent.y, tangent.x) * lateral
return blue if team == 0 else Vector2(blue.y, blue.x)
func lane_forward(team: int, lane: String, progress: float) -> Vector2:
var path: LanePath = lanes[lane]
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
return tangent if team == 0 else mirror(tangent)
## Grotto deployment points: one mirrored entrance per team, boss slightly inside.
func grotto_entry(team: int) -> Vector2:
var blue: Vector2 = GROTTO + Vector2(1.2, 4.6)
return blue if team == 0 else mirror(blue)
func grotto_boss_point() -> Vector2:
return GROTTO + Vector2(-1.5, -1.5)
func core_position(team: int) -> Vector2:
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
func _build_buildings() -> void:
buildings.clear()
for team: int in range(2):
for lane: String in LANES:
var path: LanePath = lanes[lane]
for tier: String in ["inner", "outer"]:
var fraction: float = balance.inner_tower_fraction if tier == "inner" else balance.outer_tower_fraction
var progress: float = path.length * fraction
buildings.append({"id": "%s_%s_%s" % ["blau" if team == 0 else "rot", lane, tier], "team": team, "lane": lane,
"tier": tier, "progress": progress, "position": lane_point(team, lane, progress, balance.tower_side_offset),
"max_hp": balance.tower_inner_hp if tier == "inner" else balance.tower_outer_hp,
"damage": balance.tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_inner_tower if tier == "inner" else balance.bounty_outer_tower})
for index: int in range(2):
var point: Vector2 = BASE_TOWERS_BLUE[index]
buildings.append({"id": "%s_base_%d" % ["blau" if team == 0 else "rot", index + 1], "team": team, "lane": "base",
"tier": "base", "progress": -1.0, "position": point if team == 0 else mirror(point),
"max_hp": balance.base_tower_hp, "damage": balance.base_tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_base_tower})
buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core",
"progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0})
var _index: Dictionary = {}
func building_def(id: String) -> Dictionary:
if _index.is_empty():
for entry: Dictionary in buildings:
_index[entry.id] = entry
return _index.get(id, {})
## Gate rule shared by every damage source. alive maps building id -> bool.
static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool:
var prefix: String = "blau" if int(definition.team) == 0 else "rot"
match str(definition.tier):
"outer":
return true
"inner":
return not alive.get("%s_%s_outer" % [prefix, definition.lane], false)
"base":
for lane: String in LANES:
if not alive.get("%s_%s_outer" % [prefix, lane], false) and not alive.get("%s_%s_inner" % [prefix, lane], false):
return true
return false
"core":
return not alive.get(prefix + "_base_1", false) and not alive.get(prefix + "_base_2", false)
return false