saumwacht-godot/scripts/luneraria/view/lune_world.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:09:48 +02:00

907 lines
39 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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