saumwacht-godot/scripts/luneraria/view/lune_world.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

329 lines
12 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 GROTTO := "res://assets/luneraria/grotto.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)
_grotto()
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)
var grotto_light: OmniLight3D
func _grotto() -> void:
var grotto: Node3D = (load(GROTTO) as PackedScene).instantiate()
grotto.name = "BossGrotto"
grotto.position = to3(LuneMap.GROTTO)
_dress(grotto)
add_child(grotto)
grotto_light = OmniLight3D.new()
grotto_light.position = to3(LuneMap.GROTTO + Vector2(-2.0, -2.0), 2.4)
grotto_light.light_color = Color("b37cff")
grotto_light.light_energy = 2.2
grotto_light.omni_range = 9.0
add_child(grotto_light)
## Grotto glow: violet when open, dim when locked, team colour while a claim is pending.
func sync_grotto(available: bool, claim_team: int) -> void:
if grotto_light == null:
return
grotto_light.light_color = TEAM_COLORS[claim_team] if claim_team >= 0 else Color("b37cff")
grotto_light.light_energy = 2.2 if available or claim_team >= 0 else .5
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
if point.distance_to(LuneMap.GROTTO) < 9.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