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.
439 lines
17 KiB
GDScript
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
|