saumwacht-godot/tools/luneraria/verify_g1.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:09:48 +02:00

294 lines
15 KiB
GDScript

extends Node
## G1 acceptance: the three-lane layout plays correctly and the game camera reaches it.
## Part 1 checks the map and the terrain (lanes, water crossings, towers, bases, grotto,
## deployment areas, mirror symmetry). Part 2 runs the real game with real key and mouse
## events: lane, base, grotto, hero and overview jumps, reset, rotation, zoom limits,
## deployment areas in planning, figures standing on the ground in combat.
## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g1.tscn
const OUT := "res://docs/luneraria/logs/g1/verify-g1.json"
var checks: int = 0
var failures: Array[String] = []
var facts: Dictionary = {}
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("G1_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func run() -> void:
Soundscape.enabled = false
var map := LuneMap.new()
var terrain: LuneTerrain = LuneTerrain.for_map(map)
_layout(map, terrain)
await _game()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://docs/luneraria/logs/g1"))
var file := FileAccess.open(OUT, FileAccess.WRITE)
file.store_string(JSON.stringify({"checks": checks, "failures": failures, "facts": facts}, "\t"))
file.close()
print("G1_VERIFY_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
get_tree().quit(0 if failures.is_empty() else 1)
# ---------------------------------------------------------------- part 1: layout
func _layout(map: LuneMap, terrain: LuneTerrain) -> void:
var lengths: Dictionary = {}
for lane: String in LuneMap.LANES:
lengths[lane] = snappedf(map.lanes[lane].length, .01)
facts.lane_lengths = lengths
check(absf(map.lanes["top"].length - map.lanes["bot"].length) < .001, "Top and Bottom have the same length")
check(map.lanes["mid"].length < map.lanes["top"].length * .75, "Mid is the direct route (clearly shorter than Top/Bottom)")
# Lanes are always walkable; open water lies under a lane only at the two decks.
var water_points: Dictionary = {}
var min_height: Dictionary = {}
var max_height: Dictionary = {}
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var dry: bool = true
var s: float = 0.0
water_points[lane] = 0
min_height[lane] = INF
max_height[lane] = -INF
while s <= path.length:
var p: Vector2 = path.point_at(s)
var surface: float = terrain.lane_height(lane, s)
min_height[lane] = minf(min_height[lane], surface)
max_height[lane] = maxf(max_height[lane], surface)
dry = dry and terrain.walk_height(p) > LuneTerrain.WATER_LEVEL + .15
if terrain.height_at(p) < LuneTerrain.WATER_LEVEL:
water_points[lane] = int(water_points[lane]) + 1
var on_deck: bool = (lane == "mid" and p.length() < LuneTerrain.PLATFORM_RADIUS) or (lane == "bot" and p.distance_to(path.point_at(path.length * .5)) < 7.2)
check(on_deck, "%s crosses water only on its deck (at %s)" % [lane, p.snapped(Vector2(.1, .1))])
s += 1.0
check(dry, "%s lane is walkable everywhere" % lane)
facts.lane_height_range = {"min": min_height, "max": max_height}
facts.water_points_under_lanes = water_points
check(int(water_points.top) == 0, "Top never touches the river (highland route)")
check(int(water_points.mid) > 0 and int(water_points.bot) > 0, "Mid and Bottom cross the Kluft")
check(float(max_height.top) > 2.5, "Top climbs onto the highland (%.2f m)" % float(max_height.top))
check(float(min_height.bot) < 0.0, "Bottom runs through the lowland (%.2f m)" % float(min_height.bot))
check(is_equal_approx(terrain.lane_height("mid", map.lanes["mid"].length * .5), LuneTerrain.PLATFORM_HEIGHT), "Mid crosses on the central platform")
# Buildings: inside the playable square, on dry flat pads, not crowding each other.
var cores: Array[Vector2] = [map.core_position(0), map.core_position(1)]
for definition: Dictionary in map.buildings:
var p: Vector2 = definition.position
var id: String = definition.id
check(absf(p.x) < LuneMap.HALF_EXTENT - 2.0 and absf(p.y) < LuneMap.HALF_EXTENT - 2.0, id + " lies inside the playable square")
check(terrain.height_at(p) > LuneTerrain.WATER_LEVEL + .25, id + " stands on dry ground")
var flat: bool = true
for offset: Vector2 in [Vector2(1.4, 0), Vector2(-1.4, 0), Vector2(0, 1.4), Vector2(0, -1.4)]:
flat = flat and absf(terrain.height_at(p + offset) - terrain.height_at(p)) < .15
check(flat, id + " stands on a flat pad")
for other: Dictionary in map.buildings:
if other.id != id:
check(p.distance_to(other.position) > 3.8, "%s keeps its distance to %s" % [id, other.id])
if str(definition.tier) == "base":
var team: int = int(definition.team)
check((p - cores[team]).dot(cores[1 - team] - cores[team]) > 0.0, id + " stands in front of its Ancient")
# Grotto: on the symmetry axis, beside Mid, clear of every lane corridor, entries on the terrace.
var grotto: Vector2 = map.grotto_center()
check(LuneMap.mirror(grotto).distance_to(grotto) < .001, "grotto lies on the symmetry axis")
var nearest: Array = terrain.nearest_lane(grotto + LuneMap.AXIS * 0.0)
var clearance: float = INF
for angle_step: int in range(24):
var edge: Vector2 = grotto + Vector2.from_angle(TAU * angle_step / 24.0) * LuneTerrain.ARENA_RADIUS
var lane: Array = terrain.nearest_lane(edge)
if float(lane[0]) < INF:
clearance = minf(clearance, float(lane[0]) - float(LuneTerrain.CORRIDOR[lane[1]]))
facts.grotto_clearance_to_lanes = snappedf(clearance, .01)
check(clearance > 2.0, "grotto arena stays clear of every lane corridor (%.1f m)" % clearance)
var mid: LuneMap.LanePath = map.lanes["mid"]
check(mid.point_at(mid.project(grotto)).distance_to(grotto) > 12.0, "grotto does not block Mid")
for team: int in range(2):
check(absf(terrain.walk_height(map.grotto_entry(team)) - LuneTerrain.ARENA_HEIGHT) < .05, "grotto entry of team %d lies on the terrace" % team)
check(absf(terrain.walk_height(map.grotto_boss_point()) - LuneTerrain.ARENA_HEIGHT) < .05, "boss point lies on the terrace")
check(terrain.is_water(grotto + LuneMap.AXIS * (LuneTerrain.ARENA_RADIUS + 1.5)), "the Kluft springs at the foot of the grotto terrace")
check(not terrain.is_water(Vector2(-25, -25)), "no water at the Top corner")
check(terrain.is_water(Vector2(20, 20)) and terrain.is_water(Vector2(-5, -5)), "the river runs along the axis")
# Deployment areas: dry, inside the square, in front of the own base.
var span: Dictionary = map.deployment_span()
for team: int in range(2):
for lane: String in LuneMap.LANES:
var ok: bool = true
for progress: float in [float(span.from), float(span.to)]:
for lateral: float in [-float(span.half_width), 0.0, float(span.half_width)]:
var p: Vector2 = map.lane_point(team, lane, progress, lateral)
ok = ok and absf(p.x) < LuneMap.HALF_EXTENT and absf(p.y) < LuneMap.HALF_EXTENT
ok = ok and terrain.walk_height(p) > LuneTerrain.WATER_LEVEL + .2
ok = ok and p.distance_to(cores[team]) < p.distance_to(cores[1 - team])
check(ok, "deployment area %d/%s is dry, inside and on the own side" % [team, lane])
# The ground is the exact mirror image across x = z.
var worst: float = 0.0
for j: int in range(terrain.size):
for i: int in range(j):
worst = maxf(worst, absf(terrain.ground[j * terrain.size + i] - terrain.ground[i * terrain.size + j]))
facts.terrain_mirror_error = worst
check(worst == 0.0, "terrain is the exact mirror image across x = z")
# ---------------------------------------------------------------- part 2: game camera
func key(code: Key) -> void:
var event := InputEventKey.new()
event.keycode = code
event.physical_keycode = code
event.pressed = true
get_viewport().push_input(event)
await get_tree().process_frame
var release := event.duplicate()
release.pressed = false
get_viewport().push_input(release)
await get_tree().process_frame
func wheel(up: bool) -> void:
var event := InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_WHEEL_UP if up else MOUSE_BUTTON_WHEEL_DOWN
event.pressed = true
event.position = Vector2(960, 560)
get_viewport().push_input(event)
await get_tree().process_frame
func settle(frames: int = 3) -> void:
for i: int in frames:
await get_tree().process_frame
func near_focus(game: Node3D, target: Vector2, tolerance: float = .6) -> bool:
return Vector2(game.camera.focus.x, game.camera.focus.z).distance_to(target) < tolerance
func on_screen(game: Node3D, world: Vector3) -> bool:
var camera: Camera3D = game.camera
if camera.is_position_behind(world):
return false
var screen: Vector2 = camera.unproject_position(world)
return get_viewport().get_visible_rect().has_point(screen)
func _game() -> void:
var main: Node = load("res://scenes/luneraria.tscn").instantiate()
add_child(main)
var menu: MainMenu = main.menu
while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")):
await get_tree().process_frame
menu.bund = "wurzelbund"
menu.lane = "mid"
main.start_match({"seed": 4})
await get_tree().create_timer(.8).timeout
var game: Node3D = main.game
var state: LuneMatch = game.match_state
var map: LuneMap = state.map
var team: int = int(state.players[game.human_slot].team)
check(game.camera.projection == Camera3D.PROJECTION_PERSPECTIVE, "the game camera is a perspective MOBA camera")
game.camera.reduced_motion = true
# Lane jumps F2/F3/F4 frame the own deployment side of each lane.
for index: int in range(3):
await key([KEY_F2, KEY_F3, KEY_F4][index])
var lane: String = LuneMap.LANES[index]
var target: Vector2 = map.lane_point(team, lane, map.lanes[lane].length * .2)
check(near_focus(game, target), "F%d centres the camera on %s" % [index + 2, lane])
check(on_screen(game, Vector3(target.x, game.world.height_at(target), target.y)), "%s is on screen after its hotkey" % lane)
await key(KEY_B)
check(near_focus(game, map.core_position(team)), "B centres the own Ancient")
check(on_screen(game, game.world.to3(map.core_position(team))), "own Ancient is on screen")
await key(KEY_G)
check(near_focus(game, map.grotto_center()), "G centres the boss grotto")
await key(KEY_H)
check(game.own_hero_unit() == null, "H without a hero reports that there is none")
# Hero jump after hiring one through the HUD command path.
state.players[game.human_slot].draft.gold = 600
game._on_command({"type": "hero_buy", "hero_id": "held_ringborke"})
await settle()
var hero: LuneUnit = game.own_hero_unit()
check(hero != null, "the hired hero appears in the planning formation")
if hero != null:
await key(KEY_H)
check(near_focus(game, hero.position), "H centres the selected hero")
# Overview: every building and the grotto are on screen at once.
await key(KEY_O)
await settle(4)
var all_visible: bool = true
for definition: Dictionary in map.buildings:
all_visible = all_visible and on_screen(game, game.world.to3(definition.position))
check(all_visible, "O shows all 16 towers and both Ancients")
check(on_screen(game, game.world.to3(map.grotto_center())), "O shows the boss grotto")
facts.overview = {"zoom": game.camera.zoom, "pitch_deg": rad_to_deg(LuneCameraPitch.of(game.camera)), "distance": game.camera.position.distance_to(game.camera.focus)}
# Rotation and reset.
await key(KEY_Q)
await settle()
check(is_equal_approx(game.camera.yaw, -PI * .25), "Q turns the camera by 45°")
await key(KEY_E)
await key(KEY_E)
check(is_equal_approx(game.camera.yaw, PI * .25), "E turns the camera back and further")
await key(KEY_R)
await settle()
var home: Vector2 = map.lane_point(team, "mid", map.lanes["mid"].length * .2)
check(is_zero_approx(game.camera.yaw) and near_focus(game, home) and is_equal_approx(game.camera.zoom, game.camera.DEFAULT_ZOOM), "R resets rotation, zoom and focus to the own lane")
# Zoom limits with the mouse wheel.
for i: int in 40:
await wheel(true)
check(is_equal_approx(game.camera.zoom, game.camera.MIN_ZOOM), "wheel zoom stops at the closest limit")
var near_pitch: float = rad_to_deg(LuneCameraPitch.of(game.camera))
for i: int in 60:
await wheel(false)
check(is_equal_approx(game.camera.zoom, game.camera.MAX_ZOOM), "wheel zoom stops at the widest limit")
var far_pitch: float = rad_to_deg(LuneCameraPitch.of(game.camera))
facts.pitch_range_deg = [near_pitch, far_pitch]
check(far_pitch < near_pitch - 10.0, "the view steepens when zooming out (%.0f° → %.0f°)" % [near_pitch, far_pitch])
var footprint: PackedVector2Array = game.camera.footprint()
check(footprint.size() == 4 and Geometry2D.is_point_in_polygon(Vector2(game.camera.focus.x, game.camera.focus.z), footprint), "the minimap outline contains the camera focus")
await key(KEY_R)
# Deployment areas: the own team's three pads in planning, highlighted where units stand.
var world: Node3D = game.world
var own_pads: int = 0
var other_pads: int = 0
for key_name: String in world._pads:
var pad: MeshInstance3D = world._pads[key_name]
if pad.visible:
if key_name.begins_with("%d:" % team):
own_pads += 1
else:
other_pads += 1
check(own_pads == 3 and other_pads == 0, "planning shows the own three deployment areas only")
var mid_pad: ShaderMaterial = (world._pads["%d:mid" % team] as MeshInstance3D).material_override
var top_pad: ShaderMaterial = (world._pads["%d:top" % team] as MeshInstance3D).material_override
check(float(mid_pad.get_shader_parameter("highlight")) > .5 and float(top_pad.get_shader_parameter("highlight")) < .5, "the lane that holds own units is highlighted")
# Combat: pads hide, figures stand on the ground, the lane view shows the own army.
game.hud._starter.pressed.emit()
await settle()
await key(KEY_SPACE)
await settle(2)
check(state.phase == LuneMatch.Phase.COMBAT, "Space starts the combat")
var hidden: bool = true
for key_name: String in world._pads:
hidden = hidden and not (world._pads[key_name] as MeshInstance3D).visible
check(hidden, "deployment areas hide during combat")
game.paused = true
for i: int in range(240):
state.step_combat()
await settle(3)
var grounded: bool = true
var worst: float = 0.0
for view: Node3D in game._views.values():
var expected: float = world.height_at(Vector2(view.position.x, view.position.z)) + .03
worst = maxf(worst, absf(view.position.y - expected))
grounded = grounded and absf(view.position.y - expected) < .05
facts.unit_ground_error = worst
check(grounded and game._views.size() > 20, "all %d figures stand on the terrain (max error %.3f m)" % [game._views.size(), worst])
await key(KEY_F3)
await settle(3)
var own_visible: int = 0
for unit: LuneUnit in state.combat.units:
if unit.alive and unit.team == team and unit.lane == "mid" and on_screen(game, game.world.to3(unit.position)):
own_visible += 1
check(own_visible > 0, "F3 in combat shows the own Mid army (%d on screen)" % own_visible)
# Real-time camera motion reaches its goal.
game.camera.reduced_motion = false
await key(KEY_B)
await get_tree().create_timer(1.2).timeout
check(near_focus(game, map.core_position(team), 1.0), "a smooth camera jump arrives within 1.2 s")
main.queue_free()
await settle()
class LuneCameraPitch:
static func of(camera: Camera3D) -> float:
var direction: Vector3 = (camera.focus - camera.position).normalized()
return asin(clampf(direction.y, -1.0, 1.0))