Files
2026-09-16 13:21:45 +02:00

280 lines
7.9 KiB
GDScript

class_name BotAI
extends RefCounted
## Einfache Bot-Strategie: bevorzugte Elemente sammeln, Board füllen, aufwerten.
var pref: Array[int] = []
var greed: float = 0.5 ## wie stark der Bot Gold hortet
var difficulty: int = Data.Difficulty.NORMAL
var opponent: PlayerState = null ## bekannter Gegner der Runde, für Konterwahl
func setup(rand: RandomNumberGenerator, p_difficulty: int = Data.Difficulty.NORMAL) -> void:
difficulty = p_difficulty
var all: Array[int] = []
for e in Data.ELEMENT_COUNT:
all.append(e)
# Bewusst mit dem Spiel-RNG mischen, nicht mit Array.shuffle(),
# damit ein Seed dieselbe Partie ergibt.
for i in range(all.size() - 1, 0, -1):
var j := rand.randi_range(0, i)
var tmp := all[i]
all[i] = all[j]
all[j] = tmp
pref = [all[0], all[1], all[2]]
var g: Array = Data.BOT_GREED[difficulty]
greed = rand.randf_range(g[0], g[1])
func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, opp: PlayerState = null) -> void:
opponent = opp
_do_upgrades(p, pool)
_do_buys(p, pool)
_do_placements(p)
_do_upgrades(p, pool)
var keep := int(lerpf(2.0, 18.0, greed))
# Wer kurz vor einem Upgrade steht, würfelt gezielt nach Futter.
# Leichte Bots jagen nicht, schwere immer.
var hunting := difficulty != Data.Difficulty.LEICHT and _closest_upgrade_gap(p) <= 3
var rolls := 3 if hunting else 1
if difficulty == Data.Difficulty.SCHWER:
rolls += 1
for i in rolls:
if p.gold > keep + Data.REROLL_COST and (hunting or rand.randf() < 0.6):
p.reroll(pool, rand)
_do_buys(p, pool)
_do_placements(p)
_do_upgrades(p, pool)
# Level kaufen, wenn das Brett voll ist und Nachwuchs auf der Bank wartet.
while p.gold > keep + Data.XP_BUY_COST and not p.can_place() \
and _pick_bench_for_board(p) >= 0 and p.buy_xp():
_do_placements(p)
_free_bench(p, pool)
## Stellt das Brett gegen den bekannten Gegner um: Tanks vor seine Schützen,
## eigene Schützen weg von seinen Tanks. Ohne Gegner gilt die Standardaufstellung.
func arrange(p: PlayerState, opp: PlayerState) -> void:
if p.board.is_empty():
return
var units: Array = p.board_units()
p.board.clear()
var threat := [] # Spalten (gespiegelt), in denen gegnerische Schützen stehen
var enemy_tanks := [] # Spalten (gespiegelt) gegnerischer Tanks
if opp != null:
for hex in opp.board:
var col: int = Data.BOARD_COLS - 1 - hex.x
if opp.board[hex].role == Data.Role.TANK:
enemy_tanks.append(col)
else:
threat.append(col)
var center := [3, 2, 4, 1, 5, 0, 6]
var tank_cols := threat.duplicate()
for c in center:
if not tank_cols.has(c):
tank_cols.append(c)
# Schützen möglichst weit weg von gegnerischen Tanks.
var far_cols := center.duplicate()
far_cols.sort_custom(func(a, b):
return _min_dist(a, enemy_tanks) > _min_dist(b, enemy_tanks))
units.sort_custom(func(a, b): return a.role == Data.Role.TANK and b.role != Data.Role.TANK)
for u in units:
# Heiler direkt hinter die Tanks, sonst reicht ihre Heilung (2 Felder) nicht.
var rows: Array
match u.role:
Data.Role.TANK: rows = [0, 1, 2, 3]
Data.Role.HEILER: rows = [1, 2, 0, 3]
_: rows = [3, 2, 1, 0]
var cols: Array = tank_cols if u.role != Data.Role.DPS else far_cols
var placed := false
for r in rows:
for c in cols:
var h := Vector2i(c, r)
if not p.board.has(h):
p.board[h] = u
placed = true
break
if placed:
break
func _min_dist(col: int, cols: Array) -> int:
if cols.is_empty():
return 0
var best := 99
for c in cols:
best = mini(best, absi(col - c))
return best
## Kleinste Zahl fehlender Futter-Avatare über alle Board-Einheiten.
func _closest_upgrade_gap(p: PlayerState) -> int:
var best := 99
for hex in p.board:
var miss := p.upgrade_missing(hex)
if p.board[hex].upgrade_cost().is_empty():
continue
var total := 0
for e in miss:
total += miss[e]
best = mini(best, total)
return best
## Elemente, die irgendeine Board-Einheit für ihr nächstes Upgrade braucht.
func _needed_elements(p: PlayerState) -> Dictionary:
var need := {}
for hex in p.board:
for e in p.board[hex].upgrade_cost():
need[e] = true
return need
func _wants(entry: ShopEntry, p: PlayerState) -> bool:
if entry.element < 0:
# Neutral wird beim Kauf spezialisiert, also immer brauchbar.
return true
if _needed_elements(p).has(entry.element):
return true
return pref.has(entry.element)
func _do_buys(p: PlayerState, pool: Pool) -> void:
for i in p.shop.size():
var e: ShopEntry = p.shop[i]
if e.sold:
continue
if p.free_bench_slot() < 0:
return
if not _wants(e, p):
continue
if p.gold - e.price() < 0:
continue
p.buy(i, pool, _pick_element(p) if e.element < 0 else e.element)
func _do_placements(p: PlayerState) -> void:
while p.can_place():
var idx := _pick_bench_for_board(p)
if idx < 0:
return
var a: Avatar = p.bench[idx]
var hex := _pick_hex(p, a.role)
if hex.x < 0:
return
if not p.place(idx, hex):
return
## Zuerst Elemente aufstellen, die noch gar nicht im Team sind.
## Alles Weitere bleibt als Futter auf der Bank.
func _pick_bench_for_board(p: PlayerState) -> int:
var counts := p.trait_counts()
var need := _needed_elements(p)
var best := -1
var best_score := -1
for i in p.bench.size():
var a = p.bench[i]
if a == null:
continue
var score := 0
if need.has(a.elements[0]) and counts.get(a.elements[0], 0) > 0:
score = 0 # Futter für ein laufendes Upgrade bleibt auf der Bank
elif pref.has(a.elements[0]):
score = 3 if counts.get(a.elements[0], 0) == 0 else 1
else:
score = 2 if counts.get(a.elements[0], 0) == 0 else 0
if score > best_score:
best_score = score
best = i
return best if best_score > 0 else -1
func _pick_element(p: PlayerState) -> int:
var counts := p.trait_counts()
# Konter: schlägt eines meiner Lieblingselemente das häufigste Element des Gegners,
# nehme ich das. Schwere Bots kontern auch außerhalb ihrer Vorlieben.
var dominant := _dominant_element(opponent)
if dominant >= 0:
var counter := Data.beaten_by(dominant)
if pref.has(counter) or difficulty == Data.Difficulty.SCHWER:
return counter
var best := pref[0]
var best_c := -1
for e in pref:
var c: int = counts.get(e, 0)
if c > best_c and c < 6:
best_c = c
best = e
return best
## Häufigstes Hauptelement auf dem Brett eines Spielers, -1 wenn leer.
func _dominant_element(p: PlayerState) -> int:
if p == null or p.board.is_empty():
return -1
var counts := {}
for u in p.board_units():
var e: int = u.primary_element()
counts[e] = counts.get(e, 0) + 1
var best := -1
var best_n := 1 # ab zwei Einheiten lohnt das Kontern
for e in counts:
if counts[e] > best_n:
best_n = counts[e]
best = e
return best
func _pick_hex(p: PlayerState, role: int) -> Vector2i:
var rows: Array
match role:
Data.Role.TANK: rows = [0, 1, 2, 3]
Data.Role.DPS: rows = [2, 1, 3, 0]
_: rows = [3, 2, 1, 0]
var cols := [3, 2, 4, 1, 5, 0, 6]
for r in rows:
for c in cols:
var h := Vector2i(c, r)
if not p.board.has(h):
return h
return Vector2i(-1, -1)
func _do_upgrades(p: PlayerState, pool: Pool) -> void:
var again := true
while again:
again = false
for hex in p.board.keys():
if p.can_upgrade(hex):
var options := p.available_new_elements(hex)
var choice := -1
for e in pref:
if options.has(e):
choice = e
break
if choice < 0 and not options.is_empty():
choice = options[0]
if choice >= 0 and p.upgrade(hex, choice, pool):
again = true
break
## Bank leeren, wenn sie voll ist und nichts mehr gebraucht wird.
func _free_bench(p: PlayerState, pool: Pool) -> void:
if p.free_bench_slot() >= 0:
return
var need := _needed_elements(p)
# Erst verkaufen, was weder Futter noch Lieblingselement ist.
for i in p.bench.size():
var a = p.bench[i]
if a != null and not need.has(a.elements[0]) and not pref.has(a.elements[0]):
p.sell_bench(i, pool)
return
for i in p.bench.size():
var a = p.bench[i]
if a != null and not need.has(a.elements[0]):
p.sell_bench(i, pool)
return
p.sell_bench(0, pool)