Am Zugende legt jeder Gegner fest, was er im naechsten Zug tut; der Kampf-Screen zeigt es unter dem Lebensbalken. Verbindlich statt Vorschau, denn nur so entsteht ein Fenster: Wer dem Gegner zwischen Ansage und Ausfuehrung die Ausdauer nimmt, kostet ihn den Zug. Zielwahl bekommt einen eigenen Cursor, Tab schaltet zwischen Gegner- und Faehigkeitsliste um — dieselbe Bedienung wie im Laden. Die Ziffern springen nur noch aufs Ziel, ausgeloest wird mit Enter.
287 lines
16 KiB
Markdown
287 lines
16 KiB
Markdown
# 14 — Kampfformeln und Rundenablauf
|
||
|
||
Type: grilling
|
||
Status: resolved
|
||
Blocked by: 01
|
||
|
||
## Question
|
||
|
||
Wie läuft ein Kampf ab, und mit welchen Formeln?
|
||
|
||
Der Attributkatalog aus 01 liefert die Zutaten (`damage`, `hit_chance`, `critical_chance`,
|
||
`critical_factor`, `resistance`, `dexterity`, `luck`). Wie sie zusammenwirken, ist offen:
|
||
|
||
- **Reihenfolge**: Was bestimmt, wer zuerst handelt — `dexterity`, ein Initiativwurf, oder immer der
|
||
Spieler zuerst? Ändert sich das während des Kampfes?
|
||
- **Trefferformel**: Wie berechnet sich, ob ein Angriff trifft? Ist `hit_chance` gegen Ausweichen
|
||
gerechnet, oder trifft immer und `dexterity` mindert nur den Schaden?
|
||
- **Schadensformel**: In welcher Reihenfolge greifen Waffe, `damage`-Modifikatoren, Krit und
|
||
`resistance`? Ist Resistenz prozentual oder flach, und gibt es eine Ober- oder Untergrenze?
|
||
- **Kritische Treffer**: Wie speist `luck` in `critical_chance`? Multipliziert `critical_factor` den
|
||
Rohschaden oder den geminderten?
|
||
- **Aktionen pro Zug**: Genau eine, oder gibt es Ausdauer als Budget? Wozu dient `max_stamina` sonst?
|
||
- **Fähigkeiten** (nachgetragen aus Ticket 02): Was kostet eine Fähigkeit — Ausdauer, ein eigener
|
||
Ressourcenpool, Ladungen? Gibt es Abklingzeiten, und werden sie in Zügen gezählt? Wann ist eine
|
||
Fähigkeit gesperrt, und woran sieht der Spieler das? Ticket 02 hat Fähigkeiten als Registry
|
||
festgelegt, ihre Kampfsemantik aber ausdrücklich hierher delegiert.
|
||
- **Flucht**: Möglich, mit welcher Chance, mit welchen Kosten?
|
||
- **Zufall**: Schadensstreuung ja oder nein, und woher kommt der Zufallsgenerator — braucht es einen
|
||
Seed im Speicherstand?
|
||
|
||
Ergebnis: die Formeln als Pseudocode plus der Ablauf eines Zuges von Eingabe bis Ergebnis.
|
||
|
||
## Vorgabe aus Ticket 07
|
||
|
||
Der Aktionskatalog und die Ziel-Auflösung (`self` / `target` / `all_enemies` aus 01) gehören hierher.
|
||
Alles läuft durch `Game::apply(action, source: CreatureRef, target: CreatureRef, reg)`, gelesen wird
|
||
über `Game::attribute(who, attr, reg)`.
|
||
|
||
Eine Frage fällt zusätzlich an, die 07 nur benennen konnte: **Wann werden tote Gegner aufgeräumt?**
|
||
`CreatureRef::Enemy(i)` ist ein Index in `Game.enemies` und überlebt das Entfernen aus der `Vec`
|
||
nicht — eine Aktion, die mitten im Zug einen Gegner tötet, verschiebt die Adressen aller
|
||
nachfolgenden. Der Rundenablauf muss festlegen, ob am Zugende, am Rundenende oder gar nicht
|
||
kompaktiert wird (Alternative: Tote bleiben mit einem Flag stehen).
|
||
|
||
## Answer
|
||
|
||
Begriffe im [Glossar](../../../CONTEXT.md). **Zahlen gehören nicht hierher** — die Map hält Balancing
|
||
getrennt; hier stehen Formeln und Ablauf.
|
||
|
||
### Rundenablauf
|
||
|
||
**Ein Zug = Spielerhandlung plus alle Gegnerreaktionen.** Nicht jede Handlung ein eigener Zug: Bei drei
|
||
Gegnern tickte ein Gift-Debuff sonst viermal pro Runde, und ein Tastendruck erzeugte vier Züge — das
|
||
bräche die Zusage aus 11, die Ticket 12 gerade gemessen hat.
|
||
|
||
Reihenfolge über **Initiative**, einmal beim Betreten des Kampfes aus `dexterity` plus Wurf gewürfelt
|
||
und als `i32` **am Wesen** gehalten. Ausdrücklich *nicht* als Reihenfolgeliste aus `CreatureRef`: Die
|
||
wäre eine zweite Indexliste, die beim Tod eines Gegners genauso ungültig wird wie die erste. Ein
|
||
Zahlenwert am Wesen verschwindet mit ihm, die Sortierung stimmt weiter. Gleichstand geht an den
|
||
Spieler — deterministisch und zu seinen Gunsten.
|
||
|
||
`dexterity` speist damit Initiative *und* Ausweichen. Absicht, aber 15 sollte es beim Balancing wissen.
|
||
|
||
```
|
||
Game::execute(cmd, reg):
|
||
1. turn_started (+ combat_started, falls der Kampf gerade beginnt) in die Warteschlange
|
||
2. befristete Modifikatoren ticken, Ausdauer regeneriert, Abklingzeiten sinken
|
||
3. Teilnehmer handeln in Initiativreihenfolge, Spieler-Command an seinem Platz
|
||
-> Abbruch, sobald der Held tot ist
|
||
4. nach JEDER einzelnen Handlung die Warteschlange leerarbeiten (bis leer oder 100)
|
||
5. retain auf tote Gegner, danach ggf. combat_ended
|
||
6. turn += 1
|
||
```
|
||
|
||
Schritt 4 nach jeder Handlung statt einmal am Zugende: Sonst schlägt ein Gegner zu, den ein
|
||
Dornenpanzer eine Handlung zuvor getötet hat. Schritt 5 erst **nach** dem Leeren: Sonst zeigt ein
|
||
Trigger auf `creature_died` auf einen bereits entfernten Gegner. Innerhalb eines Zuges müssen Indizes
|
||
stabil bleiben — über Zuggrenzen hinweg hält ohnehin niemand ein `CreatureRef`, die UI liest neu.
|
||
|
||
Genau **eine** Handlung pro Zug, kein Ausdauer-Budget. `max_stamina` ist die Ressource für
|
||
Fähigkeiten — das beantwortet die Frage „wozu sonst" aus dem Ticketkörper.
|
||
|
||
Stirbt der Held mitten im Zug, bricht der Zug ab (laufende Handlung samt Warteschlange noch zu Ende).
|
||
Zwei weitere Gegner, die auf eine Leiche einschlagen, erzeugen nur Kampflog und Trigger auf ein Wesen,
|
||
das die Runde nicht mehr erlebt.
|
||
|
||
### Formeln
|
||
|
||
```
|
||
p_treffer = clamp(hit_chance(A, gegen=B) - dexterity(B) * AUSWEICH, 0.05, 0.95)
|
||
|
||
roh = attribute(damage, A, gegen=B) # 01-Stapelung, bedingte Modifikatoren inklusive
|
||
krit = rand() < clamp(critical_chance(A), 0.0, 1.0)
|
||
roh = roh * critical_factor(A) falls krit
|
||
r = clamp(resistance(B, typ), -1.0, 0.8)
|
||
final = max(1, round(roh * (1.0 - r)))
|
||
|
||
p_flucht = clamp(0.5 + (dexterity(Held) - dexterity(schnellster Gegner)) * FLUCHT, 0.1, 0.9)
|
||
```
|
||
|
||
**Die Frage aus dem Ticketkörper löst sich auf.** Ob `critical_factor` den Rohschaden oder den
|
||
geminderten multipliziert, ist mathematisch belanglos: `roh × kf × (1−r)` ist dasselbe wie
|
||
`roh × (1−r) × kf`. Festzulegen war nur, dass beide multiplikativ *bleiben*.
|
||
|
||
**Befund aus M3: die Trefferformel war toter Code.** `hit_chance` existierte, war getestet — und wurde
|
||
nie aufgerufen. **Jeder Angriff traf.** Gemeldet hat es nur der Compiler
|
||
(`warning: function 'hit_chance' is never used`); kein Test schlug an, weil alle Tests die Formel
|
||
*direkt* prüften statt über einen Angriff.
|
||
|
||
Lehre für die Formeln unten: Eine Formel, die nur von ihrem eigenen Unit-Test aufgerufen wird, ist
|
||
nicht verdrahtet. Der Test, der das jetzt abfängt, führt 200 Angriffe gegen einen absurd flinken
|
||
Gegner und verlangt, dass mindestens einer verfehlt *und* mindestens einer trifft.
|
||
|
||
Drei Klemmbereiche, jeder mit Grund:
|
||
|
||
- **Resistenz bei `0.8`** — bei `1.0` wäre ein Wesen immun, und Immunität ist keine Zahl, sondern ein
|
||
Spielabbruch. Nach unten bis `-1.0`, damit Verwundbarkeit ausdrückbar bleibt („Untote nehmen
|
||
doppelten heiligen Schaden").
|
||
- **`max(1, …)`** — ein Treffer macht nie null, sonst gibt es Kämpfe, die rechnerisch nie enden.
|
||
- **`0.05 / 0.95` beim Treffen** — sonst wird ein hochgezüchteter Verteidiger unangreifbar und ein
|
||
hochgezüchteter Angreifer unfehlbar. Beides Sackgassen, die man erst auf Etage 5 bemerkt.
|
||
|
||
Keine Schadensstreuung für 1.0: Der Krit liefert die Varianz bereits, Streuung wäre ein zweiter
|
||
Zufallsknopf ohne eigene Entscheidung dahinter.
|
||
|
||
### Zufall
|
||
|
||
Eigener xorshift64* — acht Zeilen statt einer Dependency. Der Seed liegt als Feld in `Game` und damit
|
||
**im Speicherstand**.
|
||
|
||
Das ist keine Implementierungsfrage, sondern eine Spielentscheidung: **Nachladen würfelt nicht neu.**
|
||
Derselbe Angriff führt nach dem Laden zum selben Ergebnis, Save-Scumming läuft ins Leere. Ein
|
||
Abstiegs-Roguelike lebt von Entscheidungen mit Konsequenzen.
|
||
|
||
### Fähigkeiten — die Bestellung aus Ticket 02
|
||
|
||
```toml
|
||
[[abilities]]
|
||
id = "core:power_strike"
|
||
name = "Wuchtschlag"
|
||
symbol = "!"
|
||
color = "red"
|
||
cost = 15 # Ausdauer
|
||
cooldown = 3 # Zuege
|
||
condition = [ … ] # optional, Grammatik aus 08
|
||
actions = [ { action = "damage", scale = 1.5, damage_type = "blunt", target = "target" } ]
|
||
```
|
||
|
||
**Nur Ausdauer, kein zweiter Ressourcenpool.** Mana wäre ein zweites Attribut, eine zweite
|
||
Regeneration, eine zweite Anzeige und eine zweite Sperrregel. Die Decke: Ein Mod, der Mana will,
|
||
bräuchte ein Engine-Attribut — ein Nachtrag an 01, kein Problem, das ein Modder selbst lösen kann.
|
||
|
||
**Abklingzeit in Zügen**, als `cooldowns: HashMap<AbilityId, u32>` am `Creature`. Sie als befristete
|
||
Modifikatoren zu tarnen wäre clever und genau die Sorte Cleverness, die man um drei Uhr nachts
|
||
entziffert.
|
||
|
||
**Gesperrt heißt**: Ausdauer < `cost`, oder `cooldown > 0`, oder `condition` unerfüllt. Auslösen ergibt
|
||
dann `Outcome::Rejected("Nicht genug Ausdauer")` aus Ticket 12 und **kostet keinen Zug**. Die Regel
|
||
steht im Spielkern, nicht in der UI — sonst kennt ein Script sie nicht.
|
||
|
||
**Ausdauer regeneriert pro Zug** um einen festen Betrag. Das gibt `Wait` aus Ticket 11 seinen Zweck und
|
||
macht Ausdauer zu einer Entscheidung statt zu einem Countdown.
|
||
|
||
Eine misslungene Flucht ist übrigens `TurnPassed`, keine Zurückweisung: Der Versuch hat stattgefunden,
|
||
die Gegner haben gehandelt. `Rejected` bleibt dem Fall vorbehalten, dass gar nichts passiert ist.
|
||
|
||
### Gegner — die achte Registry
|
||
|
||
Beim Aufschreiben der KI fiel eine Lücke auf, die seit 02 offen lag: **Wesen stehen in keiner
|
||
Registry.** 02 hatte fünf, 07 ergänzte Affixe, 08 Quests — woher Gegnerdefinitionen kommen, hat nie
|
||
jemand gesagt, obwohl 10 sie in Räume setzen und 16 Beute an sie hängen muss.
|
||
|
||
```toml
|
||
[[creatures]]
|
||
id = "core:skeleton"
|
||
name = "Skelett"
|
||
symbol = "s"
|
||
color = "white"
|
||
tags = ["core:undead"]
|
||
base = { max_health = 30, damage = 6, hit_chance = 0.7, dexterity = 3 }
|
||
abilities = ["core:attack", "core:bone_throw"]
|
||
ai = [
|
||
{ condition = [ { check = "attribute", who = "self", attribute = "health", below = 0.3 } ],
|
||
ability = "core:flee" },
|
||
{ ability = "core:attack" }, # ohne condition = greift immer
|
||
]
|
||
```
|
||
|
||
`ai` ist eine **geordnete Liste**, der erste passende *und verfügbare* Eintrag gewinnt — dieselbe
|
||
Bedingungsgrammatik wie überall sonst, kein neues Vokabular. Passt nichts, **setzt der Gegner aus**;
|
||
einer, der die Runde blockiert, wäre schlimmer als einer, der nichts tut.
|
||
|
||
Eine `ai`-Liste **ohne bedingungslosen letzten Eintrag** gibt eine Ladewarnung über die Maschinerie aus
|
||
04. Das ist fast immer ein Versehen, und zur Laufzeit erkennt man es nur daran, dass ein Gegner
|
||
gelegentlich stillsteht — die teuerste Sorte Fehler.
|
||
|
||
Boss-Phasen sind damit *nicht* gelöst, aber vorbereitet: Die Liste trägt sie, was fehlt, ist der
|
||
Phasenbegriff.
|
||
|
||
### Balance als Daten — ausdrückliche Ausnahme zu Ticket 03
|
||
|
||
Fünf Stellknöpfe entstehen hier: `AUSWEICH`, `FLUCHT`, Ausdauer-Regeneration, Resistenz-Deckel und
|
||
Treffer-Klemmbereich. Sie liegen als `[balance]`-Tabelle in TOML und verschmelzen **feldweise** über
|
||
die Ladereihenfolge.
|
||
|
||
Das widerspricht der Ganz-oder-gar-nicht-Regel aus 03, und zwar bewusst: Ein Mod, der nur `AUSWEICH`
|
||
dreht, müsste sonst alle fünf Werte abschreiben, und ein sechster Knopf in der nächsten Engine-Version
|
||
bräche ihn stillschweigend.
|
||
|
||
Die Ausnahme bleibt eng, weil ihre Begründung eng ist: **`[balance]` hat ein festes, der Engine
|
||
bekanntes Schema.** Genau das fehlte bei Inhalt mit offenen Feldern — Modifikator- und Aktionslisten
|
||
bräuchten einen Merge-Algorithmus, und das war 03s Argument gegen feldweises Patchen. Hier greift es
|
||
nicht.
|
||
|
||
Standardwerte liegen in der Engine, nicht in `core`. Dann ist ein Mod, der einen Knopf dreht, eine
|
||
Zeile lang.
|
||
|
||
**Nachtrag aus Ticket 15:** Zwei weitere Knöpfe kommen dazu — `exp_base` und `exp_exponent`, die die
|
||
Erfahrungskurve bilden. Eine Tabelle je Level wäre die Alternative gewesen; zwei Zahlen schlagen
|
||
fünfzig Zeilen. Dazu bekommt die `creatures`-Registry ein Feld `exp_reward`.
|
||
|
||
Ausdrücklich **nicht** dazugekommen ist `points_per_level`: Ein Aufstieg gibt genau einen Punkt, das
|
||
ist eine Regel und kein Stellknopf.
|
||
|
||
**Nachtrag aus Ticket 16:** `LUCK_FACTOR` kommt dazu — `luck` gewährt eine Chance auf einen
|
||
zusätzlichen Zug auf jeder Beutetabelle. Damit hat `[balance]` acht Felder.
|
||
|
||
### Vier Nachträge an geschlossene Tickets
|
||
|
||
| Ticket | Nachtrag |
|
||
|---|---|
|
||
| **01** | `damage`-Aktion: `menge = amount + scale * damage-Attribut(Verursacher)`, beide Felder optional (Standard 0). Deckt Basisangriff (`scale = 1.0`), Feuerball (`amount = 20`) und Wuchtschlag (`scale = 1.5`) mit *einer* Formel. Dazu `flee` als **achte Aktion** |
|
||
| **07** | `initiative: i32` und `cooldowns: HashMap<AbilityId, u32>` am `Creature`; RNG-Seed in `Game`; **`enemies` ist der laufende Kampf**, nicht die Etage |
|
||
| **08** | `combat_started` und `combat_ended { outcome: Victory \| Fled }` — die Eventliste wächst auf **elf**. Der Tod des Helden braucht keine dritte Variante, `creature_died` mit `source = Player` deckt ihn ab |
|
||
| **11** | **`GameOver`** als elfter Screen, Vollbild. Bewusst nicht die lazy Variante mit einer Meldungszeile im Hauptmenü: Ein Abstiegs-Roguelike lebt davon, dass der Lauf am Ende erzählt wird — Etage, Zug, Gold, Todesursache. *Ticket 10 hat daraus `GameOver { outcome: Died \| Won }` gemacht, weil der Abstieg von der letzten Etage den Lauf gewinnt* |
|
||
|
||
### Auflagen an andere Tickets
|
||
|
||
- **10 Raum-Prefab-Format**: füllt `Game.enemies`; Kampfbeginn ist ein Prefab-Ereignis.
|
||
- **15 Progression**: was bei Sieg passiert (EXP), und die Zahlen hinter allen Formeln inklusive der
|
||
fünf `[balance]`-Knöpfe.
|
||
- **16 Loot-Tabellen**: Beute hängt an der `creatures`-Registry, die hier entstanden ist.
|
||
|
||
### Befund aus M4 — die Initiativreihenfolge ist ein Schnappschuss
|
||
|
||
Der Zugablauf legt die Reihenfolge **einmal vor dem Zug** fest und arbeitet sie danach ab. Das war
|
||
richtig gedacht — sonst könnte ein Gegner sich durch Handeln in der Reihenfolge nach vorn schieben.
|
||
Nur passt es nicht zu einer Zusage aus demselben Ticket: **Flucht beendet den Kampf sofort**, leert
|
||
also `enemies` mitten in der Schleife. Die Reihenfolge zeigt danach auf Indizes, die es nicht mehr
|
||
gibt.
|
||
|
||
Im Spiel sichtbar als Absturz nach mehrfachem Fluchtversuch — die Flucht muss glücken, *bevor* ein
|
||
Gegner in der Reihenfolge an der Reihe war, sonst fällt es nicht auf. Kein Test hatte den Fall, weil
|
||
alle Kampftests die Flucht am Ende der Reihenfolge auslösten.
|
||
|
||
Entschieden: Ein leerer `enemies` **bricht den Zug ab**, statt still übersprungen zu werden. Kein
|
||
Gegner mehr da heißt, es gibt nichts mehr zu handeln — ein `continue` würde denselben Absturz nur
|
||
verschieben, wenn später jemand die Liste wieder füllt.
|
||
|
||
Damit ist die Regel dieses Tickets vollständig: Die Reihenfolge steht vor dem Zug fest, *und* sie
|
||
endet, sobald ihre Grundlage weg ist.
|
||
|
||
### Nachtrag — die Ansage (Taktik-Ebene, nach M4)
|
||
|
||
Der Zugablauf bekommt einen **siebten Schritt**: Am Ende jedes Zuges legt jeder lebende Gegner fest,
|
||
was er im nächsten tun wird. Ausgewertet wird dieselbe `ai`-Liste nach derselben Regel — erster
|
||
passender *und verfügbarer* Eintrag —, nur eine Runde früher. Das Ergebnis steht als `intent` am
|
||
`Creature` und wird im Kampf-Screen unter dem Lebensbalken angezeigt.
|
||
|
||
**Verbindlich, nicht als Vorschau.** Der Gegner führt genau das aus, was dasteht. Die Alternative wäre
|
||
gewesen, die `ai`-Liste bei jedem Bild neu auszuwerten und das Ergebnis als Vermutung anzuzeigen — das
|
||
hätte keinen Zustand gekostet, aber auch keine Taktik erzeugt: Eine Anzeige, die sich mit der Lage
|
||
mitdreht, sagt nur, was *jetzt* gälte, und ist genau dann falsch, wenn der Spieler etwas unternimmt.
|
||
|
||
Der Gewinn liegt im **Fenster zwischen Ansage und Ausführung**. Vor dem Ausführen wird `ability_block`
|
||
erneut geprüft; schlägt es an, verliert der Gegner den Zug (`„X kommt nicht dazu — Nicht genug
|
||
Ausdauer."`). Damit wird jede Wirkung auf Ausdauer, Abklingzeit oder Bedingung zu einem *Konter*, und
|
||
die Sperrgründe, die Ticket 14 ohnehin schon berechnet, bekommen einen zweiten Nutzen.
|
||
|
||
Eine Ansage wird beim Ausführen verbraucht (`take`). Wer ohne Ansage an die Reihe kommt, tut nichts —
|
||
das trifft nur Gegner, die mitten im Zug auftauchen, und die stehen ohnehin nicht in der
|
||
Initiativreihenfolge dieses Zuges. **Ein** Ansagepunkt, kein zweiter beim Kampfbeginn.
|
||
|
||
Ausdrücklich **nicht** dazugekommen ist ein Ziel an der Ansage: Die `ai` greift ausschließlich den
|
||
Helden an, `intent` trägt deshalb nur die Fähigkeits-ID. Ein Zielfeld käme mit dem ersten Gegner, der
|
||
seine Verbündeten heilt.
|