docs: add ADR-0004 spatial grid, fix Fraction stat mode, extend spec
This commit is contained in:
@@ -4,6 +4,18 @@
|
||||
- **Datum:** 2026-08-10
|
||||
- **Betrifft:** Turm-Stats, Workshop, Cards, In-Run-Upgrades, Ultimate; Datentyp aller Stat-Werte
|
||||
- **Baut auf:** ADR-0001 (Balancing-Daten als Rust-Konstanten hinter `Balance`)
|
||||
- **Nachträge:**
|
||||
- **2026-08-10 — §3 um vier Utility-Stats ergänzt.** Auslöser: Widerspruch 5 der
|
||||
Vertical-Slice-Spec (`docs/spec-vertical-slice.md`) — der Utility-Tab des In-Run-Shops hatte mit
|
||||
`CoinGain` genau **einen** Stat, und die Spec musste entweder den Tab oder diese ADR für
|
||||
unvollständig erklären.
|
||||
- **2026-08-10 — §1 korrigiert: dritter Anwendungsmodus `Fraction`.** Auslöser: Beim Einordnen des
|
||||
neuen `FreeUpgradeChance` fiel auf, dass die `Direct`-Formel jeden Stat mit `base = 0` und
|
||||
`points` als einziger Quelle auf **null** rechnet. Betroffen waren `CritChance` und
|
||||
`DefensePercent` — **ein Defekt, kein Beobachtungsposten**: Crit-Chance hätte im Spiel nie über
|
||||
0 % steigen können. §1 und §2 sind neu gefasst, in §3 hat sich für diese beiden Zeilen die
|
||||
**Modus-Spalte** geändert (sonst nichts), und das durchgerechnete Beispiel hat einen
|
||||
Prozent-Stat bekommen, damit der Fall belegt ist.
|
||||
|
||||
## Kontext
|
||||
|
||||
@@ -33,6 +45,9 @@ Run-Grenze.
|
||||
|
||||
### 1. Drei Akkumulatoren, ein Anwendungsmodus pro Stat
|
||||
|
||||
> **Korrigiert am 2026-08-10.** Die erste Fassung kannte zwei Modi und rechnete die Prozent-Stats
|
||||
> auf null. Der Befund und die Korrektur stehen am Ende dieses Punktes.
|
||||
|
||||
Jeder Stat sammelt drei Akkumulatoren:
|
||||
|
||||
| Akkumulator | Einheit | Verknüpfung |
|
||||
@@ -41,26 +56,84 @@ Jeder Stat sammelt drei Akkumulatoren:
|
||||
| `points` | Prozentpunkte (0,15 = +15 pp) | additiv, **einmal am Ende** geclamped |
|
||||
| `mult` | Faktor | multiplikativ |
|
||||
|
||||
Jeder Stat trägt zusätzlich **ein Bit**, seinen Anwendungsmodus:
|
||||
Jeder Stat trägt zusätzlich **ein Feld**, seinen Anwendungsmodus. Es hat drei Varianten, und die
|
||||
Frage, die es beantwortet, ist immer dieselbe: **Was tun Prozentpunkte mit diesem Stat?**
|
||||
|
||||
- **`Direct`** — der Wert wächst mit dem Bonus:
|
||||
- **`Direct`** — der Stat ist eine **Größe**; `points` skalieren sie nach oben:
|
||||
`(base + flat) × (1 + clamp(points)) × mult`
|
||||
- **`Interval`** — der Wert ist eine Zeitspanne in Sekunden und **schrumpft** mit dem Bonus:
|
||||
- **`Interval`** — der Stat ist eine **Zeitspanne** in Sekunden; `points` skalieren sie nach unten:
|
||||
`(base + flat) × (1 − clamp(points)) ÷ mult`
|
||||
- **`Fraction`** — der Stat **ist selbst ein Anteil** (Chance, Prozentsatz); `points` skalieren
|
||||
nichts, sie **sind** der Wert:
|
||||
`clamp((base + flat + points) × mult)`
|
||||
|
||||
Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Damit gibt es drei
|
||||
Akkumulatoren und vier Anwendungsregeln, ohne dass irgendwo eine fünfte Regel entsteht.
|
||||
Der Modus ist eine Eigenschaft des Stats, kein Sonderfall des Modifikators. Es bleibt bei **einem**
|
||||
Feld und drei Akkumulatoren; hinzugekommen ist eine dritte Variante desselben Feldes, nicht ein
|
||||
zweites Bit und kein vierter Akkumulator.
|
||||
|
||||
Zeitbasierte Stats bleiben **Intervalle, keine Raten**: Der Spieler liest „12 s Cooldown", nicht
|
||||
„0,083 Auslösungen/s". Eine Cooldown-Reduktion ist deshalb kein eigener Akkumulator, sondern
|
||||
`points` im `Interval`-Modus.
|
||||
|
||||
Bei einem `Fraction`-Stat tragen `base`, `flat` und `points` **dieselbe Einheit** — Prozentpunkte —,
|
||||
weil der Wert des Stats aus nichts anderem besteht. Deshalb werden sie addiert und nicht
|
||||
gegeneinander skaliert, und deshalb greift der Clamp auf das Ergebnis: Bei einem `Fraction`-Stat
|
||||
*ist* das Ergebnis die Prozentpunkt-Summe. `mult` steht innerhalb des Clamps, damit die harte Wand
|
||||
aus Punkt 2 nicht durch einen Faktor umgangen werden kann.
|
||||
|
||||
#### Warum es einen dritten Modus gibt und keine billigere Lösung
|
||||
|
||||
Die erste Fassung dieser ADR kannte nur `Direct` und `Interval`. Damit rechneten `CritChance` und
|
||||
`DefensePercent` **auf null**: Beide haben `base = 0` und beziehen ihre Wirkung ausschließlich aus
|
||||
`points`, und `(0 + 0) × (1 + 0,30) × 1 = 0`. Drei Quellen mit je 10 pp Crit-Chance hätten
|
||||
zusammen 0 % ergeben. Es ist dieselbe Basis-null-Falle, wegen der es den `flat`-Akkumulator
|
||||
überhaupt gibt (siehe Punkt 3) — sie wurde dort für `mult` gezogen und für `points` übersehen,
|
||||
obwohl `(1 + points)` genauso ein Faktor auf die Basis ist.
|
||||
|
||||
Die Ursache ist nicht die Basis, sondern eine Doppelbedeutung: Bei `Damage` und `MaxHealth` ist der
|
||||
Stat eine Größe und Prozentpunkte vergrößern sie. Bei `CritChance` ist der Stat **selbst** ein
|
||||
Prozentsatz; die Prozentpunkte sind der Wert und skalieren nichts. Zwei verschiedene Dinge trugen
|
||||
denselben Akkumulatornamen und wurden mit derselben Formel bedient.
|
||||
|
||||
Drei billigere Wege wurden geprüft und verworfen:
|
||||
|
||||
1. **Prozent-Stats nehmen `flat` statt `points`.** Rechnet richtig (`(0 + 0,30) × 1 × 1 = 0,30`) und
|
||||
käme ohne dritten Modus aus — aber der Clamp hängt in Punkt 2 an `points`. Die Caps für Crit
|
||||
Chance und Defense % würden ersatzlos ausfallen, und sie sind der Grund, warum diese Stats
|
||||
überhaupt einen Cap-Eintrag haben.
|
||||
2. **Den Clamp generell auf das Ergebnis legen** statt auf die Prozentpunkt-Summe. Löst die
|
||||
Prozent-Stats, ändert aber die Bedeutung der Cooldown-Caps: Aus „höchstens 60 pp Reduktion" würde
|
||||
„mindestens 24 s Dauer". Der Cap-Wert wechselte die Einheit, würde von `base` abhängig, und eine
|
||||
Basisänderung im Balancing verschöbe still eine Obergrenze.
|
||||
3. **Eine Ausnahme je Zeile in der Stat-Tabelle.** Genau das, was der Modus verhindern soll — der
|
||||
Modus ist eine Eigenschaft des Stats, und eine Eigenschaft gehört in ein Feld, nicht in eine
|
||||
Fußnote.
|
||||
|
||||
Bleibt der dritte Modus. Er kostet eine `enum`-Variante, keinen Akkumulator und keine Regel, die
|
||||
irgendwo außerhalb dieser drei Formeln steht; der Compiler erzwingt bei jedem neuen Stat eine
|
||||
Entscheidung, welcher der drei es ist.
|
||||
|
||||
**Prüfregel für jede neue Zeile der Stat-Tabelle**, in dieser Reihenfolge:
|
||||
|
||||
1. Ist der Stat selbst ein Anteil oder eine Wahrscheinlichkeit → **`Fraction`**.
|
||||
2. Ist er eine Zeitspanne in Sekunden → **`Interval`**.
|
||||
3. Sonst → **`Direct`**. Dann muss `base + flat` an der nach Punkt 11.1 genannten Fortschritts-Stelle
|
||||
**größer als 0** sein — sonst ist der Stat entweder falsch eingeordnet oder er braucht eine
|
||||
`flat`-Quelle. Ein `Direct`-Stat mit `base = 0` und `points` als einziger Quelle ist genau der
|
||||
Fehler, den diese Korrektur behebt.
|
||||
|
||||
### 2. Clamp: summieren, dann einmal begrenzen
|
||||
|
||||
`clamp(points)` ist `points.clamp(0.0, cap)`. Das `cap` ist ein Balancing-Wert und steht in
|
||||
`clamp(x)` ist `x.clamp(0.0, cap)`. Das `cap` ist ein Balancing-Wert und steht in
|
||||
`Balance.caps`, pro Stat einer (Crit Chance, Defense %, jeder Cooldown). Es gibt keinen globalen
|
||||
Clamp und keinen Default im Code.
|
||||
|
||||
**Worauf `clamp` angewandt wird, sagt die Formel des Modus** — und es ist in allen drei Fällen
|
||||
dieselbe Größe: die **Prozentpunkt-Summe**. Bei `Direct` und `Interval` ist das der
|
||||
`points`-Akkumulator, weil nur er Prozentpunkte trägt. Bei `Fraction` ist es der ganze Wert, weil
|
||||
der Wert dort aus nichts anderem als Prozentpunkten besteht. Der Cap behält damit in jedem Fall die
|
||||
Einheit „Prozentpunkte" und bleibt vom `base` des Stats unabhängig.
|
||||
|
||||
Geclamped wird **die Summe, genau einmal, nach der Summierung** — nie pro Quelle. Folgen, die
|
||||
ausdrücklich gewollt sind:
|
||||
|
||||
@@ -83,8 +156,8 @@ bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen si
|
||||
| `MaxHealth` | > 0 | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
|
||||
| `CritFactor` | > 0 | Direct | `mult` | — |
|
||||
| `CoinGain` | 1,0 | Direct | `mult` | — |
|
||||
| `CritChance` | 0 | Direct | `points` | `caps.crit_chance` |
|
||||
| `DefensePercent` | 0 | Direct | `points` | `caps.defense_percent` |
|
||||
| `CritChance` | 0 | **Fraction** | `points` | `caps.crit_chance` |
|
||||
| `DefensePercent` | 0 | **Fraction** | `points` | `caps.defense_percent` |
|
||||
| `HealthRegen` | **0** | Direct | `flat` (Workshop), `mult` (Cards) | — |
|
||||
| `DefenseFlat` | 0 | Direct | `flat`, `mult` | — |
|
||||
| `UltimateCooldown` | > 0 | **Interval** | `points` | `caps.cooldown_reduction` |
|
||||
@@ -93,6 +166,84 @@ bespielt werden — technisch stehen jedem Stat alle drei offen (die übrigen si
|
||||
`HealthRegen` und `DefenseFlat` sind der Grund für den flachen Akkumulator: Ihre Basis ist 0,
|
||||
und ein rein multiplikativer Bonus auf 0 bleibt 0. Dasselbe gilt später für Lifesteal und Thorns.
|
||||
|
||||
**Nachtrag 2026-08-10:** `CritChance` und `DefensePercent` standen in der ersten Fassung als
|
||||
`Direct` in dieser Tabelle und rechneten deshalb auf null — dieselbe Basis-null-Falle, hier nur über
|
||||
`points` statt über `mult`. Sie stehen jetzt als `Fraction`; `base`, „Erwartet" und `Clamp` sind
|
||||
unverändert, geändert hat sich allein die Modus-Spalte. Die Begründung steht in Punkt 1.
|
||||
|
||||
#### Nachweis: rechnet jede Zeile richtig?
|
||||
|
||||
Einmal komplett durchgegangen, mit den Formeln aus Punkt 1. „Richtig" heißt: Der Stat kann seinen
|
||||
erwarteten Quellen folgen, statt an einer Basis von 0 hängen zu bleiben.
|
||||
|
||||
| Stat | Modus | Prüfung | Richtig? |
|
||||
|---|---|---|---|
|
||||
| `Damage` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
|
||||
| `AttackInterval` | Interval | `base > 0`, `mult` teilt das Intervall | ja |
|
||||
| `Range` | Direct | `base > 0`, `mult` trägt | ja |
|
||||
| `MaxHealth` | Direct | `base > 0`, `flat`+`mult` tragen | ja |
|
||||
| `CritFactor` | Direct | `base > 0`; ein Faktor wie 2,5× ist eine **Größe**, kein Anteil — `Direct` bleibt richtig | ja |
|
||||
| `CoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||||
| `CritChance` | **Fraction** | war `Direct`: `(0+0)×(1+p) = 0`. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
|
||||
| `DefensePercent` | **Fraction** | war `Direct`: dasselbe. Jetzt `clamp(0 + 0 + p)` | **korrigiert** |
|
||||
| `HealthRegen` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → `base + flat > 0`; Prüfregel 3 erfüllt | ja |
|
||||
| `DefenseFlat` | Direct | wie `HealthRegen` | ja |
|
||||
| `UltimateCooldown` | Interval | `base > 0`; `points` skalieren eine Dauer, also eine Größe | ja |
|
||||
| `WaveCooldown` | Interval | `base > 0`; wie `UltimateCooldown` | ja |
|
||||
| `CoinsPerWave` | Direct | `base = 0`, aber `flat` ist erwartete Quelle → wie `HealthRegen` | ja |
|
||||
| `CritCoinGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||||
| `FreeUpgradeChance` | **Fraction** | eine Wahrscheinlichkeit → Prüfregel 1 | **korrigiert** |
|
||||
| `MetaGain` | Direct | `base = 1,0 > 0`, `mult` trägt | ja |
|
||||
|
||||
**`Interval` ist von der Korrektur nicht betroffen.** Beide `Interval`-Stats haben eine Basis über
|
||||
null, und ihre `points` skalieren eine Dauer — also eine Größe, nicht einen Anteil. Der inverse Term
|
||||
`(1 − clamp(points))` bleibt wörtlich stehen, ebenso ihre Caps und das durchgerechnete
|
||||
Ultimate-Cooldown-Beispiel weiter unten. Es gibt keinen Zeitspannen-Stat mit Basis 0; ein solcher
|
||||
wäre auch sinnlos, weil ein Intervall von 0 s keine Reduktion braucht.
|
||||
|
||||
#### Nachtrag 2026-08-10: vier Utility-Stats
|
||||
|
||||
> **Diese vier Zeilen sind später hinzugekommen als die zwölf oben.** Auslöser war Widerspruch 5 der
|
||||
> Vertical-Slice-Spec: Der Utility-Tab des In-Run-Shops stand mit `CoinGain` bei genau einer Zeile,
|
||||
> während Attack fünf und Defence vier hat. Ein Tab mit einer Zeile ist kein Tab. Die Kalibrierung
|
||||
> aus #3 sagt, was Utility im Referenzspiel ist — **kein Kampf-Stat, sondern ein
|
||||
> Ökonomie-Multiplikator** —, und `docs/WikiData.md` listet dort Cash Bonus, Cash/Wave, Coins/Kill
|
||||
> und Free Upgrades. Diese vier Zeilen übernehmen das Muster, nicht die Werte. An den zwölf Zeilen
|
||||
> oben ist nichts geändert.
|
||||
|
||||
| Stat | `base` | Modus | Erwartet | Clamp |
|
||||
|---|---|---|---|---|
|
||||
| `CoinsPerWave` | **0** | Direct | `flat` (Workshop, In-Run), `mult` (Cards) | — |
|
||||
| `CritCoinGain` | 1,0 | Direct | `mult` (Workshop, In-Run, Cards) | — |
|
||||
| `FreeUpgradeChance` | 0 | **Fraction** | `points` (Workshop, In-Run) | `caps.free_upgrade_chance` |
|
||||
| `MetaGain` | 1,0 | Direct | `mult` (Workshop, Cards) — **nicht** In-Run | — |
|
||||
|
||||
Damit hat die Tabelle **16** Stats. Drei der vier laufen im Modus `Direct`, `FreeUpgradeChance` als
|
||||
Wahrscheinlichkeit im Modus `Fraction`; keiner ist eine Zeitspanne, also braucht keiner `Interval`.
|
||||
|
||||
`CoinsPerWave` hat Basis **0** und steht damit in derselben Gruppe wie `HealthRegen` und
|
||||
`DefenseFlat`: Ein rein multiplikativer Bonus auf 0 bleibt 0, der flache Akkumulator ist der Grund,
|
||||
dass es die Zeile überhaupt gibt.
|
||||
|
||||
**`MetaGain` ist die einzige Zeile mit einer Quellen-Einschränkung, und die ist keine
|
||||
Balancing-Frage.** Ein aus `Coins` gekaufter Multiplikator auf die Meta-Währung wäre ein
|
||||
Konvertierungspfad zwischen den beiden Währungen — und Nicht-Konvertierbarkeit ist eine
|
||||
Kern-Entscheidung des Slice (Issue #1, #11). Die Einschränkung gehört deshalb in diese Tabelle und
|
||||
nicht in die UI: Wer `MetaGain` in den In-Run-Shop legt, bricht eine Entscheidung, nicht ein Layout.
|
||||
|
||||
**`CritCoinGain` erbt eine Vorbedingung.** Es wirkt nur, solange `CritChance > 0`, und `CritChance`
|
||||
hat Basis 0. Das ist gewollt — es koppelt den Utility-Tab an den Attack-Tab, damit die
|
||||
Kaufreihenfolge eine Entscheidung ist und keine Sortierung. Es heißt aber auch, dass die
|
||||
Sichtbarkeitsanforderung aus Punkt 11 hier zwingend greift: Die in Punkt 11.1 zu nennende
|
||||
Fortschritts-Stelle muss **hinter** dem ersten Crit-Kauf liegen, sonst ist der Modifikator an der
|
||||
genannten Stelle wirkungslos und nach Punkt 11.4 abzulehnen.
|
||||
|
||||
**`FreeUpgradeChance` ist als einziger der vier eine Wahrscheinlichkeit** und folgt deshalb der Form
|
||||
von `CritChance` und `DefensePercent`: Basis 0, `points`, ein eigenes `cap` in `Balance`, Modus
|
||||
**`Fraction`**. Beim Schreiben dieser Zeile ist aufgefallen, dass diese Form mit der damaligen
|
||||
`Direct`-Formel auf null rechnete — der Fund hat die Korrektur in Punkt 1 ausgelöst. Nach Prüfregel 1
|
||||
dort ist `Fraction` die richtige Einordnung, und der Cap greift auf die Prozentpunkt-Summe.
|
||||
|
||||
**`AttackInterval` ist der Stat, der die Zuordnung fast gesprengt hätte.** „Attack Speed ×1,25"
|
||||
ist genrenüblich eine Rate, und eine Rate widerspricht der Intervall-Regel. Der
|
||||
`Interval`-Modus löst das ohne Sonderregel: Die Karte liefert `mult = 1,25`, das Intervall wird
|
||||
@@ -259,6 +410,26 @@ danach.
|
||||
`(0 + 4,00 Workshop) × 1,60 (Card Lv 2) = 6,40 HP/s`.
|
||||
Ohne flachen Akkumulator: `0 × 1,60 = 0`. Die Karte wäre wirkungslos.
|
||||
|
||||
**Crit Chance** (Modus **`Fraction`**, Basis **0**, `caps.crit_chance = 0,80`) — der Fall, an dem
|
||||
die erste Fassung dieser ADR gescheitert ist. Drei Quellen liefern Prozentpunkte:
|
||||
|
||||
| Schritt | Rechnung | Wert |
|
||||
|---|---|---|
|
||||
| Basis aus `Balance` | — | 0,00 |
|
||||
| Workshop Crit Chance Lv 20 zu +1 pp je Level | `points += 0,20` | 0,20 |
|
||||
| Card „Präzision" Lv 4 | `points += 0,12` | 0,32 |
|
||||
| In-Run-Upgrade Crit Chance | `points += 0,05` | 0,37 |
|
||||
| `clamp((0 + 0 + 0,37) × 1)` bei `cap = 0,80` | — | **0,37 → 37 % Crit-Chance** |
|
||||
|
||||
**Mit der alten `Direct`-Formel wäre dasselbe `(0 + 0) × (1 + 0,37) × 1 = 0` gewesen** — zwanzig
|
||||
Workshop-Level, eine Karte auf Stufe 4 und ein gekauftes In-Run-Upgrade zusammen wirkungslos, ohne
|
||||
dass ein Test oder der Compiler es angezeigt hätte.
|
||||
|
||||
Der Clamp greift unverändert: Kommt eine vierte Quelle mit +50 pp dazu, ist die Summe 0,87 und der
|
||||
geclampte Wert 0,80 → **80 %**. Die **ungeclampte** Summe 0,87 bleibt für die Anzeige erhalten
|
||||
(Punkt 2), und die vierte Quelle wäre nach der Sichtbarkeitsanforderung (Punkt 11.4) abzulehnen oder
|
||||
umzuskalieren, weil 7 der 50 pp ersatzlos verfallen.
|
||||
|
||||
**Ultimate-Cooldown** (Modus `Interval`), Basis 60 s, `caps.cooldown_reduction = 0,60`:
|
||||
Workshop −8 pp und Card −30 pp ergeben 38 pp → `60 × (1 − 0,38) = 37,2 s`.
|
||||
Kommt eine dritte Quelle mit −30 pp dazu, ist die Summe 68 pp, geclamped auf 60 pp →
|
||||
@@ -337,3 +508,7 @@ Sichtbarkeitsanforderung ab.
|
||||
4. **Clamp-Druck:** Wenn an der vorgesehenen Fortschritts-Stelle mehr als eine Quelle je Stat
|
||||
regelmäßig am Clamp anschlägt, ist nicht der Modifikator falsch, sondern das
|
||||
Prozent-Budget — dann wird die harte Wand gegen abnehmende Erträge neu bewertet.
|
||||
5. **Vierter Anwendungsmodus:** Ein Stat, der weder eine Größe noch eine Zeitspanne noch ein Anteil
|
||||
ist. Ein Fall reicht. Die Prüfregel in Punkt 1 ist so gebaut, dass sie ihn sichtbar macht statt
|
||||
ihn in `Direct` zu drängen — und genau dieses Hineindrängen war der Defekt, den der Nachtrag vom
|
||||
2026-08-10 behoben hat.
|
||||
|
||||
296
docs/adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md
Normal file
296
docs/adr/0004-spatial-grid-fuer-gegner-nachbarschaft.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# ADR-0004: Spatial Grid für Gegner-Nachbarschaft und weiche Separation
|
||||
|
||||
- **Status:** Angenommen
|
||||
- **Datum:** 2026-08-10
|
||||
- **Betrifft:** Gegner-gegen-Gegner-Kollision, Zielauswahl in `tower_fire`, geteilter Reichweiten-Scan
|
||||
für das Auslöse-System, Projektil-Kollision
|
||||
- **Baut auf:** ADR-0001 (alle Zahlen aus `Res<Balance>`), ADR-0002 (Ablageregel `Resource` vs.
|
||||
`Component`; vollständige Neuberechnung statt Buchführung), Perf-Messungen aus #4,
|
||||
Cap-Analyse in #1, Auslöse-Entscheidung in #12
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Gegner-gegen-Gegner-Kollision steht seit #4 als „offene Architekturfrage" im Raum — Stapeln
|
||||
zulassen oder Spatial Grid mit weicher Separation — und hat seitdem in keiner ADR und auf keiner
|
||||
Liste gestanden. Sie entscheidet, ob 10 000 Gegner spielbar statt nur lauffähig sind, und sie ist
|
||||
teuer nachzurüsten.
|
||||
|
||||
Der eigentliche Grund für eine eigene ADR ist aber nicht die Separation. **Drei Systeme brauchen
|
||||
dieselbe Nachbarschaftsabfrage und wollten sie bisher jedes für sich beantworten:**
|
||||
|
||||
| Abfrage | Radius | Aufrufe pro Frame |
|
||||
|---|---|---|
|
||||
| Weiche Separation: „welche Gegner sind mir zu nahe?" | Separationsradius (klein) | einmal **je Gegner** → bis 10 000 |
|
||||
| Zielauswahl in `tower_fire`: „welcher Gegner ist der nächste?" | Turm-`Range` (groß) | einmal (ein Turm) |
|
||||
| Geteilter Reichweiten-Scan für das Auslöse-System (#12) | Turm-`Range` (groß) | einmal (ein Turm) |
|
||||
|
||||
Dazu kommt die **Projektil-Kollision** mit einem kleinen Trefferradius und einem Aufruf je
|
||||
Projektil. #4 hat sie als einzige davon gemessen.
|
||||
|
||||
#12 hat den geteilten Reichweiten-Scan bereits entschieden — „der Scan wird einmal gemacht und
|
||||
geteilt, sonst läuft er siebenmal pro Frame gegen 10 000 Entities". Diese ADR erledigt den zweiten
|
||||
Teil derselben Beobachtung: **der eine geteilte Scan ist immer noch ein linearer Durchlauf über
|
||||
alle Gegner.** Ein Grid macht daraus eine Abfrage, an der vier Konsumenten hängen — es ist geteilte
|
||||
Infrastruktur, keine Optimierung eines einzelnen Systems.
|
||||
|
||||
### Die Messungen, gegen die hier gerechnet wird (#4, #1)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---|
|
||||
| Gesamte Gameplay-Logik bei 10 000 Gegnern | **0,114 ms** von 16,67 ms (0,7 %) |
|
||||
| `move_enemies`, teuerstes Logik-System | **~10 ns pro Gegner** → 0,100 ms bei 10 000 |
|
||||
| Population-Maintainer bei 50 000 Gegnern | 6,1 µs |
|
||||
| Kollisions-Paartest | 1,5–6,5 ns, exakt linear in n·m |
|
||||
| Naives O(n·m) trägt bis | **~5·10⁵ Paartests pro Frame** |
|
||||
| Bruch unter 60 fps | ~70 000 Sprites, Rendering dominiert mit 96–98 % |
|
||||
|
||||
`move_enemies` ist mit 0,100 ms von 0,114 ms fast die gesamte Logik. Das ist der Maßstab: Ein
|
||||
System, das einen Durchlauf über dieselbe Gegnermenge macht, kostet höchstens so viel wie dieses.
|
||||
|
||||
### Geprüft an der Bevy-0.19-Doku (docs.rs über context7, nicht aus dem Gedächtnis)
|
||||
|
||||
- Bevy 0.19 bringt **keine** Struktur für Nachbarschaftsabfragen im Gameplay mit.
|
||||
`bevy::math::bounding` liefert `Aabb2d`, `BoundingCircle`, `Bounded2d` und `IntersectsVolume` —
|
||||
also Volumen und Schnitttests für die **Narrow Phase**, keine Broad Phase. Wer eine
|
||||
Nachbarschaftsabfrage will, baut sie oder holt sie sich als Dependency.
|
||||
- `Query::par_iter` / `par_iter_mut` existieren; `par_iter` ist auf lesende Queries beschränkt.
|
||||
Zusammen mit `Local<Parallel<Vec<_>>>` wäre ein parallel gefüllter Rebuild möglich — er wird hier
|
||||
**nicht** gebaut, siehe Rechnung unten.
|
||||
- `Local<T>` und `Resource` überleben Systemläufe. Die Bucket-Vektoren des Grids werden über
|
||||
`clear()` wiederverwendet; es gibt keine Allokation pro Frame.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
### 1. Ein Uniform Grid als `Resource`, einmal pro Frame neu gebaut
|
||||
|
||||
`SpatialGrid` ist eine `Resource`: ein **dichtes Array fester Größe** über die Arena, je Zelle ein
|
||||
`Vec<Entity>`. Die Arena ist beschränkt (die Spawn-Peripherie muss laut Spec-Feature 1 sichtbar
|
||||
sein) und der Turm steht fest in der Mitte — es gibt keinen Grund für eine Hashmap. Der Zellindex
|
||||
ist zwei Divisionen und zwei `floor`. Gegner außerhalb des Gitters werden in die Randzelle geklemmt.
|
||||
|
||||
Das Grid ist **kein** `Component` an der Turm-Entity: Es beschreibt nicht den Turm, sondern die
|
||||
Welt, und es überlebt keinen Run-Wechsel als Zustand — es wird ohnehin jeden Frame neu gebaut.
|
||||
Damit bleibt die Ablageregel aus ADR-0002 §6 unberührt.
|
||||
|
||||
### 2. Zellgröße: der kleinste Radius, der **pro Gegner** abgefragt wird
|
||||
|
||||
Die Kosten einer Radiusabfrage sind `(⌈2r/c⌉+1)²` Zellen mal der mittleren Belegung. Beide
|
||||
Fehlerrichtungen kosten: `c ≪ r` erzeugt viele leere Zellenköpfe, `c ≫ r` erzeugt Kandidaten, die
|
||||
der Narrow-Phase-Test wieder verwirft.
|
||||
|
||||
**Die Zellgröße folgt dem Separationsradius, nicht der Turm-Reichweite.** Der Grund ist nicht der
|
||||
Radius, sondern die **Häufigkeit**: Die Separationsabfrage läuft einmal je Gegner, also bis zu
|
||||
10 000-mal pro Frame; der Reichweiten-Scan und die Zielauswahl laufen bei genau einem Turm **einmal**
|
||||
pro Frame. Eine Zellgröße, die für die einmalige große Abfrage gewählt wird, verteuert die
|
||||
10 000-fache kleine Abfrage; umgekehrt verteuert eine kleine Zelle die einmalige große Abfrage nur
|
||||
um Zellenköpfe, und die sind kein Narrow-Phase-Test.
|
||||
|
||||
Verbindlich:
|
||||
|
||||
```
|
||||
cell_size = ‹1,0› × max(Separationsradius, Trefferradius)
|
||||
```
|
||||
|
||||
Alle drei Werte sind ‹Platzhalter› und leben in `Balance` (ADR-0001 §4 — keine Schwelle als Literal
|
||||
im Gameplay-Code). Bei `cell_size = Separationsradius` berührt eine Separationsabfrage genau den
|
||||
3×3-Block um die eigene Zelle und nie mehr.
|
||||
|
||||
Die Turm-`Range` bestimmt die Zellgröße ausdrücklich **nicht**. Täte sie es, läge die gesamte in
|
||||
Reichweite befindliche Population in wenigen Zellen — und die Separationsabfrage wäre wieder O(n),
|
||||
also genau das, was das Grid entfernen soll. `Range` ist außerdem ein Stat, der über den Run wächst
|
||||
(ADR-0002 §3); eine Zellgröße, die einem wachsenden Stat folgt, wäre eine Struktur, die sich im
|
||||
laufenden Run umbaut.
|
||||
|
||||
### 3. Rebuild pro Frame, kein inkrementelles Update — gerechnet, nicht geschätzt
|
||||
|
||||
Der Rebuild ist ein Durchlauf über dieselbe Gegnermenge wie `move_enemies`, in derselben
|
||||
Speicherreihenfolge, mit **weniger** Arithmetik: `move_enemies` liest `Transform`, bildet die
|
||||
Richtung zur Mitte (Subtraktion plus Normalisierung, also eine Wurzel), skaliert und **schreibt**
|
||||
`Transform` zurück. Der Rebuild liest `Transform`, rechnet zwei Divisionen und zwei `floor` und
|
||||
schiebt eine `Entity` in einen `Vec`.
|
||||
|
||||
| | Rechnung | Ergebnis |
|
||||
|---|---|---|
|
||||
| Obergrenze Rebuild bei `HARD_ENEMY_CAP` | 10 000 × ≤10 ns | **≤0,10 ms** |
|
||||
| Anteil am Frame-Budget | 0,10 / 16,67 | **≤0,6 %** |
|
||||
| Logik gesamt danach | 0,114 + 0,10 | **≤0,22 ms = ≤1,3 %** |
|
||||
| Selbst am gemessenen Bruchpunkt | 70 000 × 10 ns | 0,7 ms = 4,2 % |
|
||||
|
||||
Die 10 ns sind eine großzügige Obergrenze, weil sie den teureren Nachbarn als Maßstab nimmt.
|
||||
|
||||
**Was ein inkrementelles Update dagegen einspart, ist nach oben durch dieselben ≤0,10 ms begrenzt —
|
||||
und es spart nicht einmal die, denn den Zellindex muss es genauso je Gegner ausrechnen, um einen
|
||||
Zellwechsel überhaupt zu erkennen.** Der Rest der Ersparnis ist der `Vec`-Push. Dagegen steht: ein
|
||||
Rückindex je Entity (in welcher Zelle und an welcher Position liegt sie?), ein Entfernen aus einem
|
||||
`Vec` in der Mitte, und ein Zustand, der zwischen Frames korrekt bleiben muss, während Gegner
|
||||
spawnen, sterben und bei radialer Konvergenz ständig Zellgrenzen überqueren.
|
||||
|
||||
Das ist dieselbe Abwägung, die ADR-0002 §6 für die Akkumulatoren getroffen hat, mit demselben
|
||||
Ergebnis: **vollständige Neuberechnung statt Buchführung über das Rückgängigmachen.** Buchführung
|
||||
schlägt fehl, und hier kauft sie unter 0,6 % Frame-Budget.
|
||||
|
||||
Aus demselben Grund wird der Rebuild **nicht parallelisiert**. `par_iter` plus
|
||||
`Local<Parallel<Vec<_>>>` und ein Merge-Schritt wären in Bevy 0.19 verfügbar; sie kaufen einen
|
||||
Bruchteil von 0,6 %.
|
||||
|
||||
Nebenrechnung zur nicht entschiedenen Spielgeschwindigkeit (#1, Spec „Lücken" Punkt 11): Nur die
|
||||
Simulation skaliert, nicht das Rendering. x4 ergibt ≤0,4 ms Rebuild, also ≤2,4 % des Budgets. Die
|
||||
Entscheidung hier präjudiziert den Multiplikator nicht.
|
||||
|
||||
### 4. Die Projektil-Kollision läuft über dasselbe Grid
|
||||
|
||||
#4 hat gemessen, dass naives O(n·m) bis ~5·10⁵ Paartests pro Frame trägt, und genau das empfohlen.
|
||||
Diese ADR kehrt das um, aus drei Gründen:
|
||||
|
||||
1. **Der gemessene Spielraum ist am Auslegungspunkt aufgebraucht.** 10 000 Gegner × ‹50›
|
||||
gleichzeitige Projektile sind 5·10⁵ Paartests — die Grenze, nicht ein Abstand zu ihr.
|
||||
2. **Beide Faktoren wachsen entlang der Schwierigkeitskurve, das Budget nicht.** `n` wächst mit
|
||||
`target_population(wave)`, `m` wächst, weil `AttackInterval` über Workshop, Cards und
|
||||
In-Run-Upgrades sinkt (ADR-0002 §3, Modus `Interval`). Das Produkt wächst also quadratisch gegen
|
||||
ein festes Budget. Ein Grenzwert, den man im ersten Run einhält und in Run 20 reißt, ist kein
|
||||
Grenzwert.
|
||||
3. **Das Grid steht ohnehin.** Die Abfrage kostet nur noch den 3×3-Block, weil der Trefferradius
|
||||
nach Punkt 2 in die Zellgröße eingegangen ist. Ein zweiter, anders strukturierter Durchlauf über
|
||||
die Gegnermenge wäre genau das Muster, das #12 als Bruchstelle benannt hat.
|
||||
|
||||
Der Narrow-Phase-Test bleibt, was er war: der gemessene 1,5–6,5-ns-Paartest, nur gegen ‹k›
|
||||
Kandidaten aus dem Block statt gegen `n`. `bevy::math::bounding` liefert dafür `BoundingCircle` und
|
||||
`IntersectsVolume`.
|
||||
|
||||
### 5. Weiche Separation: ein geklemmter Versatz, keine Kraft
|
||||
|
||||
Die Separation ist **keine Kollisionsauflösung**. Überlappung bleibt erlaubt; das Ziel ist, den Ring
|
||||
lesbar zu halten, nicht Durchdringung zu verhindern. Wer Durchdringung verbietet, baut den Stau, den
|
||||
die Cap-Analyse als „Stau am Turmring, den keine DPS-Menge auflöst" benannt hat.
|
||||
|
||||
Je Gegner, aus dem 3×3-Block:
|
||||
|
||||
1. **Nachbar-Budget:** höchstens ‹k› Nachbarn werden berücksichtigt. Das deckelt Kosten **und**
|
||||
Kraft in einem — dieselbe Regelform wie das „Ziel-Limit pro Auslösung" der Cap-Analyse, das die
|
||||
Spec in Feature 8 schon trägt. Eine Projektregel statt zweier.
|
||||
2. **Summe der Wegdrück-Vektoren**, je Nachbar normiert und mit dem Abstand abfallend gewichtet.
|
||||
3. **Klemmung:** Der resultierende Versatz wird auf ‹f› × (Speed × dt) mit ‹f› < 1 begrenzt und als
|
||||
Positionsversatz angewandt, nicht als Beschleunigung.
|
||||
|
||||
**Das Problem, das die Klemmung löst, sitzt am Turmring.** Radial auf einen Punkt konvergierende
|
||||
Körper erzeugen dort die höchste Dichte, die es im Spiel gibt. Die Nachbarschaft eines Gegners am
|
||||
Ring ist tangential ungefähr isotrop, radial aber nicht — hinter ihm, zum Turm hin, steht niemand,
|
||||
der zurückdrückt. Der Rest-Vektor zeigt deshalb **nach außen, vom Turm weg**. Ohne Klemmung ist
|
||||
das die Pulsier-Fehlerform: Der Ring drückt sich selbst auseinander, die Gegner laufen wieder heran,
|
||||
der Ring drückt sich wieder auseinander — und dazwischen fallen Gegner aus ihrer Angriffsreichweite.
|
||||
|
||||
Mit der Klemmung kann ein Gegner in einem Frame nie weiter versetzt werden, als er sich bewegt. Der
|
||||
radiale Anlauf wird also nie umgekehrt, ein Gegner verlässt seine Angriffsposition nicht, und Jitter
|
||||
braucht ein Überschwingen, das es konstruktiv nicht mehr gibt. Aus der Kraft wird eine begrenzte
|
||||
Ausdünnung.
|
||||
|
||||
‹k›, ‹f›, Separationsradius und die Abstandsgewichtung sind **‹Platzhalter›** in `Balance`. Sie
|
||||
findet nur Spielen, wie die vier freien Zahlen aus #8. Diese ADR erfindet keine.
|
||||
|
||||
### 6. Einordnung in den Frame
|
||||
|
||||
Verbindliche Reihenfolge, explizit festgelegt, nicht aus der Registrierungsreihenfolge geerbt:
|
||||
|
||||
1. `move_enemies` — schreibt `Transform`
|
||||
2. `rebuild_spatial_grid` — liest `Transform`, schreibt `SpatialGrid`
|
||||
3. alle Konsumenten, lesend auf `SpatialGrid`: weiche Separation, geteilter Reichweiten-Scan,
|
||||
`tower_fire`, Projektil-Kollision
|
||||
|
||||
**Verbindlich: Kein Konsument iteriert die Gegner-Query direkt.** Das ist die schärfere Fassung des
|
||||
Akzeptanzkriteriums, das die Spec für Feature 4 schon führt — aus „es gibt genau einen
|
||||
Reichweiten-Scan" wird „es gibt genau einen Durchlauf über die Gegnermenge, und das ist der
|
||||
Rebuild".
|
||||
|
||||
Eine Folge wird ausdrücklich in Kauf genommen: Die Separation schreibt `Transform`, nachdem das Grid
|
||||
gebaut wurde. Das Grid ist innerhalb desselben Frames also um genau einen Separationsversatz
|
||||
veraltet. Der ist nach Punkt 5 kleiner als ein Bewegungsschritt und damit kleiner als eine Zelle.
|
||||
Die Alternative — Separation vor dem Rebuild — verschiebt dieselbe Ungenauigkeit auf `tower_fire`
|
||||
und die Auslöse-Bedingungen, wo sie sichtbarer wäre.
|
||||
|
||||
## Verworfene Alternativen
|
||||
|
||||
- **Stapeln zulassen, keine Separation** (der Vorschlag, den die Spec unter „Lücken" Punkt 9
|
||||
festgehalten hat). Der billigste Weg, und für sich genommen vertretbar — aber er löst nur eines
|
||||
der vier Probleme. Die Zielauswahl in `tower_fire` bleibt O(n), obwohl #4 sie als nächsten
|
||||
Hotspot nach dem Rendering benannt hat, und der geteilte Reichweiten-Scan bleibt ein linearer
|
||||
Durchlauf. Das Grid trägt sich über drei Konsumenten, nicht über die Separation. Dazu kommt ein
|
||||
Design-Argument: Am Turmring liegen ohne Separation tausende Sprites deckungsgleich übereinander,
|
||||
und die Soll-Population — die Größe, auf der die gesamte Schwierigkeitskurve steht — ist dann
|
||||
nicht mehr abzulesen.
|
||||
- **Echte Kollision über eine Physik-Engine** (`bevy_rapier`, `avian`). Löst Durchdringung, also
|
||||
nicht das Problem; bringt einen Solver, einen Integrationsschritt und eine Dependency, deren
|
||||
Release-Takt an Bevy-Versionen hängt — dieselbe Einwendung, die ADR-0001 gegen
|
||||
`bevy_common_assets` erhoben hat. Bei tausenden radial konvergierenden Körpern produziert ein
|
||||
Solver genau den Stau, den die Cap-Analyse benennt.
|
||||
- **Eine fertige Spatial-Query-Crate** (etwa `bevy_spatial`). Spart den Rebuild-Code, der aus einer
|
||||
Schleife und einem Index besteht, und tauscht ihn gegen eine Dependency plus eine fremde
|
||||
Vorstellung davon, was eine Abfrage kostet. Ob eine 0.19-kompatible Fassung existiert, **wurde
|
||||
nicht geprüft** — die Entscheidung hängt nicht daran.
|
||||
- **Quadtree oder BVH.** Passen sich ungleicher Dichte an. Die Dichte hier ist aber bekannt und
|
||||
gerichtet — ein Attraktor in der Mitte, radiale Anläufe —, und ihr Rebuild ist kein linearer
|
||||
Durchlauf, sondern eine Sortierung mit Vergleichen. Die Rechnung in Punkt 3 gilt für sie nicht.
|
||||
- **Inkrementelles Update des Grids.** Siehe Punkt 3: Die Ersparnis ist nach oben durch ≤0,10 ms
|
||||
begrenzt, der Preis ist ein Rückindex je Entity und eine Bug-Klasse.
|
||||
- **Ein eigenes Grid je Konsument, jeweils mit passender Zellgröße.** Drei bis vier Rebuilds statt
|
||||
einem und drei bis vier Stellen, an denen ein veraltetes Grid entstehen kann. Der gemessene Preis
|
||||
**eines** Rebuilds ist das Argument dagegen.
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
**Positiv**
|
||||
|
||||
- Vier Nachbarschaftsabfragen haben eine Quelle. Ein neuer Konsument (Death Wave, ein späterer
|
||||
Aura-Effekt) bekommt sie, ohne einen fünften Durchlauf über die Gegnermenge einzuführen.
|
||||
- Die Zielauswahl in `tower_fire` — laut #4 der nächste Hotspot nach dem Rendering — wird von einem
|
||||
linearen Scan zu einem Zellenlauf von innen nach außen, der beim ersten Treffer abbricht.
|
||||
- Der Rebuild hat keinen Zustand zwischen Frames. Es gibt kein veraltetes Grid, keinen Rückindex
|
||||
und keine Reparaturroutine.
|
||||
- Die Projektil-Kollision hört auf, entlang der Schwierigkeitskurve zu wachsen.
|
||||
|
||||
**Negativ**
|
||||
|
||||
- **Der erste unbedingte Kostenpunkt des Projekts.** Alles bisherige läuft bei Änderung
|
||||
(`EffectiveStats`, ADR-0002 §7) oder bei Ereignis. Der Rebuild läuft jeden Frame, auch wenn nichts
|
||||
passiert. ≤0,6 % ist bezahlbar, aber es ist eine neue Art von Kosten.
|
||||
- `SpatialGrid` ist eine `Resource`, die sich **jeden Frame ändert**. `is_changed()` auf ihr ist als
|
||||
Signal wertlos. Sie darf insbesondere nie eine Quelle für die `EffectiveStats`-Neuberechnung
|
||||
werden (ADR-0002 §7), sonst läuft die wieder jeden Frame und verliert genau den Beobachtungspunkt,
|
||||
für den sie gebaut wurde.
|
||||
- Eine Zellgröße für vier Radien ist ein Kompromiss. Die Reichweiten-Abfrage durchläuft einen Block,
|
||||
der mit dem `Range`-Stat wächst — siehe Revisions-Auslöser 2.
|
||||
- Die Separation ist innerhalb des Frames um einen Versatz veraltet (Punkt 6).
|
||||
- Die weiche Separation wird nach Gefühl getunt, nicht gegen einen Test. ‹k›, ‹f› und der
|
||||
Separationsradius sind so frei wie die vier Zahlen aus #8 — und der Beleg, dass sie stimmen, ist
|
||||
ein Blick auf den Turmring, keine Zahl.
|
||||
|
||||
## Offene Punkte, die diese ADR nicht entscheidet
|
||||
|
||||
- **Death Wave als vierter Konsument.** Der radiale Sweep fragt einen wachsenden Ring ab und muss
|
||||
Gegner „in der Reihenfolge ihres Abstands" treffen (Spec-Feature 8). Ein zellweiser Lauf von innen
|
||||
nach außen ist die naheliegende Form, und der gebatchte Massentod aus Spec-„Lücken" Punkt 10 wäre
|
||||
dann eine Zellordnung statt tausender Einzelereignisse. Das ist hier **nicht** entschieden — es
|
||||
gehört zu der ADR, die für Massenwirkung und Ziel-Limit noch fehlt.
|
||||
- **Arena-Maße und Zellenzahl.** Die Kantenlänge des Gitters folgt aus der Arena, und die steht
|
||||
nicht fest. Bis dahin ist offen, ob die Spawn-Peripherie im Gitter liegt oder in die Randzellen
|
||||
klemmt.
|
||||
- **`SlowAura` und andere Gegner-modifizierende Fähigkeiten** (ADR-0002 §9) sind naheliegende
|
||||
weitere Konsumenten. Sie fallen mit der Card-Liste.
|
||||
|
||||
## Revisions-Auslöser
|
||||
|
||||
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
|
||||
|
||||
1. **Der Rebuild ist nicht mehr der billigere Durchlauf.** Eine Messung mit dem Aufbau aus
|
||||
`examples/perf_spike.rs` bei `HARD_ENEMY_CAP` zeigt `rebuild_spatial_grid` teurer als
|
||||
`move_enemies` im selben Frame. Die Entscheidung gegen das inkrementelle Update steht und fällt
|
||||
mit dieser Annahme; ist sie widerlegt, wird sie neu getroffen — nicht vorher.
|
||||
2. **Radien-Spreizung.** Ein Konsument braucht einen Abfrageradius kleiner als eine halbe Zelle oder
|
||||
größer als ‹20› Zellkanten. Dann trägt **eine** Auflösung nicht mehr für alle Abfragen, und zwei
|
||||
Auflösungen oder eine hierarchische Struktur sind zu prüfen.
|
||||
3. **Die Separation frisst den radialen Fortschritt.** Ein Gegner braucht vom Spawn bis zum Turmring
|
||||
mehr als ‹1,2›× die aus `Speed` und Abstand erwartete Zeit. Dann ist die Klemmung zu schwach
|
||||
dosiert oder die Form der Separation falsch — beobachtbar in der Run-Telemetrie, nicht nur im
|
||||
Bild.
|
||||
4. **Das Bewegungsmodell ist nicht mehr rein radial.** Ausweichen, Umkreisen, Knockback oder ein
|
||||
zweiter Anziehungspunkt. Dann fällt die Dichteannahme „Maximum am Turmring", auf der Punkt 5
|
||||
steht, und die Zellgröße ist neu zu bestimmen.
|
||||
Reference in New Issue
Block a user