extends Node3D ## 3D presentation of LuneMap (G1 layout). The ground is LuneTerrain's height field: ## the north-western highland carries Top, the Kluft river runs on the symmetry axis ## from the grotto spring under the central Mid platform into the south-eastern delta ## that Bottom crosses on a bridge. Map props (trees, rocks, curbs, bridge) and the Kluft ## surface shader are reused; the strategic objects are the G2 views (towers, Ancients, ## grotto, tower effects, occlusion fade). Bases, walls, landmarks and the platform are ## still blockout primitives for G4. Purely visual; the simulation never reads anything here. 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 GroundShader = preload("res://scripts/luneraria/view/lune_terrain.gdshader") const PadShader = preload("res://scripts/luneraria/view/lune_pad.gdshader") const TowerView = preload("res://scripts/luneraria/view/lune_tower_view.gd") const AncientView = preload("res://scripts/luneraria/view/lune_ancient_view.gd") const GrottoView = preload("res://scripts/luneraria/view/lune_grotto_view.gd") const VeinShader = preload("res://scripts/luneraria/view/materials/lune_vein.gdshader") const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") ## Top of the base plaza above the plateau (G1 disc) - figures are lifted onto it. const PLAZA_LIFT: float = .08 const PLAZA_RADIUS: float = 8.4 const TowerFx = preload("res://scripts/luneraria/view/lune_tower_fx.gd") const Stone = preload("res://assets/materials/phase10/limestone-albedo.png") const PROPS := "res://assets/luneraria/map_props.glb" const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] const TEAM_NAMES: Array[String] = ["Blau", "Rot"] ## Visible paving half width per lane; Mid is the broad monumental route. const PAVING: Dictionary = {"top": 3.0, "mid": 3.9, "bot": 3.0} ## Lane identity: old grey flagstones on Top, pale limestone on Mid, packed earth on Bottom. const PAVING_MATERIAL: Dictionary = {"top": ["ENV_paving_top", Color(.33, .31, .27)], "mid": ["ENV_paving_mid", Color(.42, .39, .33)], "bot": ["ENV_soil_bot", Color(.31, .25, .17)]} var map: LuneMap var terrain: LuneTerrain var sun: DirectionalLight3D var building_nodes: Dictionary = {} ## G2: tower views by building id, the tower attack effects and whether HP was seen once. var tower_views: Dictionary = {} var ancient_views: Dictionary = {} ## team -> [[vein MeshInstance3D, base tower id or ""], ...] var _veins: Dictionary = {} var tower_fx: Node3D var _synced_once: bool = false ## Occlusion fade (G2): how far a building thins out above its fade floor while figures or the ## camera focus are behind it. It never vanishes; the silhouette stays readable. Slim lane ## towers only give way for heroes, champions and the boss; the Ancient, the base towers and ## the grotto gate for every figure and for the camera focus. ## Main-thread cost of sync_strategic (microseconds, smoothed); read by the benchmark. var strategic_usec: float = 0.0 const OCCLUSION_FADE: float = .55 const OCCLUSION_FADE_SLIM: float = .45 var _occlusion_frame: int = 0 var grotto_view: Node3D var _materials: Dictionary = {} var _props: Dictionary = {} ## "team:lane" -> deployment pad, team -> grotto entrance ring var _pads: Dictionary = {} var _grotto_pads: Dictionary = {} func build(match_map: LuneMap) -> void: map = match_map name = "LuneWorld" terrain = LuneTerrain.for_map(map) _environment() _ground() _water() _load_props() for lane: String in LuneMap.LANES: _paving(lane) _central_platform() _delta_bridge() _curbs() for team: int in range(2): _base(team) _landmarks() _vegetation() tower_fx = TowerFx.new() add_child(tower_fx) for definition: Dictionary in map.buildings: if definition.tier == "core": _ancient(definition) else: _tower(definition) for team: int in range(2): _build_veins(team) _grotto() _deployment_pads() # ---------------------------------------------------------------- helpers ## Height a unit or building stands on (lane decks instead of the river bed), including the ## base plaza and the Ancient's dais steps, which are presentation only (G2). func height_at(p: Vector2) -> float: return terrain.walk_height(p) + surface_lift(p) func surface_lift(p: Vector2) -> float: for team: int in range(2): var d: float = p.distance_to(map.core_position(team)) if d < PLAZA_RADIUS: return PLAZA_LIFT + AncientView.surface_lift(d) if p.distance_to(LuneMap.GROTTO) < LuneTerrain.ARENA_RADIUS: return GrottoView.surface_lift(p - LuneMap.GROTTO) return 0.0 func to3(v: Vector2, lift: float = 0.0) -> Vector3: return Vector3(v.x, terrain.walk_height(v) + lift, v.y) func _ground3(v: Vector2, lift: float = 0.0) -> Vector3: return Vector3(v.x, terrain.height_at(v) + lift, v.y) ## Kluft surface material for ENV_* keys (flags come from the key: paving, soil, leaf, bark). func surface(key: String, color: Color) -> ShaderMaterial: if not _materials.has(key): var source := StandardMaterial3D.new() source.resource_name = key source.albedo_color = color _materials[key] = Sanctuary.terrain_material(source) return _materials[key] static func _glow(color: Color, energy: float) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.albedo_color = color material.emission_enabled = true material.emission = color material.emission_energy_multiplier = energy material.roughness = .3 return material func _multi(mesh: Mesh, material: Material, transforms: Array[Transform3D], label: String, shadows: bool = true) -> MultiMeshInstance3D: var multimesh := MultiMesh.new() multimesh.transform_format = MultiMesh.TRANSFORM_3D multimesh.mesh = mesh 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 = label instance.multimesh = multimesh if material != null: instance.material_override = material instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(instance) return instance func _prop_multi(prop: String, transforms: Array[Transform3D], shadows: bool = true) -> void: if not transforms.is_empty(): _multi(_props[prop], null, transforms, "Props_" + prop, shadows) static func _yaw_basis(yaw: float, scale: Vector3 = Vector3.ONE) -> Basis: return Basis(Vector3.UP, yaw) * Basis.from_scale(scale) ## Yaw that turns a model's +Z towards the given ground direction. static func _facing(direction: Vector2) -> float: return atan2(direction.x, direction.y) # ---------------------------------------------------------------- environment and ground func _environment() -> void: var environment := WorldEnvironment.new() var settings := Environment.new() Lighting.configure(settings) # The camera sits 40–170 m away; distance fog would wash out the board (G5 revisits). settings.fog_enabled = false environment.environment = settings add_child(environment) 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_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS sun.directional_shadow_max_distance = 190.0 sun.shadow_blur = 1.6 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 material := ShaderMaterial.new() material.shader = GroundShader material.set_shader_parameter("stone_map", Stone) var ground := MeshInstance3D.new() ground.name = "Ground" ground.mesh = terrain.inner_mesh() ground.material_override = material add_child(ground) var outer := MeshInstance3D.new() outer.name = "Mountains" outer.mesh = terrain.outer_mesh() outer.material_override = material # The distant ring lies outside the board; its shadows would only cost shadow-map time. outer.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(outer) func _water() -> void: var water_material := ShaderMaterial.new() water_material.shader = RiverShader var water := MeshInstance3D.new() water.name = "Water" var plane := PlaneMesh.new() plane.size = Vector2(LuneTerrain.OUTER_EXTENT * 2.0, LuneTerrain.OUTER_EXTENT * 2.0) water.mesh = plane water.position.y = LuneTerrain.WATER_LEVEL water.material_override = water_material water.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(water) # The spring leaves the grotto terrace between the two stairs as a short waterfall. var top: Vector2 = LuneMap.GROTTO + LuneMap.AXIS * (LuneTerrain.ARENA_RADIUS - 0.5) var bottom: Vector2 = LuneMap.GROTTO + LuneMap.AXIS * (LuneTerrain.ARENA_RADIUS + 1.7) var across: Vector2 = LuneMap.BLUE_SIDE * .9 var corners: Array[Vector3] = [Vector3(top.x + across.x, LuneTerrain.ARENA_HEIGHT - 0.05, top.y + across.y), Vector3(top.x - across.x, LuneTerrain.ARENA_HEIGHT - 0.05, top.y - across.y), Vector3(bottom.x - across.x, LuneTerrain.WATER_LEVEL + .02, bottom.y - across.y), Vector3(bottom.x + across.x, LuneTerrain.WATER_LEVEL + .02, bottom.y + across.y)] var tool := SurfaceTool.new() tool.begin(Mesh.PRIMITIVE_TRIANGLES) # Both windings, so the fall is visible from every camera angle. for index: int in [0, 1, 2, 0, 2, 3, 0, 2, 1, 0, 3, 2]: tool.add_vertex(corners[index]) tool.generate_normals() var fall := MeshInstance3D.new() fall.name = "SpringFall" fall.mesh = tool.commit() var foam := StandardMaterial3D.new() foam.albedo_color = Color(.72, .86, .9, .82) foam.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA foam.emission_enabled = true foam.emission = Color(.35, .5, .55) foam.emission_energy_multiplier = .35 foam.roughness = .2 fall.material_override = foam fall.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(fall) ## Shadows cover what the camera shows: close views keep the shadow map dense and cheap, ## the overview reaches the whole board. func fit_shadows(camera_distance: float) -> void: sun.directional_shadow_max_distance = clampf(camera_distance * 1.3 + 25.0, 70.0, 220.0) # ---------------------------------------------------------------- lanes ## Lane sections that a deck replaces: the central platform on Mid, the delta bridge on Bottom. func _deck_gap(lane: String, point: Vector2) -> bool: if lane == "mid": return point.length() < LuneTerrain.PLATFORM_RADIUS - 0.4 if lane == "bot": return point.distance_to(delta_point()) < 7.2 return false ## Where Bottom crosses the Kluft delta (its midpoint, on the symmetry axis). func delta_point() -> Vector2: var path: LuneMap.LanePath = map.lanes["bot"] return path.point_at(path.length * 0.5) func _paving(lane: String) -> void: var path: LuneMap.LanePath = map.lanes[lane] var half: float = PAVING[lane] var tool := SurfaceTool.new() tool.begin(Mesh.PRIMITIVE_TRIANGLES) var steps: int = int(ceil(path.length / 0.8)) var previous: Array[Vector3] = [] var previous_gap: bool = true 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 + .6)) - path.point_at(maxf(0.0, s - .6))).normalized() var normal := Vector2(-tangent.y, tangent.x) var y: float = terrain.lane_height(lane, s) + 0.05 # Outer shoulder, paving edge, paving edge, outer shoulder (a bevel into the ground). var section: Array[Vector3] = [] for offset: Array in [[half + .45, -.17], [half, 0.0], [-half, 0.0], [-half - .45, -.17]]: var at: Vector2 = point + normal * float(offset[0]) section.append(Vector3(at.x, y + float(offset[1]), at.y)) var gap: bool = _deck_gap(lane, point) if i > 0 and not gap and not previous_gap: for k: int in range(3): for vertex: Vector3 in [previous[k], section[k + 1], section[k], previous[k], previous[k + 1], section[k + 1]]: tool.add_vertex(vertex) previous = section previous_gap = gap tool.generate_normals() var ribbon := MeshInstance3D.new() ribbon.name = "Paving_" + lane ribbon.mesh = tool.commit() var spec: Array = PAVING_MATERIAL[lane] ribbon.material_override = surface(spec[0], spec[1]) ribbon.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(ribbon) ## Mid crosses the Kluft on a round platform: pier, parapet, rune circle, four standing stones. func _central_platform() -> void: var y: float = LuneTerrain.PLATFORM_HEIGHT var pier := MeshInstance3D.new() pier.name = "CentralPlatform" var cylinder := CylinderMesh.new() cylinder.top_radius = LuneTerrain.PLATFORM_RADIUS cylinder.bottom_radius = LuneTerrain.PLATFORM_RADIUS + .7 cylinder.height = 2.6 cylinder.radial_segments = 56 pier.mesh = cylinder pier.position = Vector3(0, y + .05 - 1.3, 0) pier.material_override = surface("ENV_limestone_platform", Color(.56, .52, .46)) add_child(pier) var inlay := MeshInstance3D.new() var disc := CylinderMesh.new() disc.top_radius = LuneTerrain.PLATFORM_RADIUS - 1.0 disc.bottom_radius = disc.top_radius disc.height = .04 disc.radial_segments = 56 inlay.mesh = disc inlay.position = Vector3(0, y + .07, 0) inlay.material_override = surface("ENV_paving_plaza", Color(.6, .56, .49)) add_child(inlay) var ring := MeshInstance3D.new() var torus := TorusMesh.new() torus.inner_radius = 3.25 torus.outer_radius = 3.5 torus.rings = 64 ring.mesh = torus ring.scale = Vector3(1, .12, 1) ring.position = Vector3(0, y + .1, 0) ring.material_override = _glow(Color("d9ccff"), .9) ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(ring) var parapet := MeshInstance3D.new() var rim := TorusMesh.new() rim.inner_radius = LuneTerrain.PLATFORM_RADIUS - .35 rim.outer_radius = LuneTerrain.PLATFORM_RADIUS + .05 rim.rings = 64 parapet.mesh = rim parapet.scale = Vector3(1, .9, 1) parapet.position = Vector3(0, y + .15, 0) parapet.material_override = surface("ENV_limestone_parapet", Color(.5, .47, .41)) add_child(parapet) var stones: Array[Transform3D] = [] for direction: Vector2 in [Vector2(1, 0), Vector2(0, 1), Vector2(-1, 0), Vector2(0, -1)]: var at: Vector2 = direction * 5.3 var lean: float = .05 if direction.x + direction.y > 0.0 else -.05 stones.append(Transform3D(_yaw_basis(_facing(-direction)) * Basis(Vector3.RIGHT, lean), Vector3(at.x, y + 1.75, at.y))) var slab := BoxMesh.new() slab.size = Vector3(1.0, 3.6, .62) _multi(slab, surface("ENV_cliff_runestone", Color(.44, .42, .45)), stones, "RuneStones") var runes: Array[Transform3D] = [] for stone: Transform3D in stones: runes.append(Transform3D(stone.basis, stone.origin + stone.basis.z * .32 + Vector3(0, .5, 0))) var rune := BoxMesh.new() rune.size = Vector3(.34, 1.5, .04) _multi(rune, _glow(Color("cdb8ff"), 1.3), runes, "RuneGlyphs", false) ## Bottom crosses the delta on the existing bridge prop, stretched to span the wider water. func _delta_bridge() -> void: var at: Vector2 = delta_point() var path: LuneMap.LanePath = map.lanes["bot"] var deck: float = terrain.lane_height("bot", path.length * 0.5) + 0.05 var bridge := MeshInstance3D.new() bridge.name = "DeltaBridge" bridge.mesh = _props["Bridge"] # The prop's deck surface lies 0.10 m above its origin. bridge.transform = Transform3D(_yaw_basis(PI * .25, Vector3(1.34, 1.0, 1.0)), Vector3(at.x, deck - 0.1, at.y)) add_child(bridge) ## Curb stones along Top and Mid, skipped on decks, at bases and around towers. func _curbs() -> void: var transforms: Array[Transform3D] = [] for lane: String in ["top", "mid"]: var path: LuneMap.LanePath = map.lanes[lane] var offset: float = float(PAVING[lane]) + .45 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 _deck_gap(lane, at) and at.length() > LuneTerrain.PLATFORM_RADIUS + .6 for team: int in range(2): clear = clear and map.core_position(team).distance_to(at) > LuneTerrain.BASE_RADIUS + 0.8 for definition: Dictionary in map.buildings: clear = clear and Vector2(definition.position).distance_to(at) > 2.3 if clear: transforms.append(Transform3D(_yaw_basis(atan2(-tangent.y, tangent.x)), Vector3(at.x, terrain.lane_height(lane, s) - .06, at.y))) s += 1.55 _prop_multi("Curb", transforms, false) # ---------------------------------------------------------------- bases ## Plateau plaza with the team rim, a ring wall with three lane gates and gate crystals. func _base(team: int) -> void: var core: Vector2 = map.core_position(team) var y: float = LuneTerrain.BASE_HEIGHT var plaza := MeshInstance3D.new() plaza.name = "BasePlaza_%d" % team var disc := CylinderMesh.new() disc.top_radius = 8.4 disc.bottom_radius = 8.7 disc.height = .12 disc.radial_segments = 56 plaza.mesh = disc plaza.position = Vector3(core.x, y + .02, core.y) plaza.material_override = surface("ENV_limestone_plaza", Color(.52, .48, .4)) add_child(plaza) var rim := MeshInstance3D.new() var ring := TorusMesh.new() ring.inner_radius = 8.25 ring.outer_radius = 8.5 ring.rings = 64 rim.mesh = ring rim.material_override = _glow(TEAM_COLORS[team], 1.2) rim.position = Vector3(core.x, y + .1, core.y) rim.scale = Vector3(1, .3, 1) rim.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(rim) var gates: Array[float] = [] for lane: String in LuneMap.LANES: var start: Vector2 = map.lane_point(team, lane, 0.0) gates.append(atan2(start.y - core.y, start.x - core.x)) var walls: Array[Transform3D] = [] var pillars: Array[Transform3D] = [] var gems: Array[Transform3D] = [] var radius: float = LuneTerrain.BASE_RADIUS - .2 var segments: int = 40 for index: int in range(segments): var angle: float = TAU * index / segments var near_gate: float = PI for gate: float in gates: near_gate = minf(near_gate, absf(wrapf(angle - gate, -PI, PI))) var at: Vector2 = core + Vector2(cos(angle), sin(angle)) * radius if near_gate < deg_to_rad(15.0): continue if near_gate < deg_to_rad(24.0): pillars.append(Transform3D(Basis.IDENTITY, Vector3(at.x, y + 1.7, at.y))) gems.append(Transform3D(Basis(Vector3.UP, angle), Vector3(at.x, y + 3.75, at.y))) continue walls.append(Transform3D(_yaw_basis(-angle + PI * .5), Vector3(at.x, y + .5, at.y))) var wall := BoxMesh.new() wall.size = Vector3(1.72, 1.2, .7) _multi(wall, surface("ENV_limestone_wall", Color(.5, .47, .42)), walls, "BaseWall_%d" % team) var pillar := CylinderMesh.new() pillar.top_radius = .42 pillar.bottom_radius = .55 pillar.height = 3.4 pillar.radial_segments = 10 _multi(pillar, surface("ENV_limestone_gate", Color(.55, .51, .45)), pillars, "BaseGates_%d" % team) var gem := SphereMesh.new() gem.radius = .45 gem.height = 1.1 gem.radial_segments = 6 gem.rings = 2 _multi(gem, _glow(TEAM_COLORS[team], 2.4), gems, "GateCrystals_%d" % team, false) # ---------------------------------------------------------------- landmarks (blockout) ## Top: a ruined lookout on the highland corner, an old gate and broken watch walls. ## Bottom: overgrown wall stubs in the lowland. Every piece is mirrored for Rot. func _landmarks() -> void: var walls: Array[Transform3D] = [] var columns: Array[Transform3D] = [] for team: int in range(2): # Old gate where Top reaches the highland. for side: float in [-1.0, 1.0]: var post: Vector2 = map.lane_point(team, "top", 36.0, side * 4.7) columns.append(Transform3D(Basis.from_scale(Vector3(1, 1.25, 1)), _ground3(post, 1.4))) # Broken watch walls on the outer edge of the highland path. for spec: Vector3 in [Vector3(29.0, -6.2, 3.4), Vector3(41.5, -6.4, 2.6), Vector3(45.0, 5.8, 2.0)]: var at: Vector2 = map.lane_point(team, "top", spec.x, spec.y) var along: Vector2 = map.lane_forward(team, "top", spec.x) walls.append(Transform3D(_yaw_basis(atan2(-along.y, along.x), Vector3(spec.z, 1.0, 1.0)), _ground3(at, .45))) # Overgrown stubs beside Bottom in the lowland. for spec: Vector3 in [Vector3(24.0, 6.4, 2.2), Vector3(39.0, -6.3, 1.6)]: var at: Vector2 = map.lane_point(team, "bot", spec.x, spec.y) var along: Vector2 = map.lane_forward(team, "bot", spec.x) walls.append(Transform3D(_yaw_basis(atan2(-along.y, along.x) + .3, Vector3(spec.z, .7, 1.0)), _ground3(at, .2))) var block := BoxMesh.new() block.size = Vector3(1.0, 1.1, .7) _multi(block, surface("ENV_limestone_ruin", Color(.5, .48, .44)), walls, "RuinWalls") var column := CylinderMesh.new() column.top_radius = .38 column.bottom_radius = .48 column.height = 2.4 column.radial_segments = 10 _multi(column, surface("ENV_limestone_ruin", Color(.5, .48, .44)), columns, "RuinColumns") # The lookout on the Top corner hill, on the symmetry axis. var lookout := Vector2(-31.0, -31.0) var tower := MeshInstance3D.new() tower.name = "Lookout" var shaft := CylinderMesh.new() shaft.top_radius = 2.0 shaft.bottom_radius = 2.6 shaft.height = 4.6 shaft.radial_segments = 14 tower.mesh = shaft tower.position = _ground3(lookout, 2.1) tower.material_override = surface("ENV_limestone_ruin", Color(.5, .48, .44)) add_child(tower) var crown := MeshInstance3D.new() var broken := CylinderMesh.new() broken.top_radius = 1.6 broken.bottom_radius = 2.0 broken.height = 1.4 broken.radial_segments = 7 crown.mesh = broken crown.position = _ground3(lookout, 5.0) crown.rotation = Vector3(.12, .4, -.12) crown.material_override = tower.material_override add_child(crown) # ---------------------------------------------------------------- vegetation ## Clusters, not scatter: a noise field decides where forests stand; dense cores carry ## the big trees, edges the small ones and shrubs, clearings stay open. The species ## follow the zone (highland pines, valley mixed wood, lowland oaks and reeds, mountain ## pines). The Blau side is generated and mirrored, so decoration never favours a team. func _vegetation() -> void: var noise := FastNoiseLite.new() noise.seed = 1203 noise.frequency = .055 noise.fractal_octaves = 2 var rng := RandomNumberGenerator.new() rng.seed = 90210 var buckets: Dictionary = {} var add := func(kind: String, p: Vector2, size: float, yaw: float, squash: Vector3 = Vector3.ONE) -> void: if not buckets.has(kind): buckets[kind] = [] as Array[Transform3D] for mirrored: bool in [false, true]: var at: Vector2 = LuneMap.mirror(p) if mirrored else p var basis: Basis = _yaw_basis(-yaw if mirrored else yaw, squash * size) (buckets[kind] as Array[Transform3D]).append(Transform3D(basis, _ground3(at, -.06))) var spacing: float = 2.25 var x: float = -50.0 while x <= 50.0: var z: float = -50.0 while z <= 50.0: var p := Vector2(x + rng.randf_range(-.95, .95), z + rng.randf_range(-.95, .95)) var roll: float = rng.randf() var yaw: float = rng.randf() * TAU var jitter: float = rng.randf_range(.85, 1.2) z += spacing if p.y <= p.x or not _plantable(p): continue var density: float = noise.get_noise_2d(p.x, p.y) * .5 + .5 var u: float = LuneTerrain.axis_u(p) var a: float = LuneTerrain.axis_a(p) var edge: float = terrain.escarpment(a) var border: float = pow(pow(absf(p.x), 5.0) + pow(absf(p.y), 5.0), .2) if border > 39.5: if density > .56 and roll < .55: add.call(["Pine_0", "Pine_1", "Pine_2"][int(roll * 6.0) % 3], p, jitter * 1.15, yaw) elif roll > .93: add.call("Rock_1", p, jitter * 1.6, yaw) continue if absf(u - edge) < 1.9: if roll < .35: add.call(["Rock_0", "Rock_1", "Rock_2"][int(roll * 9.0) % 3], p, jitter * 1.35, yaw) continue if density < .43: if roll > .965: add.call("Rock_2", p, jitter, yaw) continue var core: bool = density > .62 if u < edge: if core or roll < .6: add.call(["Pine_0", "Pine_1", "Pine_2"][int(roll * 7.0) % 3], p, jitter * (1.25 if core else .95), yaw) elif roll < .82: add.call("Rock_0", p, jitter, yaw) elif u < 20.0: if core: add.call(["Oak_0", "Oak_1", "Pine_1"][int(roll * 7.0) % 3], p, jitter * 1.2, yaw) elif roll < .45: add.call(["Oak_2", "Pine_2"][int(roll * 5.0) % 2], p, jitter * .9, yaw) elif roll < .8: add.call(["Shrub_0", "Shrub_1"][int(roll * 5.0) % 2], p, jitter, yaw) else: if core: add.call(["Oak_0", "Oak_1", "Oak_2"][int(roll * 7.0) % 3], p, jitter * 1.1, yaw) elif roll < .55: add.call(["Shrub_0", "Shrub_1"][int(roll * 5.0) % 2], p, jitter * 1.1, yaw) elif roll < .75: add.call("Shrub_0", p, jitter * .8, yaw, Vector3(.5, 1.9, .5)) x += spacing # River banks: stones along the Kluft, reeds in the lowland. var u_bank: float = -8.0 while u_bank < 44.0: var a_bank: float = terrain.river_half_width(u_bank) + .5 var p: Vector2 = LuneMap.AXIS * u_bank + LuneMap.BLUE_SIDE * a_bank var roll: float = rng.randf() if _bank_clear(p): if u_bank > 18.0 and roll < .6: add.call("Shrub_0", p, rng.randf_range(.7, 1.0), rng.randf() * TAU, Vector3(.45, 2.0, .45)) elif roll < .7: add.call(["Rock_0", "Rock_2"][int(roll * 4.0) % 2], p, rng.randf_range(.7, 1.05), rng.randf() * TAU) u_bank += rng.randf_range(2.0, 3.4) for kind: String in buckets: _prop_multi(kind, buckets[kind], not kind.begins_with("Shrub")) ## Keeps lanes, bases, towers, the grotto, the platform, the river and open water clear. func _plantable(p: Vector2) -> bool: if maxf(absf(p.x), absf(p.y)) > 50.0: return false var lane: Array = terrain.nearest_lane(p) if float(lane[0]) < float(LuneTerrain.CORRIDOR.get(lane[1], 3.6)) + 1.9: return false for team: int in range(2): if p.distance_to(map.core_position(team)) < LuneTerrain.BASE_RADIUS + 1.8: return false if p.distance_to(LuneMap.GROTTO) < LuneTerrain.ARENA_RADIUS + 4.6 or p.length() < LuneTerrain.PLATFORM_RADIUS + 3.0: return false if p.distance_to(Vector2(-31.0, -31.0)) < 4.2: return false var u: float = LuneTerrain.axis_u(p) if LuneTerrain.axis_a(p) < terrain.river_half_width(u) + 1.6: return false if terrain.height_at(p) < LuneTerrain.WATER_LEVEL + .12: return false for definition: Dictionary in map.buildings: if Vector2(definition.position).distance_to(p) < 4.2: return false return true func _bank_clear(p: Vector2) -> bool: if p.length() < LuneTerrain.PLATFORM_RADIUS + 1.5 or p.distance_to(delta_point()) < 8.5: return false var lane: Array = terrain.nearest_lane(p) return float(lane[0]) > float(LuneTerrain.CORRIDOR.get(lane[1], 3.6)) + .8 # ---------------------------------------------------------------- props ## 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 index: int in range(mesh.get_surface_count()): var source := mesh.surface_get_material(index) as StandardMaterial3D if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"): mesh.surface_set_material(index, surface(source.resource_name, source.albedo_color)) _props[str(child.name)] = mesh scene.free() # ---------------------------------------------------------------- grotto ## The boss grotto "Mondschlund" (G2 Blender asset) on the G1 terrace in front of the ## escarpment: arena, boss dais, altar with the Mondquelle, monumental cave gate, old columns ## and cliff masses. The G1 stairs (one per team) and the spring stay; the grotto view owns ## lights, states and the resting boss. func _grotto() -> void: var center: Vector2 = LuneMap.GROTTO grotto_view = GrottoView.new() grotto_view.position = Vector3(center.x, LuneTerrain.ARENA_HEIGHT, center.y) add_child(grotto_view) grotto_view.setup() # One stair per team from the valley up to its entrance. var steps: Array[Transform3D] = [] for team: int in range(2): var entry: Vector2 = map.grotto_entry(team) var outward: Vector2 = (entry - center).normalized() for k: int in range(5): var at: Vector2 = center + outward * (LuneTerrain.ARENA_RADIUS + .15 + k * .72) steps.append(Transform3D(_yaw_basis(_facing(outward)), _ground3(at, -.08))) var stair := BoxMesh.new() stair.size = Vector3(3.0, .34, .78) _multi(stair, surface("ENV_limestone_stair", Color(.5, .47, .44)), steps, "GrottoStairs") ## True if a prop of the given radius at p stays outside every lane corridor. func _clear_of_lanes(p: Vector2, radius: float) -> bool: var lane: Array = terrain.nearest_lane(p) return float(lane[0]) > float(LuneTerrain.CORRIDOR.get(lane[1], 3.6)) + radius ## Grotto states from the public match state (see LuneGrottoView). func sync_grotto(state: LuneMatch, views: Dictionary, delta: float) -> void: if grotto_view != null: grotto_view.sync(state, views, delta) # ---------------------------------------------------------------- deployment areas ## One pad per team and lane where the army forms up (rows of four, front row ahead), ## and one ring per team at its grotto entrance. Shown during planning only. func _deployment_pads() -> void: var span: Dictionary = map.deployment_span() var start: float = maxf(0.0, float(span.from) - .4) var finish: float = float(span.to) + .4 var half: float = float(span.half_width) for team: int in range(2): for lane: String in LuneMap.LANES: var tool := SurfaceTool.new() tool.begin(Mesh.PRIMITIVE_TRIANGLES) var previous: Array = [] var progress: float = start while progress <= finish + 0.001: var left: Vector2 = map.lane_point(team, lane, progress, half) var right: Vector2 = map.lane_point(team, lane, progress, -half) var y: float = terrain.walk_height(map.lane_point(team, lane, progress)) + .09 var section: Array = [[Vector3(left.x, y, left.y), Vector2(1.0, progress - start)], [Vector3(right.x, y, right.y), Vector2(-1.0, progress - start)]] if not previous.is_empty(): for corner: Array in [previous[0], section[1], section[0], previous[0], previous[1], section[1]]: tool.set_uv(corner[1]) tool.add_vertex(corner[0]) previous = section progress += .5 var pad := MeshInstance3D.new() pad.name = "Deployment_%d_%s" % [team, lane] pad.mesh = tool.commit() var material := ShaderMaterial.new() material.shader = PadShader material.set_shader_parameter("tint", TEAM_COLORS[team]) material.set_shader_parameter("pad_length", finish - start) material.set_shader_parameter("row_start", float(span.to) - start) pad.material_override = material pad.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF pad.visible = false add_child(pad) _pads["%d:%s" % [team, lane]] = pad var ring := MeshInstance3D.new() ring.name = "GrottoEntry_%d" % team var torus := TorusMesh.new() torus.inner_radius = 1.9 torus.outer_radius = 2.15 torus.rings = 40 ring.mesh = torus ring.scale = Vector3(1, .15, 1) var entry: Vector2 = map.grotto_entry(team) ring.position = Vector3(entry.x, LuneTerrain.ARENA_HEIGHT + .12, entry.y) ring.material_override = _glow(TEAM_COLORS[team], 1.6) ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF ring.visible = false add_child(ring) _grotto_pads[team] = ring ## Planning shows the own team's formation areas; lanes with own units are highlighted. func show_deployment(visible_now: bool, team: int, used: Dictionary, grotto_open: bool) -> void: for key: String in _pads: var pad: MeshInstance3D = _pads[key] var parts: PackedStringArray = key.split(":") pad.visible = visible_now and int(parts[0]) == team if pad.visible: (pad.material_override as ShaderMaterial).set_shader_parameter("highlight", 1.0 if int(used.get(parts[1], 0)) > 0 else 0.0) for side: int in _grotto_pads: (_grotto_pads[side] as MeshInstance3D).visible = visible_now and grotto_open and side == team # ---------------------------------------------------------------- buildings ## G2 tower: Grenzwacht (outer), Bastion (inner) or Kernwacht (base) with damage states. ## Lane towers turn their front (+z) to the lane; base towers face away from their Ancient. func _tower(definition: Dictionary) -> void: var team: int = int(definition.team) var at: Vector2 = definition.position var facing: Vector2 if definition.tier == "base": facing = (at - map.core_position(team)).normalized() else: facing = (map.lane_point(team, str(definition.lane), float(definition.progress)) - at).normalized() var view: Node3D = TowerView.new() view.position = to3(at) add_child(view) view.setup(definition, facing) tower_views[definition.id] = view building_nodes[definition.id] = view ## G2 Ancient ("Mondherz") on the plaza, facing the map centre. func _ancient(definition: Dictionary) -> void: var at: Vector2 = definition.position var view: Node3D = AncientView.new() view.position = to3(at, PLAZA_LIFT) add_child(view) view.setup(definition, (-at).normalized()) ancient_views[definition.id] = view building_nodes[definition.id] = view ## Energy veins carved into the plaza: from the Ancient's pedestal to both base towers and ## out towards the three lane gates. Their glow follows the Ancient and the tower they feed. func _build_veins(team: int) -> void: var core: Vector2 = map.core_position(team) var targets: Array = [] for definition: Dictionary in map.buildings: if int(definition.team) == team and definition.tier == "base": targets.append([Vector2(definition.position), str(definition.id), 1.9]) for lane: String in LuneMap.LANES: var gate: Vector2 = map.lane_point(team, lane, 0.0) targets.append([core + (gate - core).normalized() * (PLAZA_RADIUS - .3), "", 0.0]) var list: Array = [] var index: int = 0 for target: Array in targets: var goal: Vector2 = target[0] var direction: Vector2 = (goal - core).normalized() var normal := Vector2(-direction.y, direction.x) var start: Vector2 = core + direction * 2.05 var finish: Vector2 = goal - direction * float(target[2]) var length: float = start.distance_to(finish) var tool := SurfaceTool.new() tool.begin(Mesh.PRIMITIVE_TRIANGLES) var steps: int = maxi(4, int(length / .25)) var previous: Array = [] for k: int in range(steps + 1): var t: float = float(k) / steps var wave: float = sin(t * 7.0 + index * 1.7) * .22 * sin(PI * t) var at: Vector2 = start.lerp(finish, t) + normal * wave var width: float = lerpf(.24, .14, t) var y: float = terrain.walk_height(at) + surface_lift(at) + .012 var left: Vector2 = at + normal * width var right: Vector2 = at - normal * width var section: Array = [[Vector3(left.x, y, left.y), Vector2(t * length, 0.0)], [Vector3(right.x, y, right.y), Vector2(t * length, 1.0)]] if not previous.is_empty(): for corner: Array in [previous[0], section[1], section[0], previous[0], previous[1], section[1]]: tool.set_uv(corner[1]) tool.set_normal(Vector3.UP) tool.add_vertex(corner[0]) previous = section var vein := MeshInstance3D.new() vein.name = "Vein_%d_%d" % [team, index] vein.mesh = tool.commit() var material := ShaderMaterial.new() material.shader = VeinShader vein.material_override = material vein.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(vein) vein.set_instance_shader_parameter("tint", Materials.team_color(team, "crystal")) vein.set_instance_shader_parameter("seed", float(index) * .37) list.append([vein, target[1]]) index += 1 _veins[team] = list ## G2: all strategic objects from the authoritative match state. `views` maps unit ids to ## unit views (bolt targets); sim_delta is the simulation time of this frame (0 when paused). func sync_strategic(state: LuneMatch, views: Dictionary, time: float, delta: float, sim_delta: float) -> void: var started: int = Time.get_ticks_usec() var hp: Dictionary = state.building_hp.duplicate() var combat: LuneCombat = state.combat if state.phase == LuneMatch.Phase.COMBAT else null if combat != null: for id: String in combat.buildings: hp[id] = (combat.buildings[id] as LuneUnit).hp var max_hp: Dictionary = {} for definition: Dictionary in map.buildings: max_hp[definition.id] = definition.max_hp var interval: float = state.balance.tower_attack_interval for id: String in tower_views: var view: Node3D = tower_views[id] view.set_health(float(hp.get(id, 0.0)), float(max_hp[id]), _synced_once) var unit: LuneUnit = combat.buildings.get(id) if combat != null else null var target_view: Node3D = null if unit != null and unit.target != null: target_view = views.get(unit.target.id) var request: Array = view.update_attack(unit, target_view, interval) if not request.is_empty(): tower_fx.launch(request[0], request[1], request[2], view.team, view.tier == "base") tower_fx.mark(id, target_view if unit != null and unit.in_range else null, view.charge(), view.team) view.process_view(delta, sim_delta) tower_fx.process_fx(delta, sim_delta) for id: String in ancient_views: var ancient: Node3D = ancient_views[id] ancient.set_health(float(hp.get(id, 0.0)), float(max_hp[id]), _synced_once) ancient.process_view(delta, sim_delta) for team: int in _veins: var ancient: Node3D = ancient_views["blau_ancient" if team == 0 else "rot_ancient"] for entry: Array in _veins[team]: var feeds: String = entry[1] var alive: bool = feeds.is_empty() or float(hp.get(feeds, 0.0)) > 0.0 var vein: MeshInstance3D = entry[0] vein.set_instance_shader_parameter("energy", ancient.vein_energy() * (1.0 if alive else .08)) vein.set_instance_shader_parameter("flicker", ancient.vein_flicker() if alive else .9) _synced_once = true _occlusion_frame += 1 if _occlusion_frame % 2 == 0: update_occlusion(tower_fx.camera, views) strategic_usec = lerpf(strategic_usec, float(Time.get_ticks_usec() - started), .1) ## Buildings, ancients and the grotto gate thin out (dithered, above head height) while a ## living figure or the camera focus lies behind them on screen. Purely visual. func update_occlusion(camera: Camera3D, views: Dictionary) -> void: if camera == null or not camera.is_inside_tree(): return var screen: Rect2 = camera.get_viewport().get_visible_rect() var eye: Vector3 = camera.global_position var forward: Vector3 = -camera.global_basis.z forward.y = 0.0 forward = forward.normalized() if forward.length() > .001 else Vector3.FORWARD # [screen point, ground depth along the view, important] var points: Array = [] for view: Node3D in views.values(): if not view.visible or view.unit == null or not view.unit.alive: continue var at: Vector3 = view.aim_point() if not camera.is_position_behind(at): points.append([camera.unproject_position(at), (at - eye).dot(forward), view.unit.kind in ["hero", "champion", "boss"]]) var important: Array = points.filter(func(entry: Array) -> bool: return bool(entry[2])) # The camera focus counts for the big buildings only; slim towers never fade for the ground. var focus: Vector3 = camera.focus points.append([camera.unproject_position(focus), (focus - eye).dot(forward), true]) for building: Node3D in tower_views.values(): var slim: bool = building.tier != "base" var hidden: bool = _hides_something(building.occluder_box(), camera, screen, eye, forward, important if slim else points) building.fade_target = (OCCLUSION_FADE_SLIM if slim else OCCLUSION_FADE) if hidden else 0.0 for building: Node3D in ancient_views.values(): building.fade_target = OCCLUSION_FADE if _hides_something(building.occluder_box(), camera, screen, eye, forward, points) else 0.0 if grotto_view != null: grotto_view.fade_target = OCCLUSION_FADE if _hides_something(grotto_view.occluder_box(), camera, screen, eye, forward, points) else 0.0 ## True if a point lies inside the building's screen rectangle and further along the view ## (on the ground plane) than the building's far side. func _hides_something(box: AABB, camera: Camera3D, screen: Rect2, eye: Vector3, forward: Vector3, points: Array) -> bool: if box.size == Vector3.ZERO: return false var rect := Rect2() var first: bool = true for index: int in range(8): var corner: Vector3 = box.get_endpoint(index) if camera.is_position_behind(corner): continue var at: Vector2 = camera.unproject_position(corner) rect = Rect2(at, Vector2.ZERO) if first else rect.expand(at) first = false if first or not rect.intersects(screen): return false var center: Vector3 = box.get_center() var reach: float = maxf(box.size.x, box.size.z) * .5 var depth: float = (center - eye).dot(forward) + reach * .6 rect = rect.grow_individual(-rect.size.x * .08, 0.0, -rect.size.x * .08, 0.0) for entry: Array in points: if float(entry[1]) > depth and rect.has_point(entry[0]): return true return false