saumwacht-godot/scripts/luneraria/view/lune_world.gd
Martin Stickelbroeck c27a0097c3 M1: spielbarer Drei-Lane-Kern mit Karte, HUD, Menü und Abnahme
Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die
alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm,
HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden
aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller
(Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch.
Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:37:23 +02:00

303 lines
11 KiB
GDScript

extends Node3D
## 3D presentation of LuneMap built from existing assets: Kluft terrain shaders, the
## Saumkern models as Ancients, compare-pack rocks and trees, and the new lane tower.
## Purely visual; the simulation never reads anything from this node.
const Lighting = preload("res://scripts/presentation/lighting.gd")
const Sanctuary = preload("res://scripts/presentation/sanctuary.gd")
const RiverShader = preload("res://scripts/presentation/river.gdshader")
const TOWER := "res://assets/luneraria/tower.glb"
const ROCK := "res://assets/compare/compare-rock.glb"
const TREE := "res://assets/compare/compare-tree.glb"
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
var map: LuneMap
var building_nodes: Dictionary = {}
var _terrain: Dictionary = {}
var _crystal_materials: Array[StandardMaterial3D] = []
func build(match_map: LuneMap) -> void:
map = match_map
name = "LuneWorld"
_environment()
_ground()
_river()
for lane: String in LuneMap.LANES:
_lane_ribbon(map.lanes[lane])
for team: int in range(2):
_base_plaza(team)
_decoration()
for team: int in range(2):
var crystal := StandardMaterial3D.new()
crystal.albedo_color = TEAM_COLORS[team]
crystal.emission_enabled = true
crystal.emission = TEAM_COLORS[team]
crystal.emission_energy_multiplier = 2.2
crystal.roughness = .15
_crystal_materials.append(crystal)
for definition: Dictionary in map.buildings:
_building(definition)
func terrain(key: String, color: Color) -> ShaderMaterial:
if not _terrain.has(key):
var source := StandardMaterial3D.new()
source.resource_name = key
source.albedo_color = color
_terrain[key] = Sanctuary.terrain_material(source)
return _terrain[key]
func to3(v: Vector2, y: float = 0.0) -> Vector3:
return Vector3(v.x, y, v.y)
func _environment() -> void:
var environment := WorldEnvironment.new()
var settings := Environment.new()
Lighting.configure(settings)
# The overview camera sits ~80 m away; distance fog would wash out the whole board.
settings.fog_enabled = false
environment.environment = settings
add_child(environment)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-55, -30, 0)
sun.light_color = Color("ffe2bc")
sun.light_energy = 1.7
sun.shadow_enabled = true
sun.directional_shadow_max_distance = 90.0
sun.shadow_blur = 1.8
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-35, 150, 0)
fill.light_color = Color("a8c4e8")
fill.light_energy = .45
add_child(fill)
func _ground() -> void:
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(LuneMap.HALF_EXTENT * 2.0 + 30.0, LuneMap.HALF_EXTENT * 2.0 + 30.0)
plane.subdivide_width = 32
plane.subdivide_depth = 32
ground.mesh = plane
ground.material_override = terrain("ENV_soil", Color(.10, .15, .065))
ground.name = "Ground"
add_child(ground)
func _river() -> void:
# The river follows the symmetry axis x = z and passes under all three lanes.
var tool := SurfaceTool.new()
tool.begin(Mesh.PRIMITIVE_TRIANGLES)
var a := Vector2(-40, -40)
var b := Vector2(40, 40)
var side := Vector2(1, -1).normalized() * 3.2
var steps: int = 40
for i: int in range(steps):
# Godot front faces wind clockwise seen from above.
for quad: Array in [[i, -1], [i + 1, 1], [i + 1, -1], [i, -1], [i, 1], [i + 1, 1]]:
var t: float = float(quad[0]) / steps
var wobble: float = sin(t * 18.0) * .6
var p: Vector2 = a.lerp(b, t) + side * (float(quad[1]) + wobble * .15)
tool.set_normal(Vector3.UP)
tool.add_vertex(to3(p, .012))
var river := MeshInstance3D.new()
river.mesh = tool.commit()
var water := ShaderMaterial.new()
water.shader = RiverShader
river.material_override = water
river.name = "River"
add_child(river)
func _lane_ribbon(path: LuneMap.LanePath) -> void:
var tool := SurfaceTool.new()
tool.begin(Mesh.PRIMITIVE_TRIANGLES)
var steps: int = int(ceil(path.length / .8))
var half: float = map.balance.lane_half_width + .6
var previous_left := Vector3.ZERO
var previous_right := Vector3.ZERO
for i: int in range(steps + 1):
var s: float = path.length * float(i) / steps
var point: Vector2 = path.point_at(s)
var tangent: Vector2 = (path.point_at(minf(path.length, s + .5)) - path.point_at(maxf(0.0, s - .5))).normalized()
var normal := Vector2(-tangent.y, tangent.x)
var left: Vector3 = to3(point + normal * half, .03)
var right: Vector3 = to3(point - normal * half, .03)
if i > 0:
for vertex: Vector3 in [previous_left, right, left, previous_left, previous_right, right]:
tool.set_normal(Vector3.UP)
tool.set_uv(Vector2(vertex.x, vertex.z) * .25)
tool.add_vertex(vertex)
previous_left = left
previous_right = right
var ribbon := MeshInstance3D.new()
ribbon.mesh = tool.commit()
ribbon.material_override = terrain("ENV_paving", Color(.19, .185, .15))
ribbon.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(ribbon)
func _base_plaza(team: int) -> void:
var plaza := MeshInstance3D.new()
var disc := CylinderMesh.new()
disc.top_radius = 9.5
disc.bottom_radius = 9.8
disc.height = .12
disc.radial_segments = 48
plaza.mesh = disc
plaza.material_override = terrain("ENV_limestone", Color(.25, .225, .17))
plaza.position = to3(map.core_position(team), .02)
add_child(plaza)
var rim := MeshInstance3D.new()
var ring := TorusMesh.new()
ring.inner_radius = 9.35
ring.outer_radius = 9.6
ring.rings = 64
rim.mesh = ring
var glow := StandardMaterial3D.new()
glow.albedo_color = TEAM_COLORS[team]
glow.emission_enabled = true
glow.emission = TEAM_COLORS[team]
glow.emission_energy_multiplier = 1.2
rim.material_override = glow
rim.position = to3(map.core_position(team), .1)
rim.scale = Vector3(1, .3, 1)
add_child(rim)
func _far_from_lanes(point: Vector2, margin: float) -> bool:
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
if path.point_at(path.project(point)).distance_to(point) < margin:
return false
for team: int in range(2):
if map.core_position(team).distance_to(point) < 12.0:
return false
if absf(point.x - point.y) < 5.0:
return false
return true
func _decoration() -> void:
# Scatter one half deterministically and mirror it, so decoration never favours a team.
var rng := RandomNumberGenerator.new()
rng.seed = 90210
var tree: PackedScene = load(TREE)
var rock: PackedScene = load(ROCK)
var placed: Array[Vector2] = []
var tries: int = 0
while placed.size() < 70 and tries < 4000:
tries += 1
var point := Vector2(rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4), rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4))
if point.y < point.x or not _far_from_lanes(point, 5.2):
continue
var clear: bool = true
for other: Vector2 in placed:
if other.distance_to(point) < 2.6:
clear = false
break
if not clear:
continue
placed.append(point)
var is_tree: bool = rng.randf() < .62
var size: float = rng.randf_range(1.1, 1.9) if is_tree else rng.randf_range(.8, 1.6)
var yaw: float = rng.randf() * TAU
for mirrored: bool in [false, true]:
var at: Vector2 = LuneMap.mirror(point) if mirrored else point
var holder := Node3D.new()
holder.position = to3(at)
holder.rotation.y = yaw if not mirrored else -yaw
holder.scale = Vector3.ONE * size
var prop: Node3D = (tree if is_tree else rock).instantiate()
# The compare-pack props were exported with their height along -Z; stand them up.
prop.rotation.x = PI * .5
_dress(prop)
holder.add_child(prop)
add_child(holder)
var _vertex_colored: StandardMaterial3D
func _dress(node: Node) -> void:
if node is MeshInstance3D:
for surface: int in range(node.mesh.get_surface_count()):
var source := node.mesh.surface_get_material(surface) as StandardMaterial3D
if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"):
node.set_surface_override_material(surface, terrain(source.resource_name, source.albedo_color))
elif source != null and source.resource_name.begins_with("CV_"):
# Compare-pack props carry their colours as vertex colours.
if _vertex_colored == null:
_vertex_colored = StandardMaterial3D.new()
_vertex_colored.vertex_color_use_as_albedo = true
_vertex_colored.roughness = .86
node.set_surface_override_material(surface, _vertex_colored)
for child: Node in node.get_children():
_dress(child)
func _building(definition: Dictionary) -> void:
var root := Node3D.new()
root.name = str(definition.id)
root.position = to3(definition.position)
add_child(root)
var team: int = int(definition.team)
if definition.tier == "core":
var core: Node3D = AssetLibrary.instantiate_fighter("core_team%d" % team)
if core != null:
core.scale = Vector3.ONE * 1.35
root.add_child(core)
if core.has_method("trigger_activate"):
core.trigger_activate()
root.set_meta("core", core)
else:
var tower: Node3D = (load(TOWER) as PackedScene).instantiate()
tower.scale = Vector3.ONE * (1.18 if definition.tier == "base" else 1.0)
_dress(tower)
_tint_crystal(tower, _crystal_materials[team])
# Towers face the lane they guard.
var toward: Vector2 = map.core_position(1 - team) - Vector2(definition.position)
tower.rotation.y = atan2(toward.x, toward.y)
root.add_child(tower)
root.set_meta("tower", tower)
var light := OmniLight3D.new()
light.position = Vector3(0, 5.2 if definition.tier != "core" else 2.4, 0)
light.light_color = TEAM_COLORS[team]
light.light_energy = 1.2
light.omni_range = 6.0
root.add_child(light)
root.set_meta("light", light)
var bar := Label3D.new()
bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED
bar.no_depth_test = true
bar.font_size = 34
bar.outline_size = 8
bar.pixel_size = .01
bar.modulate = TEAM_COLORS[team].lightened(.25)
bar.position = Vector3(0, 7.4 if definition.tier != "core" else 4.4, 0)
root.add_child(bar)
root.set_meta("bar", bar)
building_nodes[definition.id] = root
func _tint_crystal(node: Node, crystal: StandardMaterial3D) -> void:
if node is MeshInstance3D:
for surface: int in range(node.mesh.get_surface_count()):
var source: Material = node.mesh.surface_get_material(surface)
if source != null and source.resource_name == "TW_Crystal":
node.set_surface_override_material(surface, crystal)
for child: Node in node.get_children():
_tint_crystal(child, crystal)
## Health text and destroyed state from the authoritative building HP.
func sync_buildings(hp: Dictionary, max_hp: Dictionary, time: float, delta: float) -> void:
for id: String in building_nodes:
var root: Node3D = building_nodes[id]
var value: float = float(hp.get(id, 0.0))
var bar: Label3D = root.get_meta("bar")
var alive: bool = value > 0.0
bar.visible = alive and value < float(max_hp[id]) - .5
bar.text = "%d%%" % ceili(value / float(max_hp[id]) * 100.0)
(root.get_meta("light") as OmniLight3D).visible = alive
if root.has_meta("tower"):
var tower: Node3D = root.get_meta("tower")
tower.position.y = move_toward(tower.position.y, 0.0 if alive else -3.2, delta * 2.0)
tower.rotation.z = move_toward(tower.rotation.z, 0.0 if alive else .22, delta * .3)
elif root.has_meta("core"):
var core: Node3D = root.get_meta("core")
if core.has_method("set_core_health"):
core.set_core_health(value / float(max_hp[id]))
if core.has_method("pose"):
core.pose(time, false, 0, 0, 0, delta)
core.visible = alive