saumwacht-godot/scripts/main.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

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

642 lines
23 KiB
GDScript

extends Node3D
## Thin input/presentation adapter. All economic actions enter the same command API.
@onready var world: Node3D = $World
@onready var camera: Camera3D = $Camera3D
@onready var battle: Node3D = $BattleController
@onready var hud: Control = $CanvasLayer/HUD
var selected_uid: int = -1
var build_type: String = ""
var move_uid: int = -1
var viewed_team: int = 0
var _formation_dirty: bool = false
var _worker_roots: Array[Node3D] = []
var _worker_counts: Array[int] = [-1, -1]
var _hover: MeshInstance3D
var _range: MeshInstance3D
var _focus: Vector3 = Vector3(-9.0, 0.0, 0.0)
var _camera_yaw: float = .20
var _manual_camera: bool = false
var _dragging_camera: bool = false
var _readout_left: float = 0.0
var _ghost: Node3D
var _base_view: bool = false
var _core_roots: Array[Node3D] = []
var _core_texts: Array[Label3D] = []
var _base_texts: Array[Label3D] = []
var _visual_time: float = 0.0
const StoneFloor = preload("res://scripts/presentation/stone_floor.gdshader")
func _ready() -> void:
DisplayServer.window_set_title("Saumwacht · Die grüne Kluft")
get_tree().auto_accept_quit = false
get_window().close_requested.connect(Soundscape.quit_game)
_make_world()
_place_camera()
WaveManager.attach_battle(battle)
EconomyManager.changed.connect(_on_economy_changed)
GameState.phase_changed.connect(_on_phase_changed)
hud.command_requested.connect(_on_command)
hud.build_selected.connect(_select_build)
hud.move_requested.connect(_begin_move)
hud.focus_requested.connect(_switch_front)
hud.base_requested.connect(_show_base)
hud.new_match_requested.connect(_start_match)
hud.menu_requested.connect(_return_menu)
hud.starter_requested.connect(_starter)
hud.cancel_requested.connect(_clear_selection)
hud.ready_requested.connect(WaveManager.ready_for_battle)
# World and controls are already visible behind the opening panel.
battle.show_formation([[], []])
if "--ringborke" in OS.get_cmdline_user_args():
hud.model_inspector.call_deferred("show_model","w04")
if "--smoke-test" in OS.get_cmdline_user_args():
var smoke: Node = load("res://scripts/qa/export_smoke.gd").new()
add_child(smoke)
smoke.call_deferred("run", self)
elif "--faction-test" in OS.get_cmdline_user_args():
var test: Node = load("res://scripts/qa/faction_test.gd").new()
add_child(test)
test.call_deferred("run", self)
elif "--expedition-test" in OS.get_cmdline_user_args():
var acceptance: Node = load("res://scripts/qa/expedition_test.gd").new()
add_child(acceptance)
acceptance.call_deferred("run",self)
func _start_match() -> void:
_clear_selection()
viewed_team = 0
Soundscape.viewed_team = 0
_reset_camera()
_place_camera()
WaveManager.start_match()
hud.notify("Setze die Startformation für %d Gold oder wähle einzelne Wächter. Du bestimmst, wann der Kampf beginnt." % DataCatalog.starter_cost())
func _on_phase_changed(_phase: int) -> void:
_clear_ghost()
_base_view = false
build_type = ""
move_uid = -1
hud.build_type = ""
_hover.visible = false
_range.visible = false
if _phase == GameState.Phase.BUILD:
_focus.z = 0.0
_manual_camera = false
camera.size = 27.0
_place_camera()
elif _phase == GameState.Phase.COMBAT:
camera.size = 23.0
if _phase == GameState.Phase.MENU:
hud.viewed_team = 0
func _on_economy_changed(_team: int) -> void:
_formation_dirty = true
func _process(delta: float) -> void:
_visual_time += delta
_animate_base(delta)
_follow_combat(delta)
for unit: CombatUnit in battle.units:
if unit.alive:
unit.face_health_bars(camera.global_basis)
_readout_left -= delta
if _readout_left <= 0.0:
_readout_left = .10
hud.update_battle_readout(battle.units, _manual_camera or not Preferences.follow_camera)
if _formation_dirty and GameState.phase == GameState.Phase.BUILD:
_formation_dirty = false
battle.show_formation([
EconomyManager.get_team(0).roster, EconomyManager.get_team(1).roster
])
_update_selection_visuals()
_refresh_workers()
_update_hover()
func _input(event: InputEvent) -> void:
if hud.model_inspector.visible:
if event is InputEventKey and event.pressed and event.keycode in [KEY_ESCAPE,KEY_V]:
hud.model_inspector.hide_model()
get_viewport().set_input_as_handled()
elif event is InputEventKey and event.keycode == KEY_SPACE:
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_V and not hud.settings_panel.visible:
hud.inspect_selected_model()
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_F1 or (event.keycode == KEY_ESCAPE and hud.settings_panel.visible):
hud.toggle_settings()
get_viewport().set_input_as_handled()
return
if event.keycode in [KEY_M, KEY_F11]:
if event.keycode == KEY_M:
Preferences.muted = not Preferences.muted
else:
Preferences.fullscreen = not Preferences.fullscreen
Preferences.commit()
hud.sync_settings()
get_viewport().set_input_as_handled()
return
if hud.settings_panel.visible:
return
# The title menu, including its fade after "Partie beginnen", keeps game keys out of play.
if GameState.phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER] or hud.main_menu.visible:
return
if event is InputEventKey and event.pressed and not event.echo:
var handled := true
match event.keycode:
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
_select_build(DataCatalog.base_ids()[event.keycode - KEY_1])
KEY_SPACE:
hud.request_wave_start()
KEY_P:
hud.toggle_pause()
KEY_C:
_show_base()
KEY_TAB:
_switch_front()
KEY_ESCAPE:
_clear_selection()
KEY_Q, KEY_E:
_camera_yaw += .35 * (-1 if event.keycode == KEY_Q else 1)
_place_camera()
KEY_R:
_reset_camera()
_:
handled = false
# Space/Tab must keep their game meaning even after a button has focus.
if handled:
get_viewport().set_input_as_handled()
func _unhandled_input(event: InputEvent) -> void:
if hud.settings_panel.visible or hud.model_inspector.visible or hud.main_menu.visible or GameState.phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER]:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE:
_dragging_camera = event.pressed
if event is InputEventMouseMotion and _dragging_camera:
if not event.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
_dragging_camera = false
return
_manual_camera = true
var right: Vector3 = camera.global_basis.x
var forward: Vector3 = camera.global_basis.z
right.y = 0
forward.y = 0
_focus += (-right.normalized() * event.relative.x - forward.normalized() * event.relative.y) * camera.size / 600.0
_focus.x = clampf(_focus.x, -20, 20)
_focus.z = clampf(_focus.z, -14, 18)
_place_camera()
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_RIGHT:
_clear_selection()
elif event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera.size = maxf(14.0, camera.size - 1.5)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera.size = minf(42.0, camera.size + 1.5)
elif event.button_index == MOUSE_BUTTON_LEFT:
_click_field(event.position)
func _mouse_cell(mouse: Vector2 = Vector2(-1, -1)) -> Vector2i:
if mouse == Vector2(-1, -1):
mouse = get_viewport().get_mouse_position()
var origin: Vector3 = camera.project_ray_origin(mouse)
var direction: Vector3 = camera.project_ray_normal(mouse)
var hit = Plane(Vector3.UP, 0.0).intersects_ray(origin, direction)
if hit == null:
return Vector2i(-1, -1)
var center: float = -9.0 if viewed_team == 0 else 9.0
var cell := Vector2i(int(floor((hit.x - center + 6.0) / 2.0)),
int(floor((hit.z + 8.0) / 2.0)))
if cell.x < 0 or cell.x >= 6 or cell.y < 0 or cell.y >= 8:
return Vector2i(-1, -1)
return cell
func _click_field(mouse: Vector2) -> void:
if GameState.phase != GameState.Phase.BUILD or viewed_team != 0:
return
if build_type.is_empty() and move_uid < 0:
# Hit the visible figure as well as its floor cell; tall models hide their feet.
var closest: float = 38.0
var picked: int = -1
for unit: CombatUnit in battle.units:
if unit.team_id != 0 or unit.is_attacker:
continue
var screen: Vector2 = camera.unproject_position(unit.position + Vector3.UP * unit.visual_height * .45)
var distance: float = screen.distance_to(mouse)
if distance < closest:
closest = distance
picked = unit.uid
if picked >= 0:
selected_uid = picked
hud.selected_uid = picked
_update_selection_visuals()
return
var cell := _mouse_cell(mouse)
if cell.x < 0:
return
if move_uid >= 0:
_on_command({"type": "move", "uid": move_uid, "cell": cell})
move_uid = -1
elif not build_type.is_empty():
var placed: bool = _on_command({"type": "build", "type_id": build_type, "cell": cell})
if placed and not Input.is_key_pressed(KEY_SHIFT):
_clear_selection()
for entry: Dictionary in EconomyManager.get_team(0).roster:
if entry.cell == cell:
selected_uid = entry.uid
hud.selected_uid = entry.uid
else:
selected_uid = -1
for entry in EconomyManager.get_team(0).roster:
if entry.cell == cell:
selected_uid = int(entry.uid)
break
hud.selected_uid = selected_uid
_update_selection_visuals()
func _select_build(type_id: String) -> void:
if GameState.phase != GameState.Phase.BUILD:
return
if viewed_team != 0:
_switch_front()
if _base_view:
_reset_camera()
_clear_ghost()
_ghost = AssetLibrary.instantiate_model(type_id)
if _ghost != null:
world.add_child(_ghost)
_ghost.rotation.y = PI
var ghost_material := _material(Color(.48, .95, .72, .38), true)
_ghost.material_override = ghost_material
_ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
build_type = type_id
move_uid = -1
selected_uid = -1
hud.selected_uid = -1
hud.build_type = type_id
hud.notify("%s platzieren · Rechtsklick bricht ab" % DataCatalog.get_unit(type_id).display_name)
_update_selection_visuals()
func _begin_move() -> void:
if selected_uid < 0 or GameState.phase != GameState.Phase.BUILD:
return
move_uid = selected_uid
build_type = ""
hud.build_type = ""
hud.notify("Neuen freien Platz wählen. Versetzen ist kostenlos.")
func _on_command(command: Dictionary) -> bool:
var result: Dictionary = EconomyManager.apply_command(0, command)
if result.ok:
Soundscape.play("place")
hud.notify(str(result.get("message", "Auftrag ausgeführt.")))
else:
Soundscape.play("error")
hud.notify(str(result.message), true)
return bool(result.ok)
func _starter() -> void:
if GameState.phase != GameState.Phase.BUILD:
return
if not EconomyManager.get_team(0).roster.is_empty():
hud.notify("Die Startformation benötigt ein leeres Baufeld.", true)
return
if EconomyManager.get_team(0).gold < DataCatalog.starter_cost():
hud.notify("Für die komplette Startformation fehlen %d Gold." %
(DataCatalog.starter_cost() - int(EconomyManager.get_team(0).gold)), true)
return
_on_command({"type": "build", "type_id": DataCatalog.for_team("g03", 0), "cell": Vector2i(2, 2)})
_on_command({"type": "build", "type_id": DataCatalog.for_team("g01", 0), "cell": Vector2i(3, 2)})
_on_command({"type": "build", "type_id": DataCatalog.for_team("g05", 0), "cell": Vector2i(2, 4)})
hud.notify("Startformation aufgestellt · %d Gold bezahlt. Leertaste startet den Kampf." % DataCatalog.starter_cost())
func _clear_selection() -> void:
_clear_ghost()
selected_uid = -1
build_type = ""
move_uid = -1
hud.selected_uid = -1
hud.build_type = ""
_update_selection_visuals()
func _switch_front() -> void:
viewed_team = 1 - viewed_team
_focus.x = -9.0 if viewed_team == 0 else 9.0
_manual_camera = false
Soundscape.viewed_team = viewed_team
_place_camera()
hud.viewed_team = viewed_team
_clear_selection()
func _place_camera() -> void:
var offset: Vector3 = Vector3(0, 29, 28).rotated(Vector3.UP, _camera_yaw)
camera.position = _focus + offset
camera.look_at(_focus, Vector3.UP)
# Move the arena into the area left of the inspector and above the shop.
var shift: Vector3 = camera.global_basis.x * camera.size * .17 - camera.global_basis.y * camera.size * .015
camera.position += shift
func _reset_camera() -> void:
_base_view = false
_focus = Vector3(-9.0 if viewed_team == 0 else 9.0, 0, 0)
_camera_yaw = .20
_manual_camera = false
camera.size = 23.0 if GameState.phase == GameState.Phase.COMBAT else 29.0
_place_camera()
func _update_hover() -> void:
var cell := _mouse_cell()
var over_ui: bool = get_viewport().gui_get_hovered_control() != null
_hover.visible = GameState.phase == GameState.Phase.BUILD and viewed_team == 0 and cell.x >= 0 and not over_ui
if is_instance_valid(_ghost):
_ghost.visible = _hover.visible
if not _hover.visible:
_range.visible = false
return
_hover.position = Vector3(-14.0 + cell.x * 2, 0.035, -7.0 + cell.y * 2)
var occupied := false
for entry in EconomyManager.get_team(0).roster:
if entry.cell == cell:
occupied = true
if is_instance_valid(_ghost):
_ghost.position = _hover.position
_ghost.visible = not occupied
var mat: StandardMaterial3D = _hover.material_override
mat.albedo_color = Color(0.9, 0.36, 0.27, 0.6) if occupied else Color(0.5, 0.85, 0.74, 0.45)
if not build_type.is_empty():
var unit_data: Dictionary = DataCatalog.get_unit(build_type)
var mesh: CylinderMesh = _range.mesh
mesh.top_radius = unit_data.attack_range
mesh.bottom_radius = unit_data.attack_range
_range.position = _hover.position + Vector3(0, 0.02, 0)
_range.visible = true
else:
_range.visible = false
func _update_selection_visuals() -> void:
if not is_instance_valid(battle):
return
for unit in battle.units:
if is_instance_valid(unit) and unit.has_method("set_selected"):
unit.set_selected(unit.uid == selected_uid and not unit.is_attacker and unit.team_id == 0)
func _material(color: Color, unshaded: bool = false) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.88
if unshaded:
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
if color.a < 1.0:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return mat
func _box(size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var instance := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
instance.mesh = mesh
instance.material_override = _material(color)
instance.position = pos
world.add_child(instance)
return instance
func _make_world() -> void:
var sanctuary := preload("res://scripts/presentation/sanctuary.gd").new()
world.add_child(sanctuary)
var grid_materials: Array[ShaderMaterial] = []
for color: Color in [Color("5a6358"), Color("4e5850")]:
var material := ShaderMaterial.new()
material.shader = StoneFloor
material.set_shader_parameter("stone_color", color)
material.set_shader_parameter("limestone", preload("res://assets/materials/phase10/limestone-albedo.png"))
material.set_shader_parameter("build_tile", true)
grid_materials.append(material)
var environment := WorldEnvironment.new()
var settings := Environment.new()
preload("res://scripts/presentation/lighting.gd").configure(settings)
environment.environment = settings
world.add_child(environment)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-52, -38, 0)
sun.light_color = Color("#ffd4a0")
sun.light_energy = 1.85
sun.light_angular_distance = 1.2
sun.shadow_enabled = true
sun.directional_shadow_max_distance = 100.0
sun.directional_shadow_fade_start = .7
sun.shadow_blur = 2.2
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_split_1 = .08
sun.directional_shadow_split_2 = .22
sun.directional_shadow_split_3 = .45
sun.directional_shadow_pancake_size = 2.5
world.add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-32, 148, 0)
fill.light_color = Color("a8c4e8")
fill.light_energy = .48
fill.light_angular_distance = 2.0
world.add_child(fill)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-18, 55, 0)
rim.light_color = Color("c8e8d8")
rim.light_energy = .22
world.add_child(rim)
for team in range(2):
var center: float = -9.0 if team == 0 else 9.0
var accent := Color("#64c6b1") if team == 0 else Color("#e29074")
for row in range(8):
for column in range(6):
var color := Color("55545a") if (row + column) % 2 == 0 else Color("4b4b52")
var grid_tile := _box(Vector3(1.94, 0.025, 1.94), Vector3(center - 5 + column * 2, .014, -7 + row * 2), color)
grid_tile.material_override = grid_materials[(row + column) % 2]
_box(Vector3(12, .03, .10), Vector3(center, .045, -8.1), accent)
_box(Vector3(12, .03, .15), Vector3(center, .045, 16), accent)
var core: Node3D = AssetLibrary.instantiate_fighter("core_team%d" % team)
if core != null:
core.position = Vector3(center, 0, 18.2)
world.add_child(core)
_core_roots.append(core)
if core.has_method("trigger_activate"):
core.trigger_activate()
else:
_box(Vector3(2, 2.2, 2), Vector3(center, 1.1, 18.2), accent)
var workers := Node3D.new()
workers.name = "Workers_Team%d" % team
world.add_child(workers)
_worker_roots.append(workers)
var label := Label3D.new()
label.text = "DEIN SAUMKERN" if team == 0 else "RIVALENKERN"
label.font_size = 36
label.outline_size = 3
label.pixel_size = 0.006
label.position = Vector3(center, 3.3, 18.2)
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = accent
world.add_child(label)
_core_texts.append(label)
var core_light := OmniLight3D.new()
core_light.position = Vector3(center, 1.6, 18.2)
core_light.light_color = accent
core_light.light_energy = 2.4
core_light.light_volumetric_fog_energy = 1.8
core_light.omni_range = 7.5
core_light.omni_attenuation = 1.4
world.add_child(core_light)
var production := Label3D.new()
production.font_size = 30
production.outline_size = 3
production.pixel_size = .006
production.billboard = BaseMaterial3D.BILLBOARD_ENABLED
production.position = Vector3(center, 2.3, 12.5)
production.modulate = accent
world.add_child(production)
_base_texts.append(production)
# Route markers make front/back readable independently of camera rotation.
for z: float in [-10.0, 9.2]:
var direction := Label3D.new()
direction.text = "▼ HIER KOMMEN DIE GEGNER ▼" if z < 0 else "RÜCKRAUM · SCHÜTZEN & HEILER"
direction.position = Vector3(center, .08, z)
direction.rotation_degrees.x = -90
direction.font_size = 32
direction.outline_size = 3
direction.pixel_size = .006
direction.modulate = accent
world.add_child(direction)
_hover = _box(Vector3(1.9, 0.02, 1.9), Vector3.ZERO, Color(0.4, 0.9, 0.7, 0.4))
_hover.visible = false
_range = MeshInstance3D.new()
var range_mesh := CylinderMesh.new()
range_mesh.height = 0.012
range_mesh.radial_segments = 64
_range.mesh = range_mesh
_range.material_override = _material(Color(0.5, 0.9, 0.75, 0.10), true)
world.add_child(_range)
_range.visible = false
func _refresh_workers() -> void:
for team: int in range(_worker_roots.size()):
var count: int = int(EconomyManager.get_team(team).get("workers", 0))
if count == _worker_counts[team]:
continue
_worker_counts[team] = count
for child: Node in _worker_roots[team].get_children():
child.free()
for index: int in range(count):
var worker: Node3D = AssetLibrary.instantiate_fighter("worker")
if worker == null:
continue
_worker_roots[team].add_child(worker)
var center: float = -9.0 if team == 0 else 9.0
worker.position = Vector3(center - 4.2 + (index % 4) * 2.8, 0, 13.7 + floori(index / 4.0) * 2.2)
worker.set_meta("home", worker.position)
worker.rotation.y = 0.0
# Shared cut-mineral geometry marks each worker's gathering station.
if AssetLibrary._rigs.has("core_team%d" % team):
var ore := MeshInstance3D.new()
ore.mesh = AssetLibrary._rigs["core_team%d" % team].groups.Crystal.mesh
ore.scale = Vector3.ONE * .22
ore.position = Vector3(.0,.25,.65)
worker.add_child(ore)
func _follow_combat(delta: float) -> void:
if GameState.phase != GameState.Phase.COMBAT or GameState.paused or _manual_camera or _base_view or not Preferences.follow_camera:
return
var sum_z: float = 0.0
var count: int = 0
for unit: CombatUnit in battle.units:
if unit.alive and unit.team_id == viewed_team:
sum_z += unit.position.z
count += 1
if count > 0:
_focus.z = lerpf(_focus.z, clampf(sum_z / count, -7.0, 12.0), minf(1.0, delta * 2.5))
_place_camera()
func _clear_ghost() -> void:
if is_instance_valid(_ghost):
_ghost.free()
_ghost = null
func _show_base() -> void:
if GameState.phase in [GameState.Phase.MENU, GameState.Phase.GAME_OVER]:
return
if _base_view:
_reset_camera()
return
_clear_selection()
_base_view = true
_manual_camera = true
_focus = Vector3(-9.0 if viewed_team == 0 else 9.0, 0.0, 15.2)
_camera_yaw = .20
camera.size = 14.0
_place_camera()
hud.notify("Arbeiter sammeln je 10 Funken pro Welle. Schütze den Kristall · C zurück zur Front.")
func _animate_base(delta: float) -> void:
for team: int in range(_core_roots.size()):
var hp: int = int(GameState.core_hp[team])
if _core_roots[team].has_method("set_core_health"):
_core_roots[team].set_core_health(hp / 3000.0)
if _core_roots[team].has_method("pose"):
_core_roots[team].pose(_visual_time, false, 0, 0, 0, delta)
_core_texts[team].text = ("DEIN KRISTALL" if team == 0 else "RIVALENKRISTALL") + " · %d / 3000" % hp
_core_texts[team].modulate = Color("e9967c") if hp < 1000 else (Color("83cfbd") if team == 0 else Color("e9967c"))
var state: Dictionary = EconomyManager.get_team(team)
_base_texts[team].text = "%d ARBEITER · +%d FUNKEN / WELLE%s" % [state.workers, int(state.workers)*10, "\n%d IN AUSBILDUNG" % state.pending_workers if state.pending_workers else ""]
var time: float = _visual_time if not GameState.paused else 0.0
var step: float = 0.0 if GameState.paused else delta
for index: int in range(_worker_roots[team].get_child_count()):
var worker: Node3D = _worker_roots[team].get_child(index)
if worker.has_method("update_work"):
worker.update_work(step, index * .83)
if worker.has_method("consume_strike_contact") and worker.consume_strike_contact():
_flash_worker_ore(worker)
elif worker.has_method("pose"):
worker.pose(time + index * .7, false, fmod(time + index * .4, 2.4) * .12, 0, 0)
for child: Node in worker.get_children():
if child is MeshInstance3D:
child.scale = child.scale.lerp(Vector3.ONE * .22, minf(1.0, step * 6.0))
worker.position = worker.get_meta("home") + Vector3(0, sin(time*2+index)*.025, 0)
func _flash_worker_ore(worker: Node3D) -> void:
for child: Node in worker.get_children():
if child is MeshInstance3D:
child.scale = Vector3.ONE * .32
func _return_menu() -> void:
Soundscape.stop_combat()
_clear_selection()
viewed_team = 0
hud.viewed_team = 0
Soundscape.viewed_team = 0
GameState.reset_match()
EconomyManager.reset_match()
WaveManager.remaining = 0.0
battle.show_formation([[], []])
_reset_camera()