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