saumwacht-godot/scripts/luneraria/view/lune_world.gd
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

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

915 lines
40 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. 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