class_name Profile extends RefCounted ## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen. const SAVE_VERSION := 5 const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert const SAVE_PATH := "user://spielstand.json" const BOOSTER_COST := 100 const BOOSTER_SIZE := 5 const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} const START_COINS := 150 const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} const REWARD_LOSS := 50 const DECK_SLOTS := 5 # Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub. const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} const ACHIEVEMENTS := [ {"id": "erster_sieg", "name": "Erster Sieg", "text": "Gewinne einen Kampf.", "reward": 50}, {"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50}, {"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100}, {"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200}, {"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100}, {"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500}, {"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100}, {"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100}, {"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100}, {"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300}, {"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500}, {"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150}, {"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100}, ] const DAILY_TASKS := [ {"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80}, {"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60}, {"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40}, {"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50}, {"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50}, {"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60}, {"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50}, {"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120}, {"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90}, {"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90}, {"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90}, {"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90}, ] static var today_override := "" # für Tests var coins := 0 var dust := 0 var collection := {} # id -> Anzahl var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids} var active_deck := 0 var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}} var achievements: Array = [] var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var daily := {"date": "", "task": "", "progress": 0, "done": false} var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"} var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe) var card_stats := {} # id -> {"played", "games", "wins"} var booster_since_mythic := 0 var tutorial_done := false var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) var records := {"streak": 0, "best_streak": 0, "fastest_win": 0} var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung ## Das aktive Deck (Kartenliste des gewählten Slots). var deck: Array: get: return decks[active_deck].cards set(value): decks[active_deck].cards = value func _init() -> void: for i in DECK_SLOTS: decks.append({"name": "Deck %d" % (i + 1), "cards": []}) static func starter() -> Profile: var p := Profile.new() p.coins = START_COINS for c in Cards.of_rarity("gewöhnlich"): if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"): p.collection[c.id] = 2 var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", "e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"] for cid in starter_ids: p.deck.append(cid) p.deck.append(cid) return p ## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil. static func load_or_new(path: String = SAVE_PATH) -> Profile: for candidate in [path, path + ".bak"]: if FileAccess.file_exists(candidate): var json := JSON.new() if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary: return from_dict(json.data) if FileAccess.file_exists(path): DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt") var p := starter() p.save(path) return p ## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration). static func from_dict(d: Dictionary) -> Profile: var p := Profile.new() var version := int(d.get("version", 1)) p.coins = int(d.get("coins", 0)) p.dust = int(d.get("dust", 0)) var coll = d.get("collection", {}) for cid in coll: if Cards.has_id(cid): p.collection[cid] = int(coll[cid]) if version >= 3: var list = d.get("decks", []) for i in mini(list.size(), DECK_SLOTS): p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1))) p.decks[i].cards = [] for cid in list[i].get("cards", []): if Cards.has_id(cid): p.decks[i].cards.append(cid) p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1) else: for cid in d.get("deck", []): if Cards.has_id(cid): p.decks[0].cards.append(cid) if version >= 2: var st = d.get("stats", {}) p.stats.games = int(st.get("games", 0)) p.stats.wins = int(st.get("wins", 0)) p.stats.boosters = int(st.get("boosters", 0)) for k in st.get("wins_by_opp", {}): p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k]) for a in d.get("achievements", []): p.achievements.append(str(a)) p.campaign_stage = int(d.get("campaign_stage", 0)) var dl = d.get("daily", {}) p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")), "progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))} var se = d.get("settings", {}) p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), "language": str(se.get("language", "de"))} if version >= 4: for cid in d.get("card_stats", {}): if Cards.has_id(cid): var st = d.card_stats[cid] p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))} p.booster_since_mythic = int(d.get("booster_since_mythic", 0)) p.tutorial_done = bool(d.get("tutorial_done", false)) if version >= 5: p.perk = str(d.get("perk", "")) if not Game.PERKS.has(p.perk): p.perk = "" var rc = d.get("records", {}) p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), "fastest_win": int(rc.get("fastest_win", 0))} p.challenge_week = int(d.get("challenge_week", -1)) return p func to_dict() -> Dictionary: return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "decks": decks, "active_deck": active_deck, "stats": stats, "achievements": achievements, "campaign_stage": campaign_stage, "daily": daily, "settings": settings, "card_stats": card_stats, "booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done, "perk": perk, "records": records, "challenge_week": challenge_week} ## Speichert und sichert den vorherigen Stand als .bak. func save(path: String = SAVE_PATH) -> void: var abs := ProjectSettings.globalize_path(path) if FileAccess.file_exists(path): DirAccess.copy_absolute(abs, abs + ".bak") var f := FileAccess.open(path, FileAccess.WRITE) f.store_string(JSON.stringify(to_dict(), "\t")) f.close() func take_notices() -> Array[String]: var out := notices.duplicate() notices.clear() return out # --- Decks und Sammlung ----------------------------------------------------------- func deck_cards(slot: int = -1) -> Array: var ids: Array = decks[slot if slot >= 0 else active_deck].cards var out := [] for cid in ids: out.append(Cards.by_id(cid)) return out func deck_valid(slot: int = -1) -> Array: return Game.validate_deck(deck_cards(slot), collection) func deck_name(slot: int = -1) -> String: return decks[slot if slot >= 0 else active_deck].name func rename_deck(slot: int, name: String) -> String: var n := name.strip_edges() if n.is_empty() or n.length() > 20: return "Name muss 1 bis 20 Zeichen haben" decks[slot].name = n return "" ## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve. ## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt. func autofill_deck(slot: int) -> String: var cards: Array = decks[slot].cards var elems := Game.deck_elements(deck_cards(slot)) if elems.is_empty(): var count := {} for c in owned_cards(): for e in c.elements: count[e] = count.get(e, 0) + 1 var ranked: Array = count.keys() ranked.sort_custom(func(a, b): return count[a] > count[b]) elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS) var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems)) pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity)) var counts := {} for cid in cards: counts[cid] = counts.get(cid, 0) + 1 var progressed := true while cards.size() < Game.DECK_SIZE and progressed: progressed = false for cost in range(1, 11): if cards.size() >= Game.DECK_SIZE: break for c in pool: if c.cost != cost: continue var have: int = counts.get(c.id, 0) if have < mini(collection.get(c.id, 0), Game.MAX_COPIES): cards.append(c.id) counts[c.id] = have + 1 progressed = true break var ok := Game.validate_deck(deck_cards(slot), collection) return "" if ok[0] else ok[1] ## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie). func top_cards(n: int) -> Array: var rows := [] for cid in card_stats: var st: Dictionary = card_stats[cid] if st.games > 0: rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played, "rate": float(st.wins) / st.games}) rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate)) return rows.slice(0, n) func owned_cards() -> Array: return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0) func distinct_owned() -> int: var n := 0 for cid in collection: if collection[cid] > 0: n += 1 return n # --- Werkstatt -------------------------------------------------------------------- ## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung. func dismantle(cid: String, n: int = 1) -> String: var owned: int = collection.get(cid, 0) if owned < n: return "Nicht genug Kopien" var needed := 0 for d in decks: needed = maxi(needed, d.cards.count(cid)) if owned - n < needed: return "Wird in einem Deck gebraucht" collection[cid] = owned - n dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n return "" ## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung. func craft(cid: String) -> String: var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity] if dust < cost: return "Nicht genug Staub (%d nötig)" % cost dust -= cost collection[cid] = collection.get(cid, 0) + 1 daily_event("craft") check_achievements() return "" ## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück. func dismantle_extras() -> int: var before := dust for cid in collection.keys(): var extra: int = collection[cid] - Game.MAX_COPIES if extra > 0: dismantle(cid, extra) return dust - before # --- Booster ---------------------------------------------------------------------- static func roll_booster(r: RandomNumberGenerator) -> Array: var rarities := RARITY_WEIGHTS.keys() var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var cards := [] for i in BOOSTER_SIZE: var rarity: String = rarities[r.rand_weighted(weights)] if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ and cards.all(func(c): return c.rarity == "gewöhnlich"): rarity = "selten" # mindestens eine seltene Karte pro Booster var pool := Cards.of_rarity(rarity) cards.append(pool[r.randi_range(0, pool.size() - 1)]) return cards ## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen. func open_booster(r: RandomNumberGenerator = null) -> Array: if coins < BOOSTER_COST: return [] if r == null: r = Game.make_rng() coins -= BOOSTER_COST var cards := roll_booster(r) booster_since_mythic += 1 var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: var pool := Cards.of_rarity("mythisch") cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] has_mythic = true if has_mythic: booster_since_mythic = 0 for c in cards: collection[c.id] = collection.get(c.id, 0) + 1 stats.boosters += 1 daily_event("booster") check_achievements() return cards # --- Kampfergebnis, Erfolge, Tagesaufgabe ------------------------------------------- ## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche. func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [], turns: int = 0, challenge: Dictionary = {}) -> int: stats.games += 1 if won: records.streak += 1 records.best_streak = maxi(records.best_streak, records.streak) if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win): records.fastest_win = turns else: records.streak = 0 for cid in played_ids: _stat(cid).played += 1 var seen := {} for c in deck_used: if not seen.has(c.id): seen[c.id] = true _stat(c.id).games += 1 if won: _stat(c.id).wins += 1 var reward := REWARD_LOSS if won: stats.wins += 1 var key := str(n_opp) stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1 reward = REWARD_WIN[n_opp] if stage >= 0 and stage == campaign_stage: campaign_stage += 1 reward = Campaign.stage(stage).reward notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name) if not challenge.is_empty() and challenge_week != Challenge.week(): challenge_week = Challenge.week() reward = challenge.reward notices.append("Herausforderung bestanden: %s (+%d Münzen)" % [challenge.name, challenge.reward]) coins += reward daily_event("game") if won: daily_event("win") if n_opp >= 3: daily_event("win_vs3") for e in Game.deck_elements(deck_used): daily_event("win_element:" + e) check_achievements({"won": won, "n_opp": n_opp, "deck": deck_used}) return reward func _stat(cid: String) -> Dictionary: if not card_stats.has(cid): card_stats[cid] = {"played": 0, "games": 0, "wins": 0} return card_stats[cid] func check_achievements(ctx: Dictionary = {}) -> Array: var unlocked := [] for a in ACHIEVEMENTS: if a.id in achievements: continue if _achieved(a.id, ctx): achievements.append(a.id) coins += a.reward unlocked.append(a) notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward]) return unlocked func _achieved(id: String, ctx: Dictionary) -> bool: var won: bool = ctx.get("won", false) var n: int = ctx.get("n_opp", 0) var deck_used: Array = ctx.get("deck", []) match id: "erster_sieg": return stats.wins >= 1 "duellant": return won and n == 1 "tischherr": return won and n >= 3 "chaosmeister": return won and n == 5 "sammler": return distinct_owned() >= 40 "vollstaendig": return distinct_owned() >= Cards.all().size() "boosterfreund": return stats.boosters >= 10 "elementarist": return won and Game.deck_elements(deck_used).size() == 1 "kombokoenig": return won and deck_used.filter(func(c): return c.is_combo()).size() >= 6 "kampagnenheld": return campaign_stage >= Campaign.STAGES.size() "elementmeister": return campaign_stage >= Campaign.total() "serie": return records.best_streak >= 5 "wochenheld": return challenge_week >= 0 return false func perk_unlocked() -> bool: return campaign_stage >= Campaign.STAGES.size() ## Schreibt Statistik als CSV (Kartenwerte und Gesamtzahlen), z. B. für Spieltests. func export_stats(path: String) -> String: var f := FileAccess.open(path, FileAccess.WRITE) if f == null: return "Datei nicht schreibbar" f.store_line("karte;seltenheit;gespielt;partien;siege") for cid in card_stats: var st: Dictionary = card_stats[cid] var c := Cards.by_id(cid) f.store_line("%s;%s;%d;%d;%d" % [c.name, c.rarity, st.played, st.games, st.wins]) f.store_line("") f.store_line("kaempfe;%d" % stats.games) f.store_line("siege;%d" % stats.wins) f.store_line("booster;%d" % stats.boosters) f.store_line("beste_serie;%d" % records.best_streak) f.store_line("schnellster_sieg;%d" % records.fastest_win) f.close() return "" static func today() -> String: return today_override if today_override != "" else Time.get_date_string_from_system() ## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat. func refresh_daily() -> void: var t := today() if daily.date != t: var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400) var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()] daily = {"date": t, "task": task.id, "progress": 0, "done": false} func daily_task() -> Dictionary: refresh_daily() for t in DAILY_TASKS: if t.id == daily.task: return t return DAILY_TASKS[0] ## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück. func daily_event(kind: String, n: int = 1) -> int: var task := daily_task() if daily.done or task.kind != kind: return 0 daily.progress += n if daily.progress >= task.goal: daily.done = true coins += task.reward notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward]) return task.reward return 0