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>
294 lines
15 KiB
GDScript
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))
|