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>
176 lines
7.4 KiB
GDScript
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
|