extends Node3D ## Spielszene: 3D-Arena, Kamera, Eingabe, Overlays. Die Panels sind eigene ## Klassen (TopBar, InfoColumn, PlayersPanel, ShopPanel) und melden sich ## über Signale. const TOP_H := 46.0 const LEFT_W := 290.0 ## links: Elemente, Einheit, Rückblick const RIGHT_W := 210.0 ## rechts: Spielerliste const BOTTOM_H := 152.0 const BG := Color(0.09, 0.10, 0.13) const CAM_FOV := 44.0 const CAM_PITCH := 52.0 ## Grad über der Waagerechten const ZOOM_MIN := 0.6 const ZOOM_MAX := 1.5 const YAW_STEP := 15.0 var game := Game.new() var board: BoardView3D var camera: Camera3D var ui_layer: CanvasLayer var ui_root: Control var hud: HudOverlay var sfx: Sfx var top: TopBar var info: InfoColumn var players: PlayersPanel var shop: ShopPanel var combat_speed := 1.0 var started := false var paused := false var scout: PlayerState = null ## Spieler, dessen Brett gerade angesehen wird # Kamera: Einpassung plus Nutzeranteil var _cam_center := Vector3.ZERO var _cam_dir := Vector3.ZERO var _cam_dist := 20.0 var cam_zoom := 1.0 var cam_yaw := 0.0 # Auswahl und Ziehen var sel_slot := {} var drag_src := {} var drag_avatar: Avatar = null # Element-Auswahl var picker: PopupPanel var picker_grid: GridContainer var picker_title: Label var picker_sub: Label var picker_cb: Callable var overlay: Control ## Start-, Pause- oder Endbildschirm func _ready() -> void: _build_world() ui_layer = CanvasLayer.new() add_child(ui_layer) hud = HudOverlay.new() hud.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) hud.board = board ui_layer.add_child(hud) sfx = Sfx.new() add_child(sfx) hud.sfx = sfx ui_root = Control.new() ui_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ui_root.mouse_filter = Control.MOUSE_FILTER_IGNORE ui_layer.add_child(ui_root) _build_panels() _build_picker() game.start() board.game = game _refresh() _show_start() set_process(true) func _process(delta: float) -> void: if not started or paused: return if game.phase == Game.Phase.PLANNING: if game.tick_planning(delta): _cancel_drag() sel_slot = {} picker.hide() sfx.play("runde") _refresh() else: top.refresh_timer(game) elif game.phase == Game.Phase.COMBAT: var before := game.phase game.update_combat(delta * combat_speed) top.refresh(game, combat_speed) if game.phase != before: _after_combat() _refresh() func _after_combat() -> void: match game.human_last_result: 1: sfx.play("sieg") -1: sfx.play("niederlage") _: sfx.play("runde") # --- Eingabe ----------------------------------------------------------------- func _unhandled_input(event: InputEvent) -> void: if not started or paused: if event is InputEventKey and event.pressed and event.keycode == KEY_P and started: _toggle_pause() return if event is InputEventMouseMotion: if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE: cam_yaw += event.relative.x * 0.3 _apply_camera() return _on_mouse_move(event.position) return if event is InputEventMouseButton: match event.button_index: MOUSE_BUTTON_LEFT: if event.pressed: _begin_drag(event.position) else: _end_drag(event.position) MOUSE_BUTTON_RIGHT: if event.pressed: _on_right_click(event.position) MOUSE_BUTTON_WHEEL_UP: if event.pressed: _zoom(-0.1) MOUSE_BUTTON_WHEEL_DOWN: if event.pressed: _zoom(0.1) return if not (event is InputEventKey and event.pressed and not event.echo): return match event.keycode: KEY_D: _on_reroll() KEY_X: _on_buy_xp() KEY_F: _on_lock() KEY_SPACE: _on_ready() KEY_P: _toggle_pause() KEY_Q: _rotate(-YAW_STEP) KEY_E: _rotate(YAW_STEP) KEY_ESCAPE: _cancel_drag() sel_slot = {} _refresh() func _on_mouse_move(pos: Vector2) -> void: var slot := _pick_slot(pos) board.hover_slot = slot if drag_avatar != null: board.drag_world = _drag_point(pos, slot) func _begin_drag(pos: Vector2) -> void: if game.phase != Game.Phase.PLANNING: return var slot := _pick_slot(pos) var a: Avatar = _avatar_at(slot) if a == null: sel_slot = {} _refresh() return drag_src = slot drag_avatar = a board.drag_active = true board.drag_avatar_id = a.id board.drag_world = _drag_point(pos, slot) sel_slot = slot _refresh() func _end_drag(pos: Vector2) -> void: if drag_avatar == null: return var dst := _pick_slot(pos) var src := drag_src _cancel_drag() if dst.is_empty() or BoardView3D._same_slot(src, dst): sel_slot = src _refresh() return if dst["kind"] == "board" and not board.is_own_half(BoardView3D.from_board(dst["hex"])): _refresh() return if not game.human.move_slot(src, dst): if dst["kind"] == "board": shop.hint("Kaderplätze voll (%d). XP kaufen für mehr." % game.human.team_slots()) sfx.play("fehler") else: sel_slot = dst sfx.play("setzen") _refresh() func _cancel_drag() -> void: drag_avatar = null drag_src = {} board.drag_active = false board.drag_avatar_id = -1 func _on_right_click(pos: Vector2) -> void: if game.phase != Game.Phase.PLANNING: return _cancel_drag() var slot := _pick_slot(pos) if slot.is_empty(): return if slot["kind"] == "bench": if game.human.sell_bench(slot["index"], game.pool): sfx.play("verkauf") elif game.human.unplace(slot["hex"]): sfx.play("setzen") sel_slot = {} _refresh() func _avatar_at(slot: Dictionary): if slot.is_empty(): return null if slot["kind"] == "bench": return game.human.bench[slot["index"]] return game.human.board.get(slot["hex"]) # --- Welt und Kamera --------------------------------------------------------- func _build_world() -> void: var we := WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_COLOR env.background_color = BG env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color(0.44, 0.48, 0.62) env.ambient_light_energy = 0.9 we.environment = env add_child(we) var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-58.0, -35.0, 0.0) sun.light_energy = 1.15 sun.shadow_enabled = true add_child(sun) var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-22.0, 140.0, 0.0) fill.light_energy = 0.4 fill.light_color = Color(0.72, 0.80, 1.0) add_child(fill) board = BoardView3D.new() add_child(board) camera = Camera3D.new() camera.fov = CAM_FOV camera.look_at_from_position(Vector3(0.0, 16.0, 13.0), Vector3.ZERO, Vector3.UP) add_child(camera) board.camera = camera get_viewport().size_changed.connect(_fit_camera) _fit_camera() ## Legt die Kamera so, dass das Spielfeld genau in die freie Bildmitte passt. func _fit_camera() -> void: var vp := Vector2(get_viewport().get_visible_rect().size) var target := Rect2(LEFT_W + 16.0, TOP_H + 16.0, vp.x - LEFT_W - RIGHT_W - 32.0, vp.y - TOP_H - BOTTOM_H - 32.0) if target.size.x <= 0.0 or target.size.y <= 0.0: return var pts := board.corner_points() var pitch := deg_to_rad(CAM_PITCH) var dir := Vector3(0.0, sin(pitch), cos(pitch)) var dist := 20.0 var center := Vector3.ZERO for i in 8: camera.look_at_from_position(center + dir * dist, center, Vector3.UP) var r := _project_rect(pts) if r.size.x <= 0.0 or r.size.y <= 0.0: return dist *= maxf(r.size.x / target.size.x, r.size.y / target.size.y) camera.look_at_from_position(center + dir * dist, center, Vector3.UP) r = _project_rect(pts) var err := r.get_center() - target.get_center() var o := camera.unproject_position(center) var kx := camera.unproject_position(center + Vector3(1.0, 0.0, 0.0)).x - o.x var kz := camera.unproject_position(center + Vector3(0.0, 0.0, 1.0)).y - o.y if absf(kx) < 0.001 or absf(kz) < 0.001: break center += Vector3(err.x / kx, 0.0, err.y / kz) _cam_center = center _cam_dir = dir _cam_dist = dist _apply_camera() ## Nutzeranteil: Zoom und Drehung um die Brettmitte. func _apply_camera() -> void: var dir := _cam_dir.rotated(Vector3.UP, deg_to_rad(cam_yaw)) camera.look_at_from_position(_cam_center + dir * _cam_dist * cam_zoom, _cam_center, Vector3.UP) func _zoom(delta: float) -> void: cam_zoom = clampf(cam_zoom + delta, ZOOM_MIN, ZOOM_MAX) _apply_camera() func _rotate(deg: float) -> void: cam_yaw = fmod(cam_yaw + deg, 360.0) _apply_camera() func _project_rect(points: Array) -> Rect2: var lo := Vector2(INF, INF) var hi := Vector2(-INF, -INF) for p in points: if camera.is_position_behind(p): return Rect2() var q := camera.unproject_position(p) lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y)) hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y)) return Rect2(lo, hi - lo) func _ground_point(screen_pos: Vector2): if camera == null: return null var from := camera.project_ray_origin(screen_pos) var dir := camera.project_ray_normal(screen_pos) if absf(dir.y) < 0.0001: return null var t := (BoardView3D.TILE_H * 0.5 - from.y) / dir.y if t < 0.0: return null return board.to_local(from + dir * t) func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3: var hit = _ground_point(screen_pos) if hit == null: return board.slot_world(fallback) return Vector3(hit.x, 0.0, hit.z) func _pick_slot(screen_pos: Vector2) -> Dictionary: var hit = _ground_point(screen_pos) if hit == null: return {} return board.slot_at_world(hit) # --- Panels ------------------------------------------------------------------ func _build_panels() -> void: var vp := Vector2(1600, 900) top = TopBar.new() top.build(vp.x, TOP_H) top.ready_pressed.connect(_on_ready) top.xp_pressed.connect(_on_buy_xp) top.speed_pressed.connect(_on_speed) top.pause_pressed.connect(_toggle_pause) ui_root.add_child(top) var col_h := vp.y - TOP_H - BOTTOM_H - 20.0 info = InfoColumn.new() info.build(8.0, TOP_H + 8.0, LEFT_W - 16.0, col_h) info.upgrade_pressed.connect(_on_upgrade) ui_root.add_child(info) players = PlayersPanel.new() players.build(vp.x - RIGHT_W + 8.0, TOP_H + 8.0, RIGHT_W - 16.0, col_h) players.scout_requested.connect(_on_scout) ui_root.add_child(players) shop = ShopPanel.new() shop.build(8.0, vp.y - BOTTOM_H, vp.x - 16.0, BOTTOM_H - 10.0) shop.buy_requested.connect(_on_buy) shop.reroll_pressed.connect(_on_reroll) shop.lock_pressed.connect(_on_lock) ui_root.add_child(shop) func _build_picker() -> void: picker = PopupPanel.new() picker.add_theme_stylebox_override("panel", UiKit.flat(Color(0.16, 0.17, 0.22))) var v := VBoxContainer.new() v.add_theme_constant_override("separation", 8) picker.add_child(v) picker_title = UiKit.label("Element wählen", 16) v.add_child(picker_title) picker_sub = UiKit.label("", 13, Color(0.75, 0.8, 0.95)) v.add_child(picker_sub) picker_grid = GridContainer.new() picker_grid.columns = 3 picker_grid.add_theme_constant_override("h_separation", 6) picker_grid.add_theme_constant_override("v_separation", 6) v.add_child(picker_grid) ui_root.add_child(picker) ## Element-Auswahl. Mit `previews` steht neben jedem Knopf, was sich ändert. func _open_picker(title: String, options: Array, cb: Callable, previews: Array = [], subtitle: String = "") -> void: picker_title.text = title picker_sub.text = subtitle picker_sub.visible = subtitle != "" for c in picker_grid.get_children(): c.queue_free() picker_cb = cb var counts := game.human.trait_counts() var with_preview := not previews.is_empty() picker_grid.columns = 2 if with_preview else 3 for i in options.size(): var e: int = options[i] var col: Color = Data.ELEMENT_COLORS[e] var b := UiKit.button("%s\n(%d im Team)" % [Data.ELEMENT_NAMES[e], counts.get(e, 0)], col, UiKit.ink(col)) b.custom_minimum_size = Vector2(120, 52 if with_preview else 46) b.pressed.connect(func(): picker.hide() picker_cb.call(e)) picker_grid.add_child(b) if with_preview: var l := UiKit.label(previews[i], 12, Color(0.85, 0.88, 0.95)) l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER picker_grid.add_child(l) picker.reset_size() picker.popup_centered() # --- Aktionen ---------------------------------------------------------------- func _on_ready() -> void: if game.phase == Game.Phase.PLANNING: _cancel_drag() sel_slot = {} picker.hide() game.start_combat() sfx.play("runde") _refresh() elif game.phase == Game.Phase.COMBAT: game.skip_combat() _after_combat() _refresh() func _on_speed() -> void: combat_speed = 1.0 if combat_speed >= 4.0 else combat_speed * 2.0 top.refresh(game, combat_speed) func _on_buy_xp() -> void: if game.phase != Game.Phase.PLANNING: return var before := game.human.level if game.human.buy_xp(): sfx.play("level" if game.human.level > before else "kauf") else: shop.hint("Nicht genug Gold für XP." if game.human.level < Data.MAX_LEVEL else "Maximales Level.") _refresh() func _on_reroll() -> void: if game.phase != Game.Phase.PLANNING: return if game.human.reroll(game.pool, game.rand): sfx.play("wuerfeln") _refresh() func _on_lock() -> void: if game.phase != Game.Phase.PLANNING: return game.human.shop_locked = not game.human.shop_locked sfx.play("setzen") _refresh() ## Kauf. `quick` (Rechtsklick) nimmt bei Neutral die zuletzt gewählte Spezialisierung. func _on_buy(index: int, quick: bool) -> void: if game.phase != Game.Phase.PLANNING: return var p := game.human if index < 0 or index >= p.shop.size(): return var entry: ShopEntry = p.shop[index] if entry.sold: return if p.free_bench_slot() < 0: shop.hint("Bank ist voll. Erst etwas verkaufen.") return if p.gold < entry.price(): return var direct := entry.element if direct < 0 and quick and p.last_element >= 0 and game.pool.element_left[p.last_element] > 0: direct = p.last_element if direct >= 0: if p.buy(index, game.pool, direct): sfx.play("kauf") else: shop.hint("Kauf nicht möglich (Vorrat leer).") _refresh() return var free: Array[int] = [] for e in Data.ELEMENT_COUNT: if game.pool.element_left[e] > 0: free.append(e) if free.is_empty(): shop.hint("Kein Element mehr im Vorrat.") return _open_picker("Spezialisierung wählen", free, func(e): if game.human.buy(index, game.pool, e): sfx.play("kauf") else: shop.hint("Kauf nicht möglich (Vorrat leer).") _refresh()) func _on_upgrade() -> void: var p := game.human var hex := selected_board_hex() if hex.x < 0 or not p.can_upgrade(hex): return var a: Avatar = p.board[hex] var options := p.available_new_elements(hex) var previews := [] for e in options: previews.append("+ " + _element_effect(e) + " (halbe Stärke)") _open_picker("Zweite/dritte Spezialisierung", options, func(e): if game.human.upgrade(hex, e, game.pool): sfx.play("aufwerten") _refresh(), previews, "Fähigkeit wird zu: " + _star_effect(a.role, a.star + 1)) func _star_effect(role: int, star: int) -> String: match role: Data.Role.TANK: return "Schild und spottet 3 Gegner" if star == 2 else "Schild, Spott und 25% Reflexion" Data.Role.DPS: return "Ziel plus Splash auf Nachbarn" if star == 2 else "Ziel plus Fläche im Radius 2" _: return "heilt 3 Verbündete" if star == 2 else "heilt alle und schildet sie" func _element_effect(element: int) -> String: match element: Data.Element.FEUER: return "Brand" Data.Element.WASSER: return "Mana und stärkere Heilung" Data.Element.WIND: return "Tempo-Schub" Data.Element.BLITZ: return "Kettenblitz" Data.Element.ERDE: return "Betäubung" _: return "Verlangsamung" func _on_scout(p: PlayerState) -> void: scout = null if (p == game.human or p == scout) else p board.scout_player = scout _refresh() func selected_board_hex() -> Vector2i: if sel_slot.is_empty() or sel_slot["kind"] != "board": return Vector2i(-1, -1) return sel_slot["hex"] # --- Anzeige ----------------------------------------------------------------- func _refresh() -> void: top.refresh(game, combat_speed) players.refresh(game, scout) info.refresh(game, _avatar_at(sel_slot), selected_board_hex()) shop.refresh(game) board.selected_slot = sel_slot if game.phase == Game.Phase.GAMEOVER: _show_gameover() # --- Overlays: Start, Pause, Ende -------------------------------------------- func _overlay_box(title: String) -> VBoxContainer: if overlay != null: overlay.queue_free() overlay = ColorRect.new() overlay.color = Color(0, 0, 0, 0.72) overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ui_root.add_child(overlay) var v := VBoxContainer.new() v.position = Vector2(600, 280) v.custom_minimum_size = Vector2(400, 0) v.add_theme_constant_override("separation", 12) overlay.add_child(v) v.add_child(UiKit.label(title, 32)) return v func _close_overlay() -> void: if overlay != null: overlay.queue_free() overlay = null func _volume_row() -> HBoxContainer: var h := HBoxContainer.new() h.add_theme_constant_override("separation", 10) h.add_child(UiKit.label("Lautstärke", 14)) var sl := HSlider.new() sl.min_value = 0.0 sl.max_value = 1.0 sl.step = 0.05 sl.value = 0.8 sl.custom_minimum_size = Vector2(220, 20) sl.focus_mode = Control.FOCUS_NONE sl.value_changed.connect(func(v): sfx.set_volume(v)) h.add_child(sl) return h func _show_start() -> void: var v := _overlay_box("Elements Autochess") v.add_child(UiKit.label("8 Spieler, 100 Leben, Elemente als Traits. Basis wie TFT.", 15, Color(0.8, 0.85, 0.95))) v.add_child(UiKit.label( "Ziehen: Bank ↔ Feld · Rechtsklick: verkaufen · D würfeln · F einfrieren · X Level\nQ/E drehen · Mausrad zoomen · P Pause · Leertaste Kampf", 13, Color(0.7, 0.72, 0.8))) v.add_child(_volume_row()) var b := UiKit.button("Spiel starten", Color(0.28, 0.5, 0.36)) b.custom_minimum_size = Vector2(200, 38) b.pressed.connect(_start_game) v.add_child(b) func _start_game() -> void: started = true paused = false _close_overlay() sfx.play("runde") func _toggle_pause() -> void: if not started or game.phase == Game.Phase.GAMEOVER: return paused = not paused if paused: _cancel_drag() picker.hide() var v := _overlay_box("Pause") v.add_child(_volume_row()) var b := UiKit.button("Weiter [P]", Color(0.28, 0.5, 0.36)) b.custom_minimum_size = Vector2(200, 36) b.pressed.connect(_toggle_pause) v.add_child(b) else: _close_overlay() func _show_gameover() -> void: if overlay != null and overlay.has_meta("gameover"): return var v := _overlay_box("Spiel vorbei") overlay.set_meta("gameover", true) var alive := game.alive_players() var win: String = alive[0].pname if not alive.is_empty() else "-" v.add_child(UiKit.label("Sieger: %s" % win, 20, Color(1.0, 0.85, 0.35))) var place := game.human.placement if game.human.placement > 0 else 1 v.add_child(UiKit.label("Dein Platz: %d von 8" % place, 18, Color(0.6, 0.95, 0.7) if place <= 4 else Color(0.95, 0.6, 0.6))) var b := UiKit.button("Neues Spiel", Color(0.28, 0.5, 0.36)) b.custom_minimum_size = Vector2(180, 36) b.pressed.connect(func(): _close_overlay() game = Game.new() game.start() board.game = game board.scout_player = null scout = null _cancel_drag() sel_slot = {} started = true paused = false _refresh()) v.add_child(b)