This commit is contained in:
team3
2026-09-16 13:21:45 +02:00
parent 8a188c5d5c
commit 49e8f2d95e
27 changed files with 1772 additions and 265 deletions

View File

@@ -9,14 +9,11 @@ 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 rig: CameraRig
var camera: Camera3D
var ui_layer: CanvasLayer
var ui_root: Control
@@ -30,14 +27,13 @@ var shop: ShopPanel
var combat_speed := 1.0
var started := false
var paused := false
var spectating := false ## nach dem Ausscheiden zuschauen
var eliminated_shown := 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
var settings := Settings.new()
var overlays: Overlays
var _prev_combos: Array = [] ## für die Meldung neuer Kombinationen
var _prev_tiers := {} ## Element -> Trait-Stufe
# Auswahl und Ziehen
var sel_slot := {}
@@ -51,7 +47,6 @@ var picker_title: Label
var picker_sub: Label
var picker_cb: Callable
var overlay: Control ## Start-, Pause- oder Endbildschirm
func _ready() -> void:
@@ -72,17 +67,42 @@ func _ready() -> void:
_build_panels()
_build_picker()
var tip := TooltipView.new()
tip.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tip.hud = hud
ui_layer.add_child(tip)
shop.card_hovered.connect(_on_card_hovered)
shop.card_left.connect(_on_card_left)
shop.undo_pressed.connect(_on_undo)
game.start()
settings.load_from_disk()
sfx.set_volume(settings.volume)
overlays = Overlays.new()
overlays.volume = settings.volume
overlays.difficulty = settings.difficulty
overlays.start_pressed.connect(_start_game)
overlays.difficulty_changed.connect(_on_difficulty)
overlays.volume_changed.connect(func(v):
settings.volume = v
sfx.set_volume(v)
settings.save_to_disk())
overlays.resume_pressed.connect(_toggle_pause)
overlays.restart_pressed.connect(_restart)
overlays.spectate_pressed.connect(_spectate)
ui_layer.add_child(overlays)
game.start(0, settings.difficulty)
board.game = game
_refresh()
_show_start()
overlays.show_start(Game.has_save())
set_process(true)
func _process(delta: float) -> void:
if not started or paused:
return
if spectating and game.phase == Game.Phase.PLANNING and game.plan_left > 3.0:
game.plan_left = 3.0
if game.phase == Game.Phase.PLANNING:
if game.tick_planning(delta):
_cancel_drag()
@@ -99,6 +119,8 @@ func _process(delta: float) -> void:
if game.phase != before:
_after_combat()
_refresh()
if game.phase == Game.Phase.PLANNING and game.human.alive:
game.save()
func _after_combat() -> void:
@@ -106,6 +128,8 @@ func _after_combat() -> void:
1: sfx.play("sieg")
-1: sfx.play("niederlage")
_: sfx.play("runde")
if not game.human.alive and not eliminated_shown and game.phase != Game.Phase.GAMEOVER:
_show_eliminated()
# --- Eingabe -----------------------------------------------------------------
@@ -117,8 +141,7 @@ func _unhandled_input(event: InputEvent) -> void:
return
if event is InputEventMouseMotion:
if event.button_mask & MOUSE_BUTTON_MASK_MIDDLE:
cam_yaw += event.relative.x * 0.3
_apply_camera()
rig.rotate_by(event.relative.x * 0.3)
return
_on_mouse_move(event.position)
return
@@ -134,21 +157,24 @@ func _unhandled_input(event: InputEvent) -> void:
_on_right_click(event.position)
MOUSE_BUTTON_WHEEL_UP:
if event.pressed:
_zoom(-0.1)
rig.zoom_by(-0.1)
MOUSE_BUTTON_WHEEL_DOWN:
if event.pressed:
_zoom(0.1)
rig.zoom_by(0.1)
return
if not (event is InputEventKey and event.pressed and not event.echo):
return
match event.keycode:
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
_on_buy(event.keycode - KEY_1, false)
KEY_Z: _on_undo()
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_Q: rig.rotate_by(-YAW_STEP)
KEY_E: rig.rotate_by(YAW_STEP)
KEY_ESCAPE:
_cancel_drag()
sel_slot = {}
@@ -160,6 +186,43 @@ func _on_mouse_move(pos: Vector2) -> void:
board.hover_slot = slot
if drag_avatar != null:
board.drag_world = _drag_point(pos, slot)
hud.tooltip_avatar = null
return
_hover_unit(pos)
## Einheit unter der Maus, egal ob eigene, Gegner im Kampf oder angesehenes Brett.
func _hover_unit(pos: Vector2) -> void:
var best: Avatar = null
var best_d := 34.0
for d in board.draw_units:
var world: Vector3 = d["pos"]
if camera.is_position_behind(world):
continue
var sp := camera.unproject_position(world + Vector3(0.0, 0.6, 0.0))
var dist := sp.distance_to(pos)
if dist < best_d:
best_d = dist
best = d["avatar"]
hud.tooltip_avatar = best
hud.tooltip_pos = pos
hud.tooltip_note = ""
func _on_card_hovered(index: int, screen_pos: Vector2) -> void:
var p := game.human
if index < 0 or index >= p.shop.size() or p.shop[index].sold:
return
var e: ShopEntry = p.shop[index]
var element := e.element if e.element >= 0 else (p.last_element if p.last_element >= 0 else Data.Element.FEUER)
hud.tooltip_avatar = e.to_avatar(element)
hud.tooltip_note = "Werte als %s, Element wählst du beim Kauf" % Data.ELEMENT_NAMES[element] \
if e.element < 0 else "%d Gold" % e.price()
hud.tooltip_pos = screen_pos - Vector2(0.0, 120.0)
func _on_card_left() -> void:
hud.tooltip_avatar = null
func _begin_drag(pos: Vector2) -> void:
@@ -220,6 +283,7 @@ func _on_right_click(pos: Vector2) -> void:
if slot["kind"] == "bench":
if game.human.sell_bench(slot["index"], game.pool):
sfx.play("verkauf")
shop.show_undo()
elif game.human.unplace(slot["hex"]):
sfx.play("setzen")
sel_slot = {}
@@ -262,87 +326,24 @@ func _build_world() -> void:
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)
rig = CameraRig.new()
add_child(rig)
camera = rig.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.
## Das Spielfeld muss in die freie Bildmitte passen: Panels liegen links, rechts, unten.
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)
rig.fit(board.corner_points(), 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))
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)
var hit = rig.ground_point(screen_pos, BoardView3D.TILE_H * 0.5)
return null if hit == null else board.to_local(hit)
func _drag_point(screen_pos: Vector2, fallback: Dictionary) -> Vector3:
@@ -452,6 +453,8 @@ func _on_ready() -> void:
game.skip_combat()
_after_combat()
_refresh()
if game.phase == Game.Phase.PLANNING and game.human.alive:
game.save()
func _on_speed() -> void:
@@ -526,6 +529,27 @@ func _on_buy(index: int, quick: bool) -> void:
_refresh())
func _on_undo() -> void:
if game.phase != Game.Phase.PLANNING:
return
if game.human.undo_sell(game.pool):
sfx.play("kauf")
shop.hide_undo()
else:
shop.hint("Rückgängig geht nicht mehr.")
_refresh()
func _on_difficulty(d: int) -> void:
settings.difficulty = d
settings.save_to_disk()
game = Game.new()
game.start(0, d)
board.game = game
_refresh()
overlays.show_start(Game.has_save())
func _on_upgrade() -> void:
var p := game.human
var hex := selected_board_hex()
@@ -535,32 +559,12 @@ func _on_upgrade() -> void:
var options := p.available_new_elements(hex)
var previews := []
for e in options:
previews.append("+ " + _element_effect(e) + " (halbe Stärke)")
previews.append("+ " + Data.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"
"Fähigkeit wird zu: " + Data.star_effect(a.role, a.star + 1))
func _on_scout(p: PlayerState) -> void:
@@ -582,69 +586,92 @@ func _refresh() -> void:
players.refresh(game, scout)
info.refresh(game, _avatar_at(sel_slot), selected_board_hex())
shop.refresh(game)
shop.lbl_tip.text = _tutorial_tip()
board.selected_slot = sel_slot
_refresh_range()
_announce_synergies()
if game.phase == Game.Phase.GAMEOVER:
_show_gameover()
Game.delete_save()
if overlays.kind() != "gameover":
overlays.show_gameover(game)
# --- 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
## Reichweite der gewählten Board-Einheit auf den Feldern zeigen.
func _refresh_range() -> void:
var hex := selected_board_hex()
var a: Avatar = _avatar_at(sel_slot)
if hex.x < 0 or a == null or game.phase != Game.Phase.PLANNING:
board.range_radius = 0
return
board.range_center = BoardView3D.from_board(hex)
if a.role == Data.Role.HEILER:
board.range_radius = Data.HEAL_RANGE
board.range_color = Color(0.45, 0.95, 0.55)
else:
board.range_radius = int(a.base_stats()["range"])
board.range_color = Color(1.0, 0.65, 0.3)
func _close_overlay() -> void:
if overlay != null:
overlay.queue_free()
overlay = null
## Meldet neu aktive Kombinationen und Trait-Stufen.
func _announce_synergies() -> void:
var p := game.human
var combos := []
for c in p.active_combos():
combos.append(c["name"])
var tiers := {}
var counts := p.trait_counts()
for e in counts:
tiers[e] = Data.trait_tier(e, counts[e])
if game.phase == Game.Phase.PLANNING:
var msgs := []
for name in combos:
if not _prev_combos.has(name):
msgs.append("Kombination aktiv: %s" % name)
for e in tiers:
if tiers[e] > _prev_tiers.get(e, 0) and tiers[e] > 0:
msgs.append("%s-Trait Stufe %d: %s" % [Data.ELEMENT_NAMES[e], tiers[e],
Data.ELEMENT_TRAITS[e]["desc"][tiers[e] - 1]])
if not msgs.is_empty():
shop.hint(" · ".join(msgs))
sfx.play("level" if msgs[0].begins_with("Kombination") else "aufwerten")
_prev_combos = combos
_prev_tiers = tiers
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
## Erstrunden-Hilfe: sagt in den ersten zwei Runden, was als Nächstes dran ist.
func _tutorial_tip() -> String:
if game.round_no > 2 or game.phase != Game.Phase.PLANNING or not game.human.alive:
return ""
var p := game.human
var on_bench := 0
for a in p.bench:
if a != null:
on_bench += 1
if p.board.is_empty() and on_bench == 0:
return "Tipp: Kauf unten einen neutralen Avatar und wähl sein Element."
if p.board.is_empty():
return "Tipp: Zieh den Avatar von der Bank auf dein Feld (untere Hälfte)."
if p.board.size() < p.team_slots() and p.gold >= Data.PRICE_NONE:
return "Tipp: Du hast noch %d freie Kaderplätze. Tanks nach vorn." % (p.team_slots() - p.board.size())
if game.round_no == 1:
return "Tipp: Leertaste startet den Kampf. Gleiche Elemente ab 2 geben einen Trait."
return "Tipp: 3 gleiche Elemente machen einen 2-Star. Futter liegt auf der Bank."
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)
# --- Start, Pause, Ausscheiden, Neustart ------------------------------------
func _start_game() -> void:
func _start_game(continue_save: bool) -> void:
if continue_save:
var loaded := Game.load_from()
if loaded != null:
game = loaded
board.game = game
board.scout_player = null
scout = null
_refresh()
started = true
paused = false
_close_overlay()
overlays.close()
sfx.play("runde")
@@ -655,39 +682,42 @@ func _toggle_pause() -> void:
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)
game.save()
overlays.show_pause()
else:
_close_overlay()
overlays.close()
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)
func _show_eliminated() -> void:
eliminated_shown = true
_cancel_drag()
picker.hide()
Game.delete_save()
overlays.show_eliminated(game.human.placement, game.round_no)
func _spectate() -> void:
spectating = true
overlays.close()
var best := game.ranking()
if not best.is_empty() and best[0].alive:
_on_scout(best[0])
func _restart() -> void:
overlays.close()
Game.delete_save()
game = Game.new()
game.start(0, settings.difficulty)
board.game = game
board.scout_player = null
scout = null
spectating = false
eliminated_shown = false
_prev_combos = []
_prev_tiers = {}
_cancel_drag()
sel_slot = {}
started = true
paused = false
_refresh()