first commit
This commit is contained in:
125
scripts/core/Avatar.gd
Normal file
125
scripts/core/Avatar.gd
Normal file
@@ -0,0 +1,125 @@
|
||||
class_name Avatar
|
||||
extends RefCounted
|
||||
|
||||
## Eine Einheit. Elemente sind leer, solange der Avatar elementlos ist.
|
||||
|
||||
static var _next_id := 1
|
||||
|
||||
var id: int
|
||||
var role: int = Data.Role.DPS
|
||||
var elements: Array[int] = []
|
||||
var star: int = 1
|
||||
var invested: int = 0 ## bezahltes Gold (für Verkaufswert)
|
||||
|
||||
|
||||
func _init(p_role: int = Data.Role.DPS, p_elements: Array[int] = [], p_star: int = 1) -> void:
|
||||
id = _next_id
|
||||
_next_id += 1
|
||||
role = p_role
|
||||
elements = p_elements.duplicate()
|
||||
star = p_star
|
||||
|
||||
|
||||
func is_elementless() -> bool:
|
||||
return elements.is_empty()
|
||||
|
||||
|
||||
func primary_element() -> int:
|
||||
return elements[0] if not elements.is_empty() else -1
|
||||
|
||||
|
||||
func has_element(e: int) -> bool:
|
||||
return elements.has(e)
|
||||
|
||||
|
||||
func display_name() -> String:
|
||||
if elements.is_empty():
|
||||
return "Neutral-" + Data.ROLE_NAMES[role]
|
||||
var parts := []
|
||||
for e in elements:
|
||||
parts.append(Data.ELEMENT_NAMES[e])
|
||||
return "/".join(parts) + "-" + Data.ROLE_NAMES[role]
|
||||
|
||||
|
||||
func color() -> Color:
|
||||
if elements.is_empty():
|
||||
return Data.NONE_COLOR
|
||||
return Data.ELEMENT_COLORS[elements[0]]
|
||||
|
||||
|
||||
func price() -> int:
|
||||
return Data.PRICE_NONE if elements.is_empty() else Data.PRICE_ELEMENT
|
||||
|
||||
|
||||
## Was zum nächsten Stern fehlt: {element: anzahl}. Leer = maximal.
|
||||
func upgrade_cost() -> Dictionary:
|
||||
if star >= Data.MAX_STAR or elements.is_empty():
|
||||
return {}
|
||||
var cost := {}
|
||||
if star == 1:
|
||||
# 3 Avatare gesamt: der Basis-Avatar zählt mit, also 2 weitere.
|
||||
cost[elements[0]] = 2
|
||||
else:
|
||||
# 2-Star -> 3-Star: je 3 Avatare pro vorhandenem Element.
|
||||
for e in elements:
|
||||
cost[e] = 3
|
||||
return cost
|
||||
|
||||
|
||||
func base_stats() -> Dictionary:
|
||||
var b: Dictionary = Data.ROLE_STATS[role]
|
||||
var mult: float = Data.STAR_MULT[star - 1]
|
||||
var ebonus := 1.0 + Data.ELEMENT_STAT_BONUS * float(maxi(0, elements.size() - 1))
|
||||
return {
|
||||
"hp": b["hp"] * mult * ebonus,
|
||||
"atk": b["atk"] * mult * ebonus,
|
||||
"armor": b["armor"],
|
||||
"aspd": b["aspd"],
|
||||
"range": b["range"],
|
||||
"mana": b["mana"],
|
||||
}
|
||||
|
||||
|
||||
func duplicate_avatar() -> Avatar:
|
||||
var a := Avatar.new(role, elements, star)
|
||||
a.invested = invested
|
||||
return a
|
||||
|
||||
|
||||
## Beschreibung der Fähigkeit, abhängig von Rolle, Stern und Elementen.
|
||||
func ability_text() -> String:
|
||||
var s := ""
|
||||
match role:
|
||||
Data.Role.TANK:
|
||||
s = "Schild %d für 4s" % int(base_stats()["atk"] * (3.0 + 0.5 * float(star - 1)))
|
||||
if star >= 2:
|
||||
s += ", spottet 3 Gegner"
|
||||
if star >= 3:
|
||||
s += ", 25% Reflexion"
|
||||
Data.Role.DPS:
|
||||
s = "%d Schaden aufs Ziel" % int(base_stats()["atk"] * (3.0 + 0.3 * float(star - 1)))
|
||||
if star == 2:
|
||||
s += " + Splash auf Nachbarn"
|
||||
elif star >= 3:
|
||||
s += " + Fläche (Radius 2)"
|
||||
Data.Role.HEILER:
|
||||
var amount := int(base_stats()["atk"] * (3.0 - 0.25 * float(star - 1)))
|
||||
if star == 1:
|
||||
s = "heilt schwächsten Verbündeten um %d" % amount
|
||||
elif star == 2:
|
||||
s = "heilt 3 Verbündete um %d" % amount
|
||||
else:
|
||||
s = "heilt alle um %d und schildet" % amount
|
||||
var extra := []
|
||||
for i in elements.size():
|
||||
var scale := "" if i == 0 else " (halb)"
|
||||
match elements[i]:
|
||||
Data.Element.FEUER: extra.append("Brand" + scale)
|
||||
Data.Element.WASSER: extra.append("Mana + stärkere Heilung" + scale)
|
||||
Data.Element.WIND: extra.append("Tempo-Schub" + scale)
|
||||
Data.Element.BLITZ: extra.append("Kettenblitz" + scale)
|
||||
Data.Element.ERDE: extra.append("Betäubung" + scale)
|
||||
Data.Element.EIS: extra.append("Verlangsamung" + scale)
|
||||
if not extra.is_empty():
|
||||
s += "\nElemente: " + ", ".join(extra)
|
||||
return s
|
||||
1
scripts/core/Avatar.gd.uid
Normal file
1
scripts/core/Avatar.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bq5aj8ldpjnqo
|
||||
155
scripts/core/BotAI.gd
Normal file
155
scripts/core/BotAI.gd
Normal file
@@ -0,0 +1,155 @@
|
||||
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
|
||||
|
||||
|
||||
func setup(rand: RandomNumberGenerator) -> void:
|
||||
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]]
|
||||
greed = rand.randf_range(0.3, 0.8)
|
||||
|
||||
|
||||
func take_turn(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> void:
|
||||
_do_upgrades(p, pool)
|
||||
_do_buys(p, pool, rand, round_no)
|
||||
_do_placements(p, pool, round_no)
|
||||
_do_upgrades(p, pool)
|
||||
var keep := int(lerpf(2.0, 18.0, greed))
|
||||
if p.gold > keep + Data.REROLL_COST and rand.randf() < 0.6:
|
||||
p.reroll(pool, rand)
|
||||
_do_buys(p, pool, rand, round_no)
|
||||
_do_placements(p, pool, round_no)
|
||||
_do_upgrades(p, pool)
|
||||
_free_bench(p, pool)
|
||||
|
||||
|
||||
func _wants(entry: ShopEntry, p: PlayerState) -> bool:
|
||||
if entry.element < 0:
|
||||
# Neutral wird beim Kauf spezialisiert, also immer brauchbar.
|
||||
return true
|
||||
if pref.has(entry.element):
|
||||
return true
|
||||
# Elemente, die schon auf dem Board sind, sind immer Futter.
|
||||
return p.trait_counts().has(entry.element)
|
||||
|
||||
|
||||
func _do_buys(p: PlayerState, pool: Pool, rand: RandomNumberGenerator, round_no: int) -> 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, pool: Pool, round_no: int) -> void:
|
||||
while p.can_place(round_no):
|
||||
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, round_no):
|
||||
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 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 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()
|
||||
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
|
||||
|
||||
|
||||
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 counts := p.trait_counts()
|
||||
for i in p.bench.size():
|
||||
var a = p.bench[i]
|
||||
if a == null:
|
||||
continue
|
||||
if counts.get(a.elements[0], 0) == 0 and not pref.has(a.elements[0]):
|
||||
p.sell_bench(i, pool)
|
||||
return
|
||||
p.sell_bench(0, pool)
|
||||
1
scripts/core/BotAI.gd.uid
Normal file
1
scripts/core/BotAI.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bl3psu7dnb3x2
|
||||
477
scripts/core/Combat.gd
Normal file
477
scripts/core/Combat.gd
Normal file
@@ -0,0 +1,477 @@
|
||||
class_name Combat
|
||||
extends RefCounted
|
||||
|
||||
## Auto-Kampf zweier Aufstellungen auf einem 7x8-Hexfeld.
|
||||
## Rein logisch und schrittweise: Bot-Kämpfe laufen sofort durch,
|
||||
## der Kampf des Spielers wird pro Frame gesteppt.
|
||||
|
||||
const ARENA_COLS := 7
|
||||
const ARENA_ROWS := 8
|
||||
const MOVE_TIME := 0.32
|
||||
const TIME_LIMIT := 35.0
|
||||
const CRIT_MULT := 1.5
|
||||
const MANA_PER_ATTACK := 10.0
|
||||
const MANA_PER_HIT := 5.0
|
||||
|
||||
|
||||
class CUnit extends RefCounted:
|
||||
var avatar: Avatar
|
||||
var team: int
|
||||
var pos: Vector2i
|
||||
var hp: float
|
||||
var max_hp: float
|
||||
var atk: float
|
||||
var armor: float
|
||||
var aspd: float
|
||||
var atk_range: int
|
||||
var mana: float
|
||||
var max_mana: float
|
||||
var mana_mult: float = 1.0
|
||||
var crit: float = 0.0
|
||||
var attack_timer: float = 0.0
|
||||
var move_timer: float = 0.0
|
||||
var alive: bool = true
|
||||
var shield: float = 0.0
|
||||
var shield_timer: float = 0.0
|
||||
var burn_dps: float = 0.0
|
||||
var burn_timer: float = 0.0
|
||||
var slow: float = 0.0
|
||||
var slow_timer: float = 0.0
|
||||
var haste: float = 0.0
|
||||
var haste_timer: float = 0.0
|
||||
var stun_timer: float = 0.0
|
||||
var reflect: float = 0.0
|
||||
var reflect_timer: float = 0.0
|
||||
var taunt_by = null
|
||||
var taunt_timer: float = 0.0
|
||||
var target = null
|
||||
var cast_flash: float = 0.0
|
||||
var hit_flash: float = 0.0
|
||||
|
||||
func effective_aspd() -> float:
|
||||
return maxf(0.1, aspd * (1.0 + haste) * (1.0 - slow))
|
||||
|
||||
|
||||
var units: Array = []
|
||||
var time: float = 0.0
|
||||
var finished: bool = false
|
||||
var winner: int = -1 ## 0 = Team A, 1 = Team B, -1 = unentschieden
|
||||
var survivors: int = 0
|
||||
var events: Array = [] ## kurze Textmeldungen für das Log
|
||||
|
||||
var _mods := [{}, {}]
|
||||
var _occ := {}
|
||||
var _rand := RandomNumberGenerator.new()
|
||||
|
||||
|
||||
func _init(a: PlayerState, b: PlayerState, seed_value: int = 0) -> void:
|
||||
_rand.seed = seed_value if seed_value != 0 else randi()
|
||||
_mods[0] = _build_mods(a)
|
||||
_mods[1] = _build_mods(b)
|
||||
_spawn(a, 0)
|
||||
_spawn(b, 1)
|
||||
_apply_start_effects()
|
||||
|
||||
|
||||
# --- Aufbau ----------------------------------------------------------------
|
||||
|
||||
func _build_mods(p: PlayerState) -> Dictionary:
|
||||
var counts := p.trait_counts()
|
||||
var m := {
|
||||
"atk": 1.0, "hp": 1.0, "aspd": 1.0, "mana": 1.0,
|
||||
"crit": 0.0, "ice": 0.0, "combos": {},
|
||||
}
|
||||
for e in counts:
|
||||
var tier := Data.trait_tier(e, counts[e])
|
||||
if tier <= 0:
|
||||
continue
|
||||
var v: float = Data.ELEMENT_TRAITS[e]["values"][tier - 1]
|
||||
match e:
|
||||
Data.Element.FEUER: m["atk"] += v
|
||||
Data.Element.WASSER: m["mana"] += v
|
||||
Data.Element.WIND: m["aspd"] += v
|
||||
Data.Element.BLITZ: m["crit"] += v
|
||||
Data.Element.ERDE: m["hp"] += v
|
||||
Data.Element.EIS: m["ice"] = maxf(m["ice"], v)
|
||||
for c in p.active_combos():
|
||||
m["combos"][c["key"]] = true
|
||||
return m
|
||||
|
||||
|
||||
func _spawn(p: PlayerState, team: int) -> void:
|
||||
var m: Dictionary = _mods[team]
|
||||
for hex in p.board:
|
||||
var a: Avatar = p.board[hex]
|
||||
var s := a.base_stats()
|
||||
var u := CUnit.new()
|
||||
u.avatar = a
|
||||
u.team = team
|
||||
u.pos = _arena_pos(hex, team)
|
||||
u.max_hp = s["hp"] * m["hp"]
|
||||
u.hp = u.max_hp
|
||||
u.atk = s["atk"] * m["atk"]
|
||||
u.armor = s["armor"]
|
||||
u.aspd = s["aspd"] * m["aspd"]
|
||||
u.atk_range = s["range"]
|
||||
u.max_mana = s["mana"]
|
||||
u.mana = 0.0
|
||||
u.mana_mult = m["mana"]
|
||||
u.crit = m["crit"]
|
||||
if _occ.has(u.pos):
|
||||
u.pos = _free_near(u.pos)
|
||||
_occ[u.pos] = u
|
||||
units.append(u)
|
||||
|
||||
|
||||
static func _arena_pos(hex: Vector2i, team: int) -> Vector2i:
|
||||
if team == 0:
|
||||
return Vector2i(hex.x, 4 + hex.y)
|
||||
return Vector2i(ARENA_COLS - 1 - hex.x, 3 - hex.y)
|
||||
|
||||
|
||||
func _free_near(p: Vector2i) -> Vector2i:
|
||||
for n in Hex.neighbors(p):
|
||||
if _in_arena(n) and not _occ.has(n):
|
||||
return n
|
||||
return p
|
||||
|
||||
|
||||
static func _in_arena(p: Vector2i) -> bool:
|
||||
return p.x >= 0 and p.x < ARENA_COLS and p.y >= 0 and p.y < ARENA_ROWS
|
||||
|
||||
|
||||
func _apply_start_effects() -> void:
|
||||
for team in 2:
|
||||
if _mods[team]["combos"].has("frost"):
|
||||
for u in units:
|
||||
if u.team != team:
|
||||
u.slow = maxf(u.slow, 0.15)
|
||||
u.slow_timer = 999.0
|
||||
|
||||
|
||||
# --- Simulation ------------------------------------------------------------
|
||||
|
||||
func step(dt: float) -> void:
|
||||
if finished:
|
||||
return
|
||||
time += dt
|
||||
for u in units:
|
||||
if u.alive:
|
||||
_tick_status(u, dt)
|
||||
for u in units:
|
||||
if u.alive and u.stun_timer <= 0.0:
|
||||
_act(u, dt)
|
||||
_check_end()
|
||||
|
||||
|
||||
func run_to_end(dt: float = 0.05) -> void:
|
||||
var guard := 0
|
||||
while not finished and guard < 5000:
|
||||
step(dt)
|
||||
guard += 1
|
||||
if not finished:
|
||||
_resolve_by_hp()
|
||||
|
||||
|
||||
func _tick_status(u: CUnit, dt: float) -> void:
|
||||
if u.shield_timer > 0.0:
|
||||
u.shield_timer -= dt
|
||||
if u.shield_timer <= 0.0:
|
||||
u.shield = 0.0
|
||||
if u.burn_timer > 0.0:
|
||||
u.burn_timer -= dt
|
||||
_apply_damage(null, u, u.burn_dps * dt, true)
|
||||
if u.burn_timer <= 0.0:
|
||||
u.burn_dps = 0.0
|
||||
if u.slow_timer > 0.0:
|
||||
u.slow_timer -= dt
|
||||
if u.slow_timer <= 0.0:
|
||||
u.slow = 0.0
|
||||
if u.haste_timer > 0.0:
|
||||
u.haste_timer -= dt
|
||||
if u.haste_timer <= 0.0:
|
||||
u.haste = 0.0
|
||||
if u.reflect_timer > 0.0:
|
||||
u.reflect_timer -= dt
|
||||
if u.reflect_timer <= 0.0:
|
||||
u.reflect = 0.0
|
||||
if u.stun_timer > 0.0:
|
||||
u.stun_timer -= dt
|
||||
if u.taunt_timer > 0.0:
|
||||
u.taunt_timer -= dt
|
||||
if u.taunt_timer <= 0.0:
|
||||
u.taunt_by = null
|
||||
u.cast_flash = maxf(0.0, u.cast_flash - dt)
|
||||
u.hit_flash = maxf(0.0, u.hit_flash - dt)
|
||||
|
||||
|
||||
func _act(u: CUnit, dt: float) -> void:
|
||||
var t = _pick_target(u)
|
||||
if t == null:
|
||||
return
|
||||
u.target = t
|
||||
var d := Hex.distance(u.pos, t.pos)
|
||||
if d <= u.atk_range:
|
||||
u.attack_timer -= dt * u.effective_aspd()
|
||||
if u.attack_timer <= 0.0:
|
||||
u.attack_timer += 1.0
|
||||
_basic_attack(u, t)
|
||||
else:
|
||||
u.move_timer -= dt
|
||||
if u.move_timer <= 0.0:
|
||||
u.move_timer = MOVE_TIME
|
||||
_step_towards(u, t)
|
||||
|
||||
|
||||
func _pick_target(u: CUnit):
|
||||
if u.taunt_by != null and u.taunt_by.alive and u.taunt_timer > 0.0:
|
||||
return u.taunt_by
|
||||
if u.target != null and u.target.alive:
|
||||
return u.target
|
||||
return _nearest_enemy(u)
|
||||
|
||||
|
||||
func _nearest_enemy(u: CUnit, exclude = null):
|
||||
var best = null
|
||||
var best_d := 999
|
||||
for o in units:
|
||||
if not o.alive or o.team == u.team or o == exclude:
|
||||
continue
|
||||
var d := Hex.distance(u.pos, o.pos)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = o
|
||||
return best
|
||||
|
||||
|
||||
func _step_towards(u: CUnit, t: CUnit) -> void:
|
||||
var best := u.pos
|
||||
var best_d := Hex.distance(u.pos, t.pos)
|
||||
for n in Hex.neighbors(u.pos):
|
||||
if not _in_arena(n) or _occ.has(n):
|
||||
continue
|
||||
var d := Hex.distance(n, t.pos)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = n
|
||||
if best != u.pos:
|
||||
_occ.erase(u.pos)
|
||||
u.pos = best
|
||||
_occ[u.pos] = u
|
||||
|
||||
|
||||
# --- Angriff und Schaden ---------------------------------------------------
|
||||
|
||||
func _basic_attack(u: CUnit, t: CUnit) -> void:
|
||||
var dmg := u.atk
|
||||
if u.crit > 0.0 and _rand.randf() < u.crit:
|
||||
dmg *= CRIT_MULT
|
||||
_apply_damage(u, t, dmg, false)
|
||||
var ice: float = _mods[u.team]["ice"]
|
||||
if ice > 0.0:
|
||||
t.slow = maxf(t.slow, ice)
|
||||
t.slow_timer = maxf(t.slow_timer, 2.0)
|
||||
if _mods[u.team]["combos"].has("sturmfront"):
|
||||
var second = _nearest_enemy(u, t)
|
||||
if second != null:
|
||||
_apply_damage(u, second, dmg * 0.5, false)
|
||||
u.mana += MANA_PER_ATTACK * u.mana_mult
|
||||
if u.mana >= u.max_mana:
|
||||
_cast(u)
|
||||
|
||||
|
||||
func _apply_damage(src, tgt: CUnit, amount: float, is_true: bool) -> void:
|
||||
if not tgt.alive or amount <= 0.0:
|
||||
return
|
||||
var dmg := amount if is_true else amount * (1.0 - tgt.armor)
|
||||
if tgt.shield > 0.0:
|
||||
var absorbed: float = minf(tgt.shield, dmg)
|
||||
tgt.shield -= absorbed
|
||||
dmg -= absorbed
|
||||
if dmg > 0.0:
|
||||
tgt.hp -= dmg
|
||||
tgt.hit_flash = 0.12
|
||||
tgt.mana += MANA_PER_HIT * tgt.mana_mult
|
||||
if tgt.mana >= tgt.max_mana and tgt.alive and tgt.hp > 0.0:
|
||||
_cast(tgt)
|
||||
if src != null and dmg > 0.0:
|
||||
if tgt.reflect > 0.0:
|
||||
src.hp -= dmg * tgt.reflect
|
||||
if src.hp <= 0.0:
|
||||
_kill(src)
|
||||
if _mods[tgt.team]["combos"].has("magma"):
|
||||
src.hp -= dmg * 0.20
|
||||
if src.hp <= 0.0:
|
||||
_kill(src)
|
||||
if _mods[src.team]["combos"].has("dampf"):
|
||||
src.hp = minf(src.max_hp, src.hp + dmg * 0.18)
|
||||
if tgt.hp <= 0.0:
|
||||
_kill(tgt)
|
||||
|
||||
|
||||
func _kill(u: CUnit) -> void:
|
||||
if not u.alive:
|
||||
return
|
||||
u.alive = false
|
||||
u.hp = 0.0
|
||||
_occ.erase(u.pos)
|
||||
events.append(u.avatar.display_name() + " fällt")
|
||||
|
||||
|
||||
func _heal(u: CUnit, amount: float) -> void:
|
||||
if u.alive:
|
||||
u.hp = minf(u.max_hp, u.hp + amount)
|
||||
|
||||
|
||||
# --- Fähigkeiten ----------------------------------------------------------
|
||||
|
||||
func _cast(u: CUnit) -> void:
|
||||
u.mana = 0.0
|
||||
u.cast_flash = 0.35
|
||||
var elems: Array = u.avatar.elements
|
||||
var heal_bonus := 0.0
|
||||
for i in elems.size():
|
||||
if elems[i] == Data.Element.WASSER:
|
||||
heal_bonus += 0.20 * (1.0 if i == 0 else 0.5)
|
||||
|
||||
var hit_enemies: Array = []
|
||||
var main_target = u.target if (u.target != null and u.target.alive) else _nearest_enemy(u)
|
||||
|
||||
match u.avatar.role:
|
||||
Data.Role.TANK:
|
||||
var sh := u.atk * (3.0 + 0.5 * float(u.avatar.star - 1)) * (1.0 + heal_bonus)
|
||||
u.shield = maxf(u.shield, sh)
|
||||
u.shield_timer = 4.0
|
||||
if u.avatar.star >= 2:
|
||||
var n := 0
|
||||
for o in units:
|
||||
if o.alive and o.team != u.team and Hex.distance(o.pos, u.pos) <= 2 and n < 3:
|
||||
o.taunt_by = u
|
||||
o.taunt_timer = 2.0
|
||||
o.target = u
|
||||
n += 1
|
||||
if u.avatar.star >= 3:
|
||||
u.reflect = 0.25
|
||||
u.reflect_timer = 4.0
|
||||
Data.Role.DPS:
|
||||
if main_target == null:
|
||||
return
|
||||
var base := u.atk * (3.0 + 0.3 * float(u.avatar.star - 1))
|
||||
_apply_damage(u, main_target, base, false)
|
||||
hit_enemies.append(main_target)
|
||||
if u.avatar.star == 2:
|
||||
for n in Hex.neighbors(main_target.pos):
|
||||
var o = _occ.get(n)
|
||||
if o != null and o.alive and o.team != u.team:
|
||||
_apply_damage(u, o, u.atk * 1.5, false)
|
||||
hit_enemies.append(o)
|
||||
elif u.avatar.star >= 3:
|
||||
for o in units:
|
||||
if o.alive and o.team != u.team and o != main_target \
|
||||
and Hex.distance(o.pos, main_target.pos) <= 2:
|
||||
_apply_damage(u, o, u.atk * 2.0, false)
|
||||
hit_enemies.append(o)
|
||||
Data.Role.HEILER:
|
||||
var amount := u.atk * (3.0 - 0.25 * float(u.avatar.star - 1)) * (1.0 + heal_bonus)
|
||||
var allies := _allies_by_missing_hp(u.team)
|
||||
var count := 1
|
||||
if u.avatar.star == 2:
|
||||
count = 3
|
||||
elif u.avatar.star >= 3:
|
||||
count = allies.size()
|
||||
for i in mini(count, allies.size()):
|
||||
_heal(allies[i], amount)
|
||||
if u.avatar.star >= 3:
|
||||
allies[i].shield = maxf(allies[i].shield, u.atk * 1.5 * (1.0 + heal_bonus))
|
||||
allies[i].shield_timer = 3.0
|
||||
|
||||
_apply_element_effects(u, main_target, hit_enemies)
|
||||
|
||||
|
||||
func _apply_element_effects(u: CUnit, main_target, hit_enemies: Array) -> void:
|
||||
var elems: Array = u.avatar.elements
|
||||
var combos: Dictionary = _mods[u.team]["combos"]
|
||||
if combos.has("feuersturm"):
|
||||
for o in hit_enemies:
|
||||
o.burn_dps = maxf(o.burn_dps, u.atk * 0.4)
|
||||
o.burn_timer = maxf(o.burn_timer, 3.0)
|
||||
if combos.has("magnetsturm") and main_target != null and main_target.alive:
|
||||
main_target.stun_timer = maxf(main_target.stun_timer, 0.6)
|
||||
|
||||
for i in elems.size():
|
||||
var scale := 1.0 if i == 0 else 0.5
|
||||
match elems[i]:
|
||||
Data.Element.FEUER:
|
||||
for o in hit_enemies:
|
||||
if o.alive:
|
||||
o.burn_dps = maxf(o.burn_dps, u.atk * 0.6 * scale)
|
||||
o.burn_timer = maxf(o.burn_timer, 3.0)
|
||||
Data.Element.WASSER:
|
||||
u.mana += 15.0 * scale
|
||||
Data.Element.WIND:
|
||||
u.haste = maxf(u.haste, 0.30 * scale)
|
||||
u.haste_timer = 3.0
|
||||
Data.Element.BLITZ:
|
||||
var chained := 0
|
||||
for o in units:
|
||||
if chained >= 2:
|
||||
break
|
||||
if o.alive and o.team != u.team and o != main_target:
|
||||
_apply_damage(u, o, u.atk * 0.8 * scale, false)
|
||||
chained += 1
|
||||
Data.Element.ERDE:
|
||||
if main_target != null and main_target.alive:
|
||||
main_target.stun_timer = maxf(main_target.stun_timer, 0.75 * scale)
|
||||
Data.Element.EIS:
|
||||
if main_target != null and main_target.alive:
|
||||
main_target.slow = maxf(main_target.slow, 0.30 * scale)
|
||||
main_target.slow_timer = maxf(main_target.slow_timer, 3.0)
|
||||
|
||||
|
||||
func _allies_by_missing_hp(team: int) -> Array:
|
||||
var list := []
|
||||
for o in units:
|
||||
if o.alive and o.team == team:
|
||||
list.append(o)
|
||||
list.sort_custom(func(a, b): return (a.hp / a.max_hp) < (b.hp / b.max_hp))
|
||||
return list
|
||||
|
||||
|
||||
# --- Ende ------------------------------------------------------------------
|
||||
|
||||
func alive_count(team: int) -> int:
|
||||
var n := 0
|
||||
for u in units:
|
||||
if u.alive and u.team == team:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _check_end() -> void:
|
||||
var a := alive_count(0)
|
||||
var b := alive_count(1)
|
||||
if a == 0 or b == 0:
|
||||
finished = true
|
||||
if a == b:
|
||||
winner = -1
|
||||
survivors = 0
|
||||
else:
|
||||
winner = 0 if a > b else 1
|
||||
survivors = maxi(a, b)
|
||||
return
|
||||
if time >= TIME_LIMIT:
|
||||
_resolve_by_hp()
|
||||
|
||||
|
||||
func _resolve_by_hp() -> void:
|
||||
finished = true
|
||||
var frac := [0.0, 0.0]
|
||||
for u in units:
|
||||
if u.alive:
|
||||
frac[u.team] += u.hp / u.max_hp
|
||||
if absf(frac[0] - frac[1]) < 0.25:
|
||||
winner = -1
|
||||
survivors = 0
|
||||
else:
|
||||
winner = 0 if frac[0] > frac[1] else 1
|
||||
survivors = alive_count(winner)
|
||||
1
scripts/core/Combat.gd.uid
Normal file
1
scripts/core/Combat.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bkybe632bjrh8
|
||||
161
scripts/core/Data.gd
Normal file
161
scripts/core/Data.gd
Normal file
@@ -0,0 +1,161 @@
|
||||
class_name Data
|
||||
extends RefCounted
|
||||
|
||||
## Statische Spieldaten: Elemente, Rollen, Basiswerte, Traits, Preise.
|
||||
|
||||
enum Element { FEUER, WASSER, WIND, BLITZ, ERDE, EIS }
|
||||
enum Role { TANK, DPS, HEILER }
|
||||
|
||||
const ELEMENT_COUNT := 6
|
||||
const ELEMENT_NAMES := ["Feuer", "Wasser", "Wind", "Blitz", "Erde", "Eis"]
|
||||
const ELEMENT_COLORS := [
|
||||
Color(0.92, 0.31, 0.16),
|
||||
Color(0.18, 0.52, 0.90),
|
||||
Color(0.48, 0.85, 0.60),
|
||||
Color(0.96, 0.84, 0.22),
|
||||
Color(0.62, 0.45, 0.24),
|
||||
Color(0.66, 0.92, 0.98),
|
||||
]
|
||||
const NONE_COLOR := Color(0.62, 0.62, 0.66)
|
||||
|
||||
const ROLE_NAMES := ["Tank", "DPS", "Heiler"]
|
||||
const ROLE_SHORT := ["T", "D", "H"]
|
||||
|
||||
# Basiswerte auf 1-Star.
|
||||
const ROLE_STATS := {
|
||||
Role.TANK: {"hp": 700.0, "atk": 45.0, "armor": 0.40, "aspd": 0.60, "range": 1, "mana": 80.0},
|
||||
Role.DPS: {"hp": 450.0, "atk": 65.0, "armor": 0.18, "aspd": 0.80, "range": 3, "mana": 100.0},
|
||||
Role.HEILER: {"hp": 520.0, "atk": 38.0, "armor": 0.22, "aspd": 0.70, "range": 3, "mana": 90.0},
|
||||
}
|
||||
|
||||
# Multiplikator pro Sternstufe (Index = star - 1).
|
||||
const STAR_MULT := [1.0, 1.8, 3.24]
|
||||
const MAX_STAR := 3
|
||||
|
||||
# Jedes zusätzliche Element gibt einen kleinen Grundwert-Bonus.
|
||||
const ELEMENT_STAT_BONUS := 0.06
|
||||
|
||||
# --- Oekonomie -------------------------------------------------------------
|
||||
const START_HP := 100
|
||||
const BASE_INCOME := 5
|
||||
const WIN_GOLD := 1
|
||||
const REROLL_COST := 2
|
||||
const PRICE_NONE := 1
|
||||
const PRICE_ELEMENT := 2
|
||||
## Zinsen: je 10 Gold ein Gold dazu, gedeckelt bei 50 Gold.
|
||||
const INTEREST_STEP := 10
|
||||
const INTEREST_MAX := 5
|
||||
const SHOP_SLOTS := 5
|
||||
## Je aufgestelltem Element wird ein Shop-Platz zu 25% seltener neutral.
|
||||
## Multiplikativ, damit Neutral nie ganz verschwindet.
|
||||
const NEUTRAL_FALLOFF := 0.75
|
||||
const BENCH_SLOTS := 9
|
||||
const START_GOLD := 4
|
||||
|
||||
# --- Geteilter Pool --------------------------------------------------------
|
||||
## Alle 8 Spieler teilen sich diesen Vorrat pro Element. Ein 3-Star bindet
|
||||
## allein 9 Stück, dazu kommt was auf Bänken und Brettern liegt.
|
||||
## Bei 180 hält der Vorrat eine ganze Partie und kann im Endspiel trotzdem knapp werden.
|
||||
const POOL_PER_ELEMENT := 180
|
||||
|
||||
# --- Board -----------------------------------------------------------------
|
||||
const BOARD_COLS := 7
|
||||
const BOARD_ROWS := 4
|
||||
const MAX_TEAM_SLOTS := 9
|
||||
|
||||
# --- Element-Traits --------------------------------------------------------
|
||||
# steps = Anzahl aufgestellter Einheiten mit diesem Element.
|
||||
const ELEMENT_TRAITS := {
|
||||
Element.FEUER: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["+12% Angriff", "+26% Angriff", "+48% Angriff"],
|
||||
"values": [0.12, 0.26, 0.48],
|
||||
},
|
||||
Element.WASSER: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["+20% Mana", "+40% Mana", "+70% Mana"],
|
||||
"values": [0.20, 0.40, 0.70],
|
||||
},
|
||||
Element.WIND: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["+10% Tempo", "+22% Tempo", "+40% Tempo"],
|
||||
"values": [0.10, 0.22, 0.40],
|
||||
},
|
||||
Element.BLITZ: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["+15% Krit", "+30% Krit", "+55% Krit"],
|
||||
"values": [0.15, 0.30, 0.55],
|
||||
},
|
||||
Element.ERDE: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["+12% Leben", "+26% Leben", "+48% Leben"],
|
||||
"values": [0.12, 0.26, 0.48],
|
||||
},
|
||||
Element.EIS: {
|
||||
"steps": [2, 4, 6],
|
||||
"desc": ["Treffer -12% Tempo", "Treffer -22% Tempo", "Treffer -35% Tempo"],
|
||||
"values": [0.12, 0.22, 0.35],
|
||||
},
|
||||
}
|
||||
|
||||
# --- Kombi-Traits ----------------------------------------------------------
|
||||
# Aktiv, wenn beide Elemente mindestens COMBO_MIN Einheiten haben.
|
||||
const COMBO_MIN := 2
|
||||
const COMBO_TRAITS := [
|
||||
{"a": Element.FEUER, "b": Element.WIND, "key": "feuersturm", "name": "Feuersturm", "desc": "Fähigkeiten setzen in Brand"},
|
||||
{"a": Element.WASSER, "b": Element.EIS, "key": "frost", "name": "Frost", "desc": "Gegner starten 15% langsamer"},
|
||||
{"a": Element.BLITZ, "b": Element.ERDE, "key": "magnetsturm","name": "Magnetsturm", "desc": "Fähigkeiten betäuben 0.6s"},
|
||||
{"a": Element.FEUER, "b": Element.ERDE, "key": "magma", "name": "Magma", "desc": "20% Schaden reflektiert"},
|
||||
{"a": Element.WIND, "b": Element.BLITZ, "key": "sturmfront", "name": "Sturmfront", "desc": "Angriffe springen auf ein 2. Ziel"},
|
||||
{"a": Element.WASSER, "b": Element.FEUER, "key": "dampf", "name": "Dampf", "desc": "18% Lebensraub"},
|
||||
]
|
||||
|
||||
|
||||
static func element_name(e: int) -> String:
|
||||
return ELEMENT_NAMES[e]
|
||||
|
||||
|
||||
static func element_color(e: int) -> Color:
|
||||
return ELEMENT_COLORS[e]
|
||||
|
||||
|
||||
static func role_name(r: int) -> String:
|
||||
return ROLE_NAMES[r]
|
||||
|
||||
|
||||
## Aktive Stufe eines Element-Traits (0 = inaktiv, sonst 1..3).
|
||||
static func trait_tier(element: int, count: int) -> int:
|
||||
var steps: Array = ELEMENT_TRAITS[element]["steps"]
|
||||
var tier := 0
|
||||
for i in steps.size():
|
||||
if count >= steps[i]:
|
||||
tier = i + 1
|
||||
return tier
|
||||
|
||||
|
||||
## Teamgröße wächst mit der Runde.
|
||||
static func team_slots(round_no: int) -> int:
|
||||
return mini(3 + int(float(round_no - 1) / 2.0), MAX_TEAM_SLOTS)
|
||||
|
||||
|
||||
## Wahrscheinlichkeit, dass ein Shop-Platz einen neutralen Avatar zeigt.
|
||||
## Ohne eigene Elemente gibt es nur Neutral.
|
||||
static func neutral_chance(unlocked_count: int) -> float:
|
||||
return pow(NEUTRAL_FALLOFF, float(unlocked_count))
|
||||
|
||||
|
||||
## Zinsen auf das gehaltene Gold.
|
||||
static func interest(gold: int) -> int:
|
||||
return mini(INTEREST_MAX, int(float(gold) / float(INTEREST_STEP)))
|
||||
|
||||
|
||||
## Streak-Bonusgold (Sieg- oder Niederlagenserie).
|
||||
static func streak_gold(streak: int) -> int:
|
||||
var s := absi(streak)
|
||||
if s >= 5:
|
||||
return 3
|
||||
if s == 4:
|
||||
return 2
|
||||
if s == 3:
|
||||
return 1
|
||||
return 0
|
||||
1
scripts/core/Data.gd.uid
Normal file
1
scripts/core/Data.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cpbejautavs4q
|
||||
210
scripts/core/Game.gd
Normal file
210
scripts/core/Game.gd
Normal file
@@ -0,0 +1,210 @@
|
||||
class_name Game
|
||||
extends RefCounted
|
||||
|
||||
## Rundenlogik: 8 Spieler (1 Mensch, 7 Bots), geteilter Pool, Paarungen, Kämpfe.
|
||||
|
||||
enum Phase { PLANNING, COMBAT, RESULT, GAMEOVER }
|
||||
|
||||
const BOT_NAMES := [
|
||||
"Ignis", "Nerea", "Zephyr", "Volt", "Terra", "Frost", "Umbra",
|
||||
]
|
||||
|
||||
var players: Array = []
|
||||
var pool: Pool
|
||||
var rand := RandomNumberGenerator.new()
|
||||
var round_no: int = 0
|
||||
var phase: int = Phase.PLANNING
|
||||
var human: PlayerState
|
||||
var bots := {} ## pid -> BotAI
|
||||
var pairings: Array = [] ## [{a, b, ghost}]
|
||||
var player_combat: Combat = null
|
||||
var pending: Array = [] ## Ergebnisse der Bot-Kämpfe
|
||||
var last_round_log: Array = []
|
||||
|
||||
|
||||
func start(seed_value: int = 0) -> void:
|
||||
rand.seed = seed_value if seed_value != 0 else randi()
|
||||
pool = Pool.new()
|
||||
players.clear()
|
||||
human = PlayerState.new(0, "Du", false)
|
||||
players.append(human)
|
||||
for i in BOT_NAMES.size():
|
||||
var b := PlayerState.new(i + 1, BOT_NAMES[i], true)
|
||||
players.append(b)
|
||||
var ai := BotAI.new()
|
||||
ai.setup(rand)
|
||||
bots[b.pid] = ai
|
||||
round_no = 1
|
||||
for p in players:
|
||||
p.roll_shop(pool, rand)
|
||||
_run_bot_turns()
|
||||
phase = Phase.PLANNING
|
||||
|
||||
|
||||
func alive_players() -> Array:
|
||||
var out := []
|
||||
for p in players:
|
||||
if p.alive:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# --- Planungsphase ---------------------------------------------------------
|
||||
|
||||
func _run_bot_turns() -> void:
|
||||
for p in players:
|
||||
if p.is_bot and p.alive:
|
||||
bots[p.pid].take_turn(p, pool, rand, round_no)
|
||||
|
||||
|
||||
# --- Kampfphase ------------------------------------------------------------
|
||||
|
||||
func start_combat() -> void:
|
||||
if phase != Phase.PLANNING:
|
||||
return
|
||||
pairings = _make_pairings()
|
||||
pending.clear()
|
||||
player_combat = null
|
||||
for pr in pairings:
|
||||
if pr["a"] == human or pr["b"] == human:
|
||||
player_combat = Combat.new(pr["a"], pr["b"], rand.randi())
|
||||
pending.append({"pair": pr, "combat": player_combat})
|
||||
else:
|
||||
var c := Combat.new(pr["a"], pr["b"], rand.randi())
|
||||
c.run_to_end()
|
||||
pending.append({"pair": pr, "combat": c})
|
||||
phase = Phase.COMBAT
|
||||
if player_combat == null:
|
||||
resolve_round()
|
||||
|
||||
|
||||
func update_combat(dt: float) -> void:
|
||||
if phase != Phase.COMBAT or player_combat == null:
|
||||
return
|
||||
player_combat.step(dt)
|
||||
if player_combat.finished:
|
||||
resolve_round()
|
||||
|
||||
|
||||
func skip_combat() -> void:
|
||||
if phase == Phase.COMBAT and player_combat != null:
|
||||
player_combat.run_to_end()
|
||||
resolve_round()
|
||||
|
||||
|
||||
func _make_pairings() -> Array:
|
||||
var alive := alive_players()
|
||||
alive.shuffle()
|
||||
var out := []
|
||||
var i := 0
|
||||
while i + 1 < alive.size():
|
||||
out.append({"a": alive[i], "b": alive[i + 1], "ghost": false})
|
||||
i += 2
|
||||
if i < alive.size():
|
||||
# Ungerade Anzahl: der Rest kämpft gegen ein Abbild eines Gegners.
|
||||
var lone: PlayerState = alive[i]
|
||||
var others := alive.filter(func(p): return p != lone)
|
||||
if others.is_empty():
|
||||
return out
|
||||
var src: PlayerState = others[rand.randi_range(0, others.size() - 1)]
|
||||
out.append({"a": lone, "b": _make_ghost(src), "ghost": true})
|
||||
return out
|
||||
|
||||
|
||||
func _make_ghost(src: PlayerState) -> PlayerState:
|
||||
var g := PlayerState.new(-1, src.pname + " (Abbild)", true)
|
||||
for hex in src.board:
|
||||
g.board[hex] = src.board[hex]
|
||||
return g
|
||||
|
||||
|
||||
# --- Auswertung ------------------------------------------------------------
|
||||
|
||||
func resolve_round() -> void:
|
||||
last_round_log.clear()
|
||||
for item in pending:
|
||||
var pr: Dictionary = item["pair"]
|
||||
var c: Combat = item["combat"]
|
||||
if not c.finished:
|
||||
c.run_to_end()
|
||||
var a: PlayerState = pr["a"]
|
||||
var b: PlayerState = pr["b"]
|
||||
var dmg := 3 + c.survivors
|
||||
if c.winner == 0:
|
||||
_apply_result(a, b, dmg, pr["ghost"])
|
||||
elif c.winner == 1:
|
||||
_apply_result(b, a, dmg, pr["ghost"])
|
||||
else:
|
||||
a.register_result(false)
|
||||
a.take_damage(3)
|
||||
if not pr["ghost"]:
|
||||
b.register_result(false)
|
||||
b.take_damage(3)
|
||||
last_round_log.append("%s vs %s: unentschieden" % [a.pname, b.pname])
|
||||
pending.clear()
|
||||
player_combat = null
|
||||
_check_eliminations()
|
||||
if alive_players().size() <= 1:
|
||||
phase = Phase.GAMEOVER
|
||||
for p in alive_players():
|
||||
p.placement = 1
|
||||
return
|
||||
_next_round()
|
||||
|
||||
|
||||
func _apply_result(winner: PlayerState, loser: PlayerState, dmg: int, ghost: bool) -> void:
|
||||
winner.register_result(true)
|
||||
if loser.pid >= 0:
|
||||
loser.register_result(false)
|
||||
loser.take_damage(dmg)
|
||||
last_round_log.append("%s schlägt %s (-%d Leben)" % [winner.pname, loser.pname, dmg])
|
||||
|
||||
|
||||
func _check_eliminations() -> void:
|
||||
var still := alive_players().size()
|
||||
for p in players:
|
||||
if p.hp <= 0 and p.alive:
|
||||
p.alive = false
|
||||
p.placement = still
|
||||
still -= 1
|
||||
last_round_log.append(p.pname + " ist ausgeschieden")
|
||||
|
||||
|
||||
func _next_round() -> void:
|
||||
round_no += 1
|
||||
for p in players:
|
||||
if not p.alive:
|
||||
continue
|
||||
p.gold += p.round_income()
|
||||
p.roll_shop(pool, rand)
|
||||
_run_bot_turns()
|
||||
phase = Phase.PLANNING
|
||||
|
||||
|
||||
# --- Hilfen für die UI ----------------------------------------------------
|
||||
|
||||
func human_opponent() -> PlayerState:
|
||||
for pr in pairings:
|
||||
if pr["a"] == human:
|
||||
return pr["b"]
|
||||
if pr["b"] == human:
|
||||
return pr["a"]
|
||||
return null
|
||||
|
||||
|
||||
func human_team_in_combat() -> int:
|
||||
for pr in pairings:
|
||||
if pr["a"] == human:
|
||||
return 0
|
||||
if pr["b"] == human:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
func ranking() -> Array:
|
||||
var sorted := players.duplicate()
|
||||
sorted.sort_custom(func(a, b):
|
||||
if a.alive != b.alive:
|
||||
return a.alive
|
||||
return a.hp > b.hp)
|
||||
return sorted
|
||||
1
scripts/core/Game.gd.uid
Normal file
1
scripts/core/Game.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dj3pdat6ft684
|
||||
51
scripts/core/Hex.gd
Normal file
51
scripts/core/Hex.gd
Normal file
@@ -0,0 +1,51 @@
|
||||
class_name Hex
|
||||
extends RefCounted
|
||||
|
||||
## Hex-Helfer für "odd-r" Offset-Koordinaten (spitze Hexes, ungerade Zeilen versetzt).
|
||||
|
||||
const NEIGHBORS_EVEN := [
|
||||
Vector2i(1, 0), Vector2i(0, -1), Vector2i(-1, -1),
|
||||
Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1),
|
||||
]
|
||||
const NEIGHBORS_ODD := [
|
||||
Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1),
|
||||
Vector2i(-1, 0), Vector2i(0, 1), Vector2i(1, 1),
|
||||
]
|
||||
|
||||
|
||||
static func to_cube(p: Vector2i) -> Vector3i:
|
||||
var x := p.x - int((p.y - (p.y & 1)) / 2.0)
|
||||
var z := p.y
|
||||
return Vector3i(x, -x - z, z)
|
||||
|
||||
|
||||
static func distance(a: Vector2i, b: Vector2i) -> int:
|
||||
var ca := to_cube(a)
|
||||
var cb := to_cube(b)
|
||||
return int((absi(ca.x - cb.x) + absi(ca.y - cb.y) + absi(ca.z - cb.z)) / 2.0)
|
||||
|
||||
|
||||
static func neighbors(p: Vector2i) -> Array:
|
||||
var offs: Array = NEIGHBORS_ODD if (p.y & 1) == 1 else NEIGHBORS_EVEN
|
||||
var out := []
|
||||
for o in offs:
|
||||
out.append(p + o)
|
||||
return out
|
||||
|
||||
|
||||
## Pixelmittelpunkt eines Hex-Feldes.
|
||||
static func to_pixel(p: Vector2i, size: float) -> Vector2:
|
||||
var w := sqrt(3.0) * size
|
||||
var h := 1.5 * size
|
||||
var x := w * (float(p.x) + 0.5 * float(p.y & 1))
|
||||
var y := h * float(p.y)
|
||||
return Vector2(x, y)
|
||||
|
||||
|
||||
## Eckpunkte eines spitzen Hex um (0,0).
|
||||
static func corners(size: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
for i in 6:
|
||||
var ang := deg_to_rad(60.0 * float(i) - 30.0)
|
||||
pts.append(Vector2(size * cos(ang), size * sin(ang)))
|
||||
return pts
|
||||
1
scripts/core/Hex.gd.uid
Normal file
1
scripts/core/Hex.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bv0038ucsvxtk
|
||||
359
scripts/core/PlayerState.gd
Normal file
359
scripts/core/PlayerState.gd
Normal file
@@ -0,0 +1,359 @@
|
||||
class_name PlayerState
|
||||
extends RefCounted
|
||||
|
||||
## Zustand eines Spielers oder Bots: Gold, Leben, Bank, Board, Shop.
|
||||
|
||||
var pid: int = 0
|
||||
var pname: String = "Spieler"
|
||||
var is_bot: bool = false
|
||||
var gold: int = Data.START_GOLD
|
||||
var hp: int = Data.START_HP
|
||||
var streak: int = 0 ## >0 Siegserie, <0 Niederlagenserie
|
||||
var alive: bool = true
|
||||
var last_won: bool = false
|
||||
var placement: int = 0 ## Endplatzierung, 0 = noch im Spiel
|
||||
|
||||
var bench: Array = [] ## Avatar oder null
|
||||
var board: Dictionary = {} ## Vector2i -> Avatar
|
||||
var shop: Array = [] ## ShopEntry
|
||||
## Elemente, die dieser Spieler aufgestellt hat. Nur die zeigt sein Shop.
|
||||
var unlocked: Array[bool] = []
|
||||
|
||||
|
||||
func _init(p_pid: int = 0, p_name: String = "Spieler", p_bot: bool = false) -> void:
|
||||
pid = p_pid
|
||||
pname = p_name
|
||||
is_bot = p_bot
|
||||
for i in Data.BENCH_SLOTS:
|
||||
bench.append(null)
|
||||
for i in Data.ELEMENT_COUNT:
|
||||
unlocked.append(false)
|
||||
|
||||
|
||||
func unlock(element: int) -> void:
|
||||
if element >= 0:
|
||||
unlocked[element] = true
|
||||
|
||||
|
||||
## Anzahl der Elemente, die dieser Spieler aufgestellt hat.
|
||||
func unlocked_count() -> int:
|
||||
var n := 0
|
||||
for e in Data.ELEMENT_COUNT:
|
||||
if unlocked[e]:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
## Elemente, die im eigenen Shop erscheinen können: selbst aufgestellt und
|
||||
## im geteilten Vorrat noch verfügbar.
|
||||
func shop_elements(pool: Pool) -> Array[int]:
|
||||
var out: Array[int] = []
|
||||
for e in Data.ELEMENT_COUNT:
|
||||
if unlocked[e] and pool.element_left[e] > 0:
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
# --- Shop ------------------------------------------------------------------
|
||||
|
||||
func roll_shop(pool: Pool, rng: RandomNumberGenerator) -> void:
|
||||
shop.clear()
|
||||
var active := shop_elements(pool)
|
||||
# Je mehr eigene Elemente aufgestellt sind, desto seltener kommt Neutral.
|
||||
var neutral := Data.neutral_chance(unlocked_count())
|
||||
for i in Data.SHOP_SLOTS:
|
||||
var element := -1
|
||||
if not active.is_empty() and rng.randf() >= neutral:
|
||||
element = active[rng.randi_range(0, active.size() - 1)]
|
||||
shop.append(ShopEntry.new(rng.randi_range(0, 2), element))
|
||||
|
||||
|
||||
func reroll(pool: Pool, rng: RandomNumberGenerator) -> bool:
|
||||
if gold < Data.REROLL_COST:
|
||||
return false
|
||||
gold -= Data.REROLL_COST
|
||||
roll_shop(pool, rng)
|
||||
return true
|
||||
|
||||
|
||||
func free_bench_slot() -> int:
|
||||
for i in bench.size():
|
||||
if bench[i] == null:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
## Kauft einen Avatar. Neutrale brauchen die beim Kauf gewählte Spezialisierung.
|
||||
func buy(index: int, pool: Pool, chosen_element: int = -1) -> bool:
|
||||
if index < 0 or index >= shop.size():
|
||||
return false
|
||||
var entry: ShopEntry = shop[index]
|
||||
if entry.sold or gold < entry.price():
|
||||
return false
|
||||
var element := entry.element if entry.element >= 0 else chosen_element
|
||||
if element < 0 or element >= Data.ELEMENT_COUNT:
|
||||
return false
|
||||
var slot := free_bench_slot()
|
||||
if slot < 0:
|
||||
return false
|
||||
if not pool.take(element):
|
||||
return false
|
||||
gold -= entry.price()
|
||||
bench[slot] = entry.to_avatar(element)
|
||||
entry.sold = true
|
||||
return true
|
||||
|
||||
|
||||
func sell_bench(index: int, pool: Pool) -> bool:
|
||||
if index < 0 or index >= bench.size() or bench[index] == null:
|
||||
return false
|
||||
var a: Avatar = bench[index]
|
||||
pool.return_avatar(a)
|
||||
gold += maxi(1, a.invested)
|
||||
bench[index] = null
|
||||
return true
|
||||
|
||||
|
||||
func sell_board(hex: Vector2i, pool: Pool) -> bool:
|
||||
if not board.has(hex):
|
||||
return false
|
||||
var a: Avatar = board[hex]
|
||||
pool.return_avatar(a)
|
||||
gold += maxi(1, a.invested)
|
||||
board.erase(hex)
|
||||
return true
|
||||
|
||||
|
||||
# --- Aufstellen ------------------------------------------------------------
|
||||
|
||||
func team_slots(round_no: int) -> int:
|
||||
return Data.team_slots(round_no)
|
||||
|
||||
|
||||
func can_place(round_no: int) -> bool:
|
||||
return board.size() < team_slots(round_no)
|
||||
|
||||
|
||||
## Setzt einen Avatar von der Bank aufs Board.
|
||||
func place(bench_index: int, hex: Vector2i, round_no: int) -> bool:
|
||||
if bench_index < 0 or bench_index >= bench.size() or bench[bench_index] == null:
|
||||
return false
|
||||
if board.has(hex) or not _valid_hex(hex):
|
||||
return false
|
||||
if not can_place(round_no):
|
||||
return false
|
||||
var a: Avatar = bench[bench_index]
|
||||
board[hex] = a
|
||||
bench[bench_index] = null
|
||||
unlock(a.elements[0])
|
||||
return true
|
||||
|
||||
|
||||
func unplace(hex: Vector2i) -> bool:
|
||||
if not board.has(hex):
|
||||
return false
|
||||
var slot := free_bench_slot()
|
||||
if slot < 0:
|
||||
return false
|
||||
bench[slot] = board[hex]
|
||||
board.erase(hex)
|
||||
return true
|
||||
|
||||
|
||||
func move_on_board(from_hex: Vector2i, to_hex: Vector2i) -> bool:
|
||||
if not board.has(from_hex) or not _valid_hex(to_hex):
|
||||
return false
|
||||
var a: Avatar = board[from_hex]
|
||||
if board.has(to_hex):
|
||||
board[from_hex] = board[to_hex]
|
||||
else:
|
||||
board.erase(from_hex)
|
||||
board[to_hex] = a
|
||||
return true
|
||||
|
||||
|
||||
## Verschiebt einen Avatar zwischen Bank und Board, in beide Richtungen.
|
||||
## Ist das Ziel belegt, tauschen die beiden Avatare die Plätze.
|
||||
## src und dst sind {"kind": "board"/"bench", "hex": Vector2i, "index": int}.
|
||||
func move_slot(src: Dictionary, dst: Dictionary, round_no: int) -> bool:
|
||||
if src.is_empty() or dst.is_empty() or src == dst:
|
||||
return false
|
||||
var sk: String = src["kind"]
|
||||
var dk: String = dst["kind"]
|
||||
|
||||
if sk == "board" and dk == "board":
|
||||
return move_on_board(src["hex"], dst["hex"])
|
||||
|
||||
if sk == "bench" and dk == "bench":
|
||||
var i: int = src["index"]
|
||||
var j: int = dst["index"]
|
||||
if bench[i] == null:
|
||||
return false
|
||||
var tmp = bench[j]
|
||||
bench[j] = bench[i]
|
||||
bench[i] = tmp
|
||||
return true
|
||||
|
||||
if sk == "board" and dk == "bench":
|
||||
var hex: Vector2i = src["hex"]
|
||||
var j2: int = dst["index"]
|
||||
if not board.has(hex):
|
||||
return false
|
||||
var unit: Avatar = board[hex]
|
||||
if bench[j2] == null:
|
||||
board.erase(hex)
|
||||
else:
|
||||
board[hex] = bench[j2]
|
||||
bench[j2] = unit
|
||||
return true
|
||||
|
||||
# bench -> board
|
||||
var i3: int = src["index"]
|
||||
var hex3: Vector2i = dst["hex"]
|
||||
if bench[i3] == null or not _valid_hex(hex3):
|
||||
return false
|
||||
if not board.has(hex3) and not can_place(round_no):
|
||||
return false
|
||||
var unit3: Avatar = bench[i3]
|
||||
if board.has(hex3):
|
||||
bench[i3] = board[hex3]
|
||||
else:
|
||||
bench[i3] = null
|
||||
board[hex3] = unit3
|
||||
unlock(unit3.elements[0])
|
||||
return true
|
||||
|
||||
|
||||
func _valid_hex(h: Vector2i) -> bool:
|
||||
return h.x >= 0 and h.x < Data.BOARD_COLS and h.y >= 0 and h.y < Data.BOARD_ROWS
|
||||
|
||||
|
||||
# --- Aufwerten -------------------------------------------------------------
|
||||
|
||||
## Wie viel Futter pro Element auf der Bank liegt.
|
||||
func fodder_counts() -> Dictionary:
|
||||
var counts := {}
|
||||
for a in bench:
|
||||
if a == null or a.is_elementless() or a.star != 1:
|
||||
continue
|
||||
var e: int = a.elements[0]
|
||||
counts[e] = counts.get(e, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
func can_upgrade(hex: Vector2i) -> bool:
|
||||
if not board.has(hex):
|
||||
return false
|
||||
var a: Avatar = board[hex]
|
||||
var cost := a.upgrade_cost()
|
||||
if cost.is_empty():
|
||||
return false
|
||||
var have := fodder_counts()
|
||||
for e in cost:
|
||||
if have.get(e, 0) < cost[e]:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Fehlende Futter-Avatare pro Element (für die Anzeige).
|
||||
func upgrade_missing(hex: Vector2i) -> Dictionary:
|
||||
if not board.has(hex):
|
||||
return {}
|
||||
var a: Avatar = board[hex]
|
||||
var cost := a.upgrade_cost()
|
||||
var have := fodder_counts()
|
||||
var miss := {}
|
||||
for e in cost:
|
||||
var m: int = cost[e] - have.get(e, 0)
|
||||
if m > 0:
|
||||
miss[e] = m
|
||||
return miss
|
||||
|
||||
|
||||
func available_new_elements(hex: Vector2i) -> Array[int]:
|
||||
var out: Array[int] = []
|
||||
if not board.has(hex):
|
||||
return out
|
||||
var a: Avatar = board[hex]
|
||||
for e in Data.ELEMENT_COUNT:
|
||||
if not a.has_element(e):
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
func upgrade(hex: Vector2i, new_element: int, pool: Pool) -> bool:
|
||||
if not can_upgrade(hex):
|
||||
return false
|
||||
var a: Avatar = board[hex]
|
||||
if a.has_element(new_element) or new_element < 0 or new_element >= Data.ELEMENT_COUNT:
|
||||
return false
|
||||
var cost := a.upgrade_cost()
|
||||
var spent := 0
|
||||
for e in cost:
|
||||
var need: int = cost[e]
|
||||
for i in bench.size():
|
||||
if need <= 0:
|
||||
break
|
||||
var f = bench[i]
|
||||
if f != null and f.star == 1 and not f.is_elementless() and f.elements[0] == e:
|
||||
spent += f.invested
|
||||
bench[i] = null
|
||||
need -= 1
|
||||
a.invested += spent
|
||||
a.star += 1
|
||||
a.elements.append(new_element)
|
||||
unlock(new_element)
|
||||
return true
|
||||
|
||||
|
||||
# --- Traits ----------------------------------------------------------------
|
||||
|
||||
## Zählt aufgestellte Einheiten pro Element.
|
||||
func trait_counts() -> Dictionary:
|
||||
var counts := {}
|
||||
for hex in board:
|
||||
var a: Avatar = board[hex]
|
||||
for e in a.elements:
|
||||
counts[e] = counts.get(e, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
func active_combos() -> Array:
|
||||
var counts := trait_counts()
|
||||
var out := []
|
||||
for c in Data.COMBO_TRAITS:
|
||||
if counts.get(c["a"], 0) >= Data.COMBO_MIN and counts.get(c["b"], 0) >= Data.COMBO_MIN:
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
# --- Runde -----------------------------------------------------------------
|
||||
|
||||
func round_income() -> int:
|
||||
var g := Data.BASE_INCOME
|
||||
if last_won:
|
||||
g += Data.WIN_GOLD
|
||||
g += Data.streak_gold(streak)
|
||||
g += Data.interest(gold)
|
||||
return g
|
||||
|
||||
|
||||
func register_result(won: bool) -> void:
|
||||
last_won = won
|
||||
if won:
|
||||
streak = maxi(1, streak + 1)
|
||||
else:
|
||||
streak = mini(-1, streak - 1)
|
||||
|
||||
|
||||
## Nur Leben abziehen. Das Ausscheiden setzt Game._check_eliminations,
|
||||
## damit die Platzierung stimmt.
|
||||
func take_damage(dmg: int) -> void:
|
||||
hp = maxi(0, hp - dmg)
|
||||
|
||||
|
||||
func board_units() -> Array:
|
||||
var out := []
|
||||
for hex in board:
|
||||
out.append(board[hex])
|
||||
return out
|
||||
1
scripts/core/PlayerState.gd.uid
Normal file
1
scripts/core/PlayerState.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ddat00fvvoxjv
|
||||
48
scripts/core/Pool.gd
Normal file
48
scripts/core/Pool.gd
Normal file
@@ -0,0 +1,48 @@
|
||||
class_name Pool
|
||||
extends RefCounted
|
||||
|
||||
## Geteilter Avatar-Pool. Alle 8 Spieler kaufen aus demselben Vorrat.
|
||||
## Welche Elemente ein Spieler im Shop sieht, steht in PlayerState.unlocked.
|
||||
|
||||
var element_left: Array[int] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
for i in Data.ELEMENT_COUNT:
|
||||
element_left.append(Data.POOL_PER_ELEMENT)
|
||||
|
||||
|
||||
func available(element: int) -> bool:
|
||||
return element < 0 or element_left[element] > 0
|
||||
|
||||
|
||||
func take(element: int) -> bool:
|
||||
if element < 0:
|
||||
# Der neutrale Standard-Avatar ist unbegrenzt, nur Elemente sind knapp.
|
||||
return true
|
||||
if element_left[element] <= 0:
|
||||
return false
|
||||
element_left[element] -= 1
|
||||
return true
|
||||
|
||||
|
||||
func give_back(element: int, amount: int = 1) -> void:
|
||||
if element < 0:
|
||||
return
|
||||
else:
|
||||
element_left[element] = mini(Data.POOL_PER_ELEMENT, element_left[element] + amount)
|
||||
|
||||
|
||||
## Gibt alle Bausteine eines Avatars zurück in den Pool.
|
||||
func return_avatar(a: Avatar) -> void:
|
||||
if a.is_elementless():
|
||||
give_back(-1)
|
||||
return
|
||||
# Basis-Avatar plus verbrauchtes Futter.
|
||||
give_back(a.elements[0])
|
||||
if a.star >= 2:
|
||||
give_back(a.elements[0], 2)
|
||||
if a.star >= 3:
|
||||
for e in a.elements:
|
||||
give_back(e, 3)
|
||||
# Zusätzlich gewählte Elemente kosten keinen Pool-Slot.
|
||||
1
scripts/core/Pool.gd.uid
Normal file
1
scripts/core/Pool.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dp7fj0pjyyyye
|
||||
32
scripts/core/ShopEntry.gd
Normal file
32
scripts/core/ShopEntry.gd
Normal file
@@ -0,0 +1,32 @@
|
||||
class_name ShopEntry
|
||||
extends RefCounted
|
||||
|
||||
var role: int
|
||||
var element: int = -1 ## -1 = elementlos
|
||||
var sold: bool = false
|
||||
|
||||
|
||||
func _init(p_role: int = 0, p_element: int = -1) -> void:
|
||||
role = p_role
|
||||
element = p_element
|
||||
|
||||
|
||||
func price() -> int:
|
||||
return Data.PRICE_NONE if element < 0 else Data.PRICE_ELEMENT
|
||||
|
||||
|
||||
func color() -> Color:
|
||||
return Data.NONE_COLOR if element < 0 else Data.ELEMENT_COLORS[element]
|
||||
|
||||
|
||||
func label() -> String:
|
||||
var e: String = "Neutral" if element < 0 else Data.ELEMENT_NAMES[element]
|
||||
return e + "\n" + Data.ROLE_NAMES[role]
|
||||
|
||||
|
||||
## Neutrale Avatare bekommen die beim Kauf gewählte Spezialisierung.
|
||||
func to_avatar(chosen_element: int) -> Avatar:
|
||||
var e := element if element >= 0 else chosen_element
|
||||
var a := Avatar.new(role, [e] as Array[int], 1)
|
||||
a.invested = price()
|
||||
return a
|
||||
1
scripts/core/ShopEntry.gd.uid
Normal file
1
scripts/core/ShopEntry.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ch7jrt780kmt8
|
||||
Reference in New Issue
Block a user