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()