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>
642 lines
23 KiB
GDScript
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()
|