docs: ticket 14 (kampfformeln) aufloesen, nachtraege an 01 07 08 11, registry-liste und initiative ins glossar
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 14 — Kampfformeln und Rundenablauf
|
||||
|
||||
Type: grilling
|
||||
Status: open
|
||||
Status: resolved
|
||||
Blocked by: 01
|
||||
|
||||
## Question
|
||||
@@ -41,3 +41,183 @@ Eine Frage fällt zusätzlich an, die 07 nur benennen konnte: **Wann werden tote
|
||||
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*.
|
||||
|
||||
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.
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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.
|
||||
|
||||
Reference in New Issue
Block a user