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. Existing assets are reused: the lane tower, the ## Saumkern Ancients, the grotto rock arc, map props (trees, rocks, curbs, bridge, ## banners) and the Kluft surface shader. New shapes are simple blockout primitives ## that G2/G4 replace. Purely visual; the simulation never reads anything from 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 Stone = preload("res://assets/materials/phase10/limestone-albedo.png") const TOWER := "res://assets/luneraria/tower.glb" const GROTTO_SCENE := "res://assets/luneraria/grotto.glb" const PROPS := "res://assets/luneraria/map_props.glb" const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] const TEAM_NAMES: Array[String] = ["Blau", "Rot"] const GROTTO_COLOR := Color("b37cff") ## 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 = {} var grotto_light: OmniLight3D var _grotto_runes: StandardMaterial3D var _materials: Dictionary = {} var _crystal_materials: Array[StandardMaterial3D] = [] 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() 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() _deployment_pads() # ---------------------------------------------------------------- helpers ## Height a unit or building stands on (lane decks instead of the river bed). func height_at(p: Vector2) -> float: return terrain.walk_height(p) 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 var _vertex_colored: StandardMaterial3D ## 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() func _dress(node: Node) -> void: if node is MeshInstance3D: for index: int in range(node.mesh.get_surface_count()): var source := node.mesh.surface_get_material(index) as StandardMaterial3D if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"): node.set_surface_override_material(index, surface(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(index, _vertex_colored) for child: Node in node.get_children(): _dress(child) # ---------------------------------------------------------------- grotto ## Blockout of the boss grotto: the terrace arena in front of the escarpment, the rock ## arc as cave mouth, a ring of pillars around the back, cliffs for the silhouette, ## one stair per team up to its entrance and the spring falling towards Mid. func _grotto() -> void: var center: Vector2 = LuneMap.GROTTO var y: float = LuneTerrain.ARENA_HEIGHT var arena := MeshInstance3D.new() arena.name = "GrottoArena" var disc := CylinderMesh.new() disc.top_radius = LuneTerrain.ARENA_RADIUS - .25 disc.bottom_radius = LuneTerrain.ARENA_RADIUS disc.height = .1 disc.radial_segments = 56 arena.mesh = disc arena.position = Vector3(center.x, y + .02, center.y) arena.material_override = surface("ENV_paving_arena", Color(.4, .37, .42)) add_child(arena) _grotto_runes = _glow(GROTTO_COLOR, 1.0) var runes := MeshInstance3D.new() var ring := TorusMesh.new() ring.inner_radius = 4.3 ring.outer_radius = 4.6 ring.rings = 64 runes.mesh = ring runes.scale = Vector3(1, .12, 1) runes.position = Vector3(center.x, y + .09, center.y) runes.material_override = _grotto_runes runes.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(runes) var cave := MeshInstance3D.new() cave.name = "BossGrotto" cave.mesh = _cave_mesh() cave.scale = Vector3.ONE * 1.05 cave.position = Vector3(center.x, y - .15, center.y) - Vector3(LuneMap.AXIS.x, 0, LuneMap.AXIS.y) * 1.2 _dress(cave) add_child(cave) var pillars: Array[Transform3D] = [] var caps: Array[Transform3D] = [] # Pillars around the back half; the front towards Mid stays open for stairs and spring. var back: float = atan2(-LuneMap.AXIS.y, -LuneMap.AXIS.x) for index: int in range(11): var angle: float = back + deg_to_rad(-120.0 + 24.0 * index) var at: Vector2 = center + Vector2(cos(angle), sin(angle)) * (LuneTerrain.ARENA_RADIUS - .15) pillars.append(Transform3D(Basis.IDENTITY, Vector3(at.x, y + 1.6, at.y))) caps.append(Transform3D(Basis(Vector3.UP, angle), Vector3(at.x, y + 3.45, at.y))) var pillar := CylinderMesh.new() pillar.top_radius = .36 pillar.bottom_radius = .46 pillar.height = 3.2 pillar.radial_segments = 10 _multi(pillar, surface("ENV_limestone_grotto", Color(.46, .43, .47)), pillars, "GrottoPillars") var cap := SphereMesh.new() cap.radius = .34 cap.height = .9 cap.radial_segments = 6 cap.rings = 2 _multi(cap, _grotto_runes, caps, "GrottoCrystals", false) # Cliffs behind the arena give the grotto its silhouette from the game camera; any # piece that would reach into a lane corridor is left out. var cliffs: Array[Transform3D] = [] for step: int in range(-1, 2): var angle: float = back + step * deg_to_rad(38.0) var at: Vector2 = center + Vector2(cos(angle), sin(angle)) * 9.6 var inward: Vector2 = (center - at).normalized() if _clear_of_lanes(at, 5.2): cliffs.append(Transform3D(_yaw_basis(_facing(inward), Vector3(.62, 2.2 - absf(step) * .35, 1.1)), _ground3(at, -1.3))) _prop_multi("EdgeCliff", cliffs) var rocks: Array[Transform3D] = [] for spec: Vector3 in [Vector3(-9.0, 5.8, 2.2), Vector3(-10.8, 0.0, 2.6), Vector3(-9.0, -5.8, 2.2), Vector3(-4.5, 8.8, 1.6), Vector3(-4.5, -8.8, 1.6)]: var at: Vector2 = center + LuneMap.AXIS * spec.x + LuneMap.BLUE_SIDE * spec.y if _clear_of_lanes(at, spec.z * 1.4): rocks.append(Transform3D(_yaw_basis(spec.y * .4, Vector3.ONE * spec.z), _ground3(at, -.3))) _prop_multi("Rock_1", rocks) # 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") grotto_light = OmniLight3D.new() grotto_light.position = Vector3(center.x, y + 2.6, center.y) - Vector3(LuneMap.AXIS.x, 0, LuneMap.AXIS.y) * 3.0 grotto_light.light_color = GROTTO_COLOR grotto_light.light_energy = 2.4 grotto_light.omni_range = 12.0 add_child(grotto_light) ## 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 ## The M2 grotto model without its floor disc and loose boulders (the G1 arena has its ## own floor): only the rock arc, the dark cave and the crystals remain. func _cave_mesh() -> ArrayMesh: var scene: Node3D = (load(GROTTO_SCENE) as PackedScene).instantiate() var source: Mesh = (scene.get_node("BossGrotto") as MeshInstance3D).mesh var mesh := ArrayMesh.new() for index: int in range(source.get_surface_count()): var material: Material = source.surface_get_material(index) var key: String = material.resource_name if material != null else "" if key in ["ENV_soil", "ENV_limestone"]: continue mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, source.surface_get_arrays(index)) mesh.surface_set_material(mesh.get_surface_count() - 1, material) scene.free() return mesh ## 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 var color: Color = TEAM_COLORS[claim_team] if claim_team >= 0 else GROTTO_COLOR var lit: bool = available or claim_team >= 0 grotto_light.light_color = color grotto_light.light_energy = 2.4 if lit else .5 _grotto_runes.emission = color _grotto_runes.albedo_color = color _grotto_runes.emission_energy_multiplier = 1.2 if lit else .15 # ---------------------------------------------------------------- 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 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 index: int in range(banner.mesh.get_surface_count()): var source: Material = banner.mesh.surface_get_material(index) if source != null and source.resource_name == "PR_Banner": banner.set_surface_override_material(index, 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 index: int in range(node.mesh.get_surface_count()): var source: Material = node.mesh.surface_get_material(index) if source != null and source.resource_name == "TW_Crystal": node.set_surface_override_material(index, 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