saumwacht-godot/scripts/luneraria/view/lune_world.gd
Martin Stickelbroeck faf0fbdef0 M3: Balance, Kartenprops, sieben Volksrepräsentanten und Abnahme
Bots planen Items/Champions vor der Armee und helfen deterministisch,
damit Spiegelmatches fair bleiben. Boss und Profile neu abgestimmt;
Balance-, Stress- und Volks-Werkzeuge sowie Kartenprops ergänzt.
2026-09-28 00:41:05 +02:00

439 lines
17 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 PROPS := "res://assets/luneraria/map_props.glb"
## Lane crossings over the river (x = z); all three lie on the symmetry axis.
const CROSSINGS: Array[Vector2] = [Vector2(-25, -25), Vector2(0, 0), Vector2(25, 25)]
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)
_load_props()
_bridges()
_curbs()
_decoration()
_edges()
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
var _props: Dictionary = {}
## Prop meshes from the Blender generator, with the Kluft shaders applied per surface.
func _load_props() -> void:
var scene: Node3D = (load(PROPS) as PackedScene).instantiate()
for child: Node in scene.get_children():
if child is MeshInstance3D:
var mesh: ArrayMesh = (child as MeshInstance3D).mesh.duplicate()
for surface: int in range(mesh.get_surface_count()):
var source := mesh.surface_get_material(surface) as StandardMaterial3D
if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"):
mesh.surface_set_material(surface, terrain(source.resource_name, source.albedo_color))
_props[str(child.name)] = mesh
scene.free()
func _multimesh(prop: String, transforms: Array[Transform3D], shadows: bool = true) -> MultiMeshInstance3D:
var multimesh := MultiMesh.new()
multimesh.transform_format = MultiMesh.TRANSFORM_3D
multimesh.mesh = _props[prop]
multimesh.instance_count = transforms.size()
for index: int in range(transforms.size()):
multimesh.set_instance_transform(index, transforms[index])
var instance := MultiMeshInstance3D.new()
instance.name = "Props_" + prop
instance.multimesh = multimesh
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(instance)
return instance
func _bridges() -> void:
var transforms: Array[Transform3D] = []
for point: Vector2 in CROSSINGS:
transforms.append(Transform3D(Basis(Vector3.UP, PI * .25), to3(point)))
_multimesh("Bridge", transforms)
func _near_crossing(point: Vector2, margin: float) -> bool:
for crossing: Vector2 in CROSSINGS:
if crossing.distance_to(point) < margin:
return true
return false
## Curb stones along both lane edges, skipped on bridges, at bases and around towers.
func _curbs() -> void:
var transforms: Array[Transform3D] = []
var offset: float = map.balance.lane_half_width + 1.0
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var s: float = 0.0
while s < path.length:
var sampled: Array = path.sample(s)
var tangent: Vector2 = sampled[1]
var normal := Vector2(-tangent.y, tangent.x)
for side: float in [-1.0, 1.0]:
var at: Vector2 = sampled[0] + normal * offset * side
var clear: bool = not _near_crossing(at, 6.5) and map.core_position(0).distance_to(at) > 11.0 and map.core_position(1).distance_to(at) > 11.0
for definition: Dictionary in map.buildings:
clear = clear and Vector2(definition.position).distance_to(at) > 2.2
if clear:
transforms.append(Transform3D(Basis(Vector3.UP, atan2(-tangent.y, tangent.x)), to3(at)))
s += 1.55
_multimesh("Curb", transforms, false)
func _decoration() -> void:
# Scatter one half deterministically and mirror it, so decoration never favours a team.
var rng := RandomNumberGenerator.new()
rng.seed = 90210
var kinds: Array[String] = ["Pine_0", "Pine_1", "Pine_2", "Oak_0", "Oak_1", "Oak_2", "Rock_0", "Rock_1", "Rock_2", "Shrub_0", "Shrub_1"]
var weights: Array[float] = [1.2, 1.0, 1.0, 1.2, 1.0, .8, .7, .5, .8, 1.0, 1.0]
var total: float = 0.0
for weight: float in weights:
total += weight
var buckets: Dictionary = {}
var placed: Array[Vector2] = []
var tries: int = 0
while placed.size() < 150 and tries < 8000:
tries += 1
var point := Vector2(rng.randf_range(-LuneMap.HALF_EXTENT - 2, LuneMap.HALF_EXTENT + 2), rng.randf_range(-LuneMap.HALF_EXTENT - 2, LuneMap.HALF_EXTENT + 2))
if point.y < point.x or not _far_from_lanes(point, 4.9):
continue
var clear: bool = true
for other: Vector2 in placed:
if other.distance_to(point) < 1.9:
clear = false
break
if not clear:
continue
placed.append(point)
var roll: float = rng.randf() * total
var kind: String = kinds[-1]
for index: int in range(kinds.size()):
roll -= weights[index]
if roll <= 0.0:
kind = kinds[index]
break
var size: float = rng.randf_range(.85, 1.25)
var yaw: float = rng.randf() * TAU
if not buckets.has(kind):
buckets[kind] = [] as Array[Transform3D]
for mirrored: bool in [false, true]:
var at: Vector2 = LuneMap.mirror(point) if mirrored else point
var basis := Basis(Vector3.UP, -yaw if mirrored else yaw).scaled(Vector3.ONE * size)
(buckets[kind] as Array[Transform3D]).append(Transform3D(basis, to3(at)))
for kind: String in buckets:
_multimesh(kind, buckets[kind], not kind.begins_with("Shrub"))
## A ring of cliff segments frames the playable square.
func _edges() -> void:
var transforms: Array[Transform3D] = []
var edge: float = LuneMap.HALF_EXTENT + 5.0
for side: int in range(4):
for index: int in range(7):
var along: float = -edge + 6.0 + index * 12.0
var at: Vector2
var yaw: float
match side:
0: at = Vector2(along, -edge); yaw = 0.0
1: at = Vector2(edge, along); yaw = PI * .5
2: at = Vector2(-along, edge); yaw = PI
_: at = Vector2(-edge, -along); yaw = PI * 1.5
transforms.append(Transform3D(Basis(Vector3.UP, yaw), to3(at)))
_multimesh("EdgeCliff", transforms)
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)
if definition.tier == "core":
for side: float in [-1.0, 1.0]:
# Team banners flank each Ancient.
var banner := MeshInstance3D.new()
banner.mesh = _props["Banner"]
var cloth := StandardMaterial3D.new()
cloth.albedo_color = TEAM_COLORS[team]
cloth.cull_mode = BaseMaterial3D.CULL_DISABLED
cloth.roughness = .8
for surface: int in range(banner.mesh.get_surface_count()):
var source: Material = banner.mesh.surface_get_material(surface)
if source != null and source.resource_name == "PR_Banner":
banner.set_surface_override_material(surface, cloth)
var toward: Vector2 = (map.core_position(1 - team) - map.core_position(team)).normalized()
var across := Vector2(-toward.y, toward.x) * side * 3.6
banner.position = Vector3(across.x, 0, across.y) + Vector3(toward.x, 0, toward.y) * 2.4
banner.rotation.y = atan2(toward.x, toward.y)
root.add_child(banner)
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