280 lines
7.9 KiB
GDScript
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)
|