feat(handel): NPC-Registry, Dialog, Laden und Stapelregel

Vierzehnte Registry: NPCs sind keine Wesen — kein Kampfzustand, keine
Initiative, keine Attribute. Ihre Antwortliste ist flach; was erst nach
einer Antwort erscheint, ist eine Option mit einer Bedingung.

- open_shop als elfte Aktion (game/ setzt eine Absicht, die UI schiebt)
- Talk/Choose/Buy/Sell, Talk wirkt auf das eigene Feld wie PickUp
- Preisspanne statt Goldkonto beim Haendler, Charisma in der Preisfunktion
- Warenkorb einmal bei der Etagenerzeugung gezogen
- Stapelregel aus Ticket 07 endlich gebaut: 50-120 Gold war 120 Eintraege
- Dialog- und Shop-Screen, NPC-Symbol im Grid
This commit is contained in:
2026-08-12 20:31:35 +02:00
parent 197f62ce33
commit 7ed15afaf9
22 changed files with 818 additions and 26 deletions

View File

@@ -151,7 +151,11 @@ Fähigkeiten. Das Vokabular bleibt hier unverändert.
### Der Katalog ### Der Katalog
`damage` · `change_attribute` · `apply_modifier` · `remove_modifier` · `give_item` · `remove_item` · `damage` · `change_attribute` · `apply_modifier` · `remove_modifier` · `give_item` · `remove_item` ·
`start_quest` (08) · `flee` (14) · `run_script` (13) · `give_exp` (15) — **zehn**. `start_quest` (08) · `flee` (14) · `run_script` (13) · `give_exp` (15) · `open_shop` (M4) —
**elf**.
`open_shop` kam mit dem Handelsmodell dazu: Eine Dialogoption oeffnet den Laden, und als Aktion steht
sie in derselben Liste wie `start_quest` statt als Sonderfeld daneben.
### Nachtrag aus M3: benannte Modifikatoren brauchen eine Registry ### Nachtrag aus M3: benannte Modifikatoren brauchen eine Registry

View File

@@ -116,10 +116,20 @@ Mit frei zusammengesetzten Modifikatoren je Exemplar könnte ein zufällig erzeu
alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die
Anzeige hätte keinen Namen für das, was da liegt. Anzeige hätte keinen Namen für das, was da liegt.
**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. Ein Vergleich zweier **Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`.
`Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu binden wäre eine
Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit Ein Vergleich zweier `Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu
Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut. binden wäre eine Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass
ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn
so baut.
**Befund aus M4: Die Regel war bis dahin nur beschrieben.** `ItemInstance` hatte kein `count`, und der
Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge** ins Inventar — 120 Zeilen im
Inventar-Screen, und ein Kauf hätte 45 davon einzeln entfernen müssen. `ItemInstance` trägt jetzt ein
`count`, und die Stapelregel wird beim Aufheben *ausgeführt* statt nur beschrieben.
Damit bedeutet auch `stackable` aus Ticket 02 zum ersten Mal etwas — das Feld stand seit M1 in den
Daten und hat nie jemand gelesen.
### Ein Eingang, ein Ausgang ### Ein Eingang, ein Ausgang

View File

@@ -143,6 +143,11 @@ Kein `ItemDetail`-Popup: Die Inventarliste zeigt Details in einer Seitenspalte,
kein Screen. `Shop` bleibt getrennt von `Dialog`, weil Handel zwei Listen nebeneinander braucht und kein Screen. `Shop` bleibt getrennt von `Dialog`, weil Handel zwei Listen nebeneinander braucht und
der Dialog eine Textfläche mit Antwortoptionen. der Dialog eine Textfläche mit Antwortoptionen.
*Nachtrag aus M4:* Der `Dialog`-Screen zeigt **eine flache Optionsliste**, keinen Knotengraph — Text
des NPC oben, darunter die Optionen, deren `condition` gerade hält. Ein Graph waere die Maschine fuers
Story-Modul, und das steht unter *Out of scope*. `Talk` wirkt dabei auf das Feld, auf dem der Held
steht: dieselbe Regel wie `PickUp` und `Descend`.
`ListState` lebt in der Screen-Variante und wird beim `pop()` mitverworfen. Der Stack macht den `ListState` lebt in der Screen-Variante und wird beim `pop()` mitverworfen. Der Stack macht den
wichtigen Fall gratis richtig: Solange ein Screen im Stack *liegt*, existiert sein Zustand weiter — wichtigen Fall gratis richtig: Solange ein Screen im Stack *liegt*, existiert sein Zustand weiter —
Inventar → Detail → zurück landet wieder auf demselben Eintrag. Zurückgesetzt wird nur beim echten Inventar → Detail → zurück landet wieder auf demselben Eintrag. Zurückgesetzt wird nur beim echten

View File

@@ -91,6 +91,28 @@ Lücke**: Ein Trigger kann die Nutzlast des Events, das ihn ausgelöst hat, **ni
sieht nur Wesen. „Dornenpanzer reflektiert nur Feuer" ist damit nicht formulierbar. Zu entscheiden vor sieht nur Wesen. „Dornenpanzer reflektiert nur Feuer" ist damit nicht formulierbar. Zu entscheiden vor
M4, weil dort Affix- und Gegenstands-Trigger kommen. M4, weil dort Affix- und Gegenstands-Trigger kommen.
**M4 — Beute, Inventar, Affixe und Handel** (`feat/m4-beute`, `feat/m4-handel`, 56 Tests)
| Zusage | Aus | Befund |
|---|---|---|
| Eine Maschinerie für Gegenstände *und* Affixe | 16 | **bestätigt** — dieselben Gewichte, dieselbe Tabelle-in-Tabelle |
| Beute erst beim Aufheben auflösen, ein Symbol für alles | 16 | **bestätigt** — die Anzeige verrät nichts, was noch nicht gewürfelt ist |
| Affix ist eine benannte Modifikatorliste am Exemplar | 07 | **bestätigt** — strukturell dasselbe wie eine Charakteroption, `attribute()` blieb unverändert |
| Speicherstand bleibt eine ID-Liste | 07 | **bestätigt** — `ItemInstance` trägt `def`, `affixes`, `count` |
| Stapelregel: gleiche `def` **und** gleiche `affixes` | 07 | **war nie umgesetzt** — siehe unten |
| Gold ist ein gewöhnlicher Gegenstand | 09 | **bestätigt** — Bezahlen läuft durch `take("core:gold", …)`, kein Sonderfeld |
| Flache Dialogliste statt Knotengraph | M4 | **bestätigt** — die bedingte Option ersetzt den zweiten Knoten, ohne Knoten-IDs im Save |
| `open_shop` als elfte Aktion, nicht als Sonderfeld | 01 | **bestätigt** — `game/` setzt eine Absicht, die Schleife schiebt den Screen |
| Warenkorb einmal bei der Erzeugung ziehen | M4 | **bestätigt** — Schließen und Öffnen liefert dasselbe Angebot |
Ein Befund nachgetragen: **07/01/09** — die Stapelregel stand seit Ticket 07 auf dem Papier, aber
niemand hatte sie gebaut. Der Beutezieher schob für `count = "50-120"` bis zu **120 einzelne Einträge**
ins Inventar. Gefunden nicht beim Nachdenken, sondern beim Entwerfen des Handels: „Gold abziehen"
braucht einen Stapel, sonst ist Bezahlen eine Schleife über Einträge.
Dazu **eine bewusst offene Stelle**: `Npc.triggers` ist nicht gebaut, weil `triggers_for` NPCs nicht
einsammelt — ein Feld ohne Leser wäre eine Zusage, die niemand einlöst. TODO steht im Code.
**Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen: **Drei Befunde aus M1, die nur der Compiler liefern konnte** — alle in die Tickets zurückgetragen:
- **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen. - **11**: Der Grenztest in seiner wörtlichen Fassung schlägt beim eigenen Doc-Kommentar an. Muss Kommentarzeilen ausnehmen.
@@ -150,11 +172,65 @@ auch über Ticket 13.
- [15 — Progression und Level-up](issues/15-progression-und-levelup.md) ([#16](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/16)) — Ein Aufstieg gibt **genau einen** frei verteilbaren Punkt auf die fuenf Grundwerte; `AssignPoints` ist **ein** Command mit allen Punkten (drei Stufen waeren sonst drei Zuege im Menue). **Keine Obergrenze** — `clamp(1, 50)` faellt, die Kurve begrenzt sich ueber steigende Kosten (`exp_base`, `exp_exponent` in `[balance]`). Erfahrung aus Toetungen (`exp_reward` am Wesen) und Quests (`give_exp`, zehnte Aktion), **nicht** fuers Betreten einer Etage — das belohnte das Durchrennen. **Die Zuwachs-Regel bleibt eng**: nur Aufstieg und `AssignPoints` heben den Lebensstand mit `max_health`, Ausruestung nie — sonst „heilt ein Ring beim Anlegen" (01). Rasse und Klasse bleiben **flach**, `per_level` entfaellt ersatzlos; was sie traegt, sind Faehigkeiten. `level` darf modifiziert werden, aber Punkte haengen an `base.level` — ein `+1 level`-Ring schenkt keinen. Vier Nachtraege: `give_exp` an 01, `current_exp`/`unspent_points` an **`Game`** statt ans Wesen (07 hatte die Platzierung hierher delegiert), `AssignPoints` an 11, zwei Balance-Knoepfe plus `exp_reward` an 14. - [15 — Progression und Level-up](issues/15-progression-und-levelup.md) ([#16](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/16)) — Ein Aufstieg gibt **genau einen** frei verteilbaren Punkt auf die fuenf Grundwerte; `AssignPoints` ist **ein** Command mit allen Punkten (drei Stufen waeren sonst drei Zuege im Menue). **Keine Obergrenze** — `clamp(1, 50)` faellt, die Kurve begrenzt sich ueber steigende Kosten (`exp_base`, `exp_exponent` in `[balance]`). Erfahrung aus Toetungen (`exp_reward` am Wesen) und Quests (`give_exp`, zehnte Aktion), **nicht** fuers Betreten einer Etage — das belohnte das Durchrennen. **Die Zuwachs-Regel bleibt eng**: nur Aufstieg und `AssignPoints` heben den Lebensstand mit `max_health`, Ausruestung nie — sonst „heilt ein Ring beim Anlegen" (01). Rasse und Klasse bleiben **flach**, `per_level` entfaellt ersatzlos; was sie traegt, sind Faehigkeiten. `level` darf modifiziert werden, aber Punkte haengen an `base.level` — ein `+1 level`-Ring schenkt keinen. Vier Nachtraege: `give_exp` an 01, `current_exp`/`unspent_points` an **`Game`** statt ans Wesen (07 hatte die Platzierung hierher delegiert), `AssignPoints` an 11, zwei Balance-Knoepfe plus `exp_reward` an 14.
- [16 — Loot-Tabellen](issues/16-loot-tabellen.md) ([#17](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/17)) — Zwoelfte Registry `[[loot_tables]]`, **eine Maschinerie fuer Gegenstaende und Affixe**. **Gewichte statt Prozent**, weil Prozente sich auf 100 summieren muessen und ein hinzugefuegter Mod-Eintrag die Summe kaputt macht. Nieten sind gewoehnliche Eintraege (kein `drop_chance`), Tabelle-in-Tabelle liefert Seltenheitsstufen, ohne dass „Seltenheit" ein Begriff wird. **Affix-Stufen statt Affix-Werte** — eine Staerke je Exemplar haette Save-Format, Preisformel und Stapelregel aus 07 auf einmal gekostet. **`luck` hat genau eine Regel**: Chance auf einen zusaetzlichen Zug auf jeder Tabelle — wirkt dreifach (mehr Gegenstaende, mehr Affixe, haeufiger die staerkere Stufe), ohne dreimal geschrieben zu werden. Der **Mod-Einhaengepunkt existiert schon**: Tabelle-in-Tabelle plus zwei Ueberschreibungen, kein zweiter Mechanismus. Bodenbeute wird **erst beim Aufheben** gezogen (`Room` haelt eine `loot_table` plus Positionen), moeglich nur weil alle Haufen dasselbe Symbol zeigen — dadurch wirkt ein Gluecksring auch auf das, was schon liegt. `guaranteed = true` fuer Bosse, Mehrfachziehung erlaubt. Nachtraege: `Room`-Umbau und optionales Prefab-`loot` an 10, `LUCK_FACTOR` an 14. - [16 — Loot-Tabellen](issues/16-loot-tabellen.md) ([#17](https://gitlab.hive-brain.de/MDietermann/rustkeep/issues/17)) — Zwoelfte Registry `[[loot_tables]]`, **eine Maschinerie fuer Gegenstaende und Affixe**. **Gewichte statt Prozent**, weil Prozente sich auf 100 summieren muessen und ein hinzugefuegter Mod-Eintrag die Summe kaputt macht. Nieten sind gewoehnliche Eintraege (kein `drop_chance`), Tabelle-in-Tabelle liefert Seltenheitsstufen, ohne dass „Seltenheit" ein Begriff wird. **Affix-Stufen statt Affix-Werte** — eine Staerke je Exemplar haette Save-Format, Preisformel und Stapelregel aus 07 auf einmal gekostet. **`luck` hat genau eine Regel**: Chance auf einen zusaetzlichen Zug auf jeder Tabelle — wirkt dreifach (mehr Gegenstaende, mehr Affixe, haeufiger die staerkere Stufe), ohne dreimal geschrieben zu werden. Der **Mod-Einhaengepunkt existiert schon**: Tabelle-in-Tabelle plus zwei Ueberschreibungen, kein zweiter Mechanismus. Bodenbeute wird **erst beim Aufheben** gezogen (`Room` haelt eine `loot_table` plus Positionen), moeglich nur weil alle Haufen dasselbe Symbol zeigen — dadurch wirkt ein Gluecksring auch auf das, was schon liegt. `guaranteed = true` fuer Bosse, Mehrfachziehung erlaubt. Nachtraege: `Room`-Umbau und optionales Prefab-`loot` an 10, `LUCK_FACTOR` an 14.
## Nachentscheidungen
Entscheidungen, die nach dem Schließen aller Tickets fällig wurden — beim Bauen, nicht beim Planen.
### NPC- und Handelsmodell (während M4)
Der letzte Punkt aus „Not yet specified". Er hing an nichts Offenem mehr: `Talk` stand in Ticket 11s
Command-Liste, `npc_talked` in Ticket 08s Eventliste, `Dialog` und `Shop` unter den elf Screens, und
Ticket 10 ließ das Prefab seine NPCs nennen. Was fehlte, waren die Definitionen dahinter.
**NPCs sind die vierzehnte Registry.** Nicht `creatures` mit `hostile = false`: Ein Händler trüge dann
`health`, `initiative`, `cooldowns`, `ai` und `exp_reward` als dauerhaft toten Ballast, und die Frage
„kann ich den Händler erschlagen" wäre beantwortet, ohne dass sie jemand gestellt hat. Vereinheitlichen
lohnt, wenn Felder *geteilt* werden, nicht wenn sie mitgeschleppt werden.
**Der Warenbestand ist eine Beutetabelle.** `shop = "core:blacksmith_stock"` verweist auf
`[[loot_tables]]` — dieselben Gewichte, dieselbe Tabelle-in-Tabelle, dieselben Affixe. Kein neuer
„Waren"-Begriff, und ein Mod hängt Waren mit demselben Handgriff ein wie Beute. Gezogen wird **einmal
beim Erzeugen der Etage**: Bei jedem `Talk` neu wäre ausnutzbar (weggehen, wiederkommen, bis das gute
Stück im Regal liegt) — dieselbe Klasse wie Save-Scumming, die Ticket 14 geschlossen hat. Der
Unterschied zur Bodenbeute, die *doch* erst beim Aufheben zieht: Dort war das Argument **Glück**, und
bei einem Laden hat Glück nichts zu suchen — ein Kauf ist kein Fund.
**Preise mit Spanne**, sonst ist Handel ein Exploit oder ein No-op:
```
kauf = item_price × (1 − price_modifier/100)
verkauf = item_price × VERKAUF × (1 + price_modifier/100) # VERKAUF ≈ 0.4
```
`charisma` speist `price_modifier` über eine Formel, die in der **Preisfunktion** lebt, nicht in
`Game::attribute` — dort wäre es das einzige Attribut mit einem Sonderfall, und ein generischer Pfad
mit einer Ausnahme ist die Stelle, an der später jemand rät. Der Händler kauft **alles und hat
unbegrenzt Gold**: Die Spanne verhindert die Geldmaschine bereits, ein Goldvorrat wäre eine zweite
Regel gegen dasselbe Problem.
**Dialoge sind eine flache Optionsliste**, kein Knotengraph. Jede Option trägt Text, `condition` und
`actions` — damit sind Questvergabe, bedingte Optionen und Ladenzugang abgedeckt. Was erst ein Graph
könnte, ist **Erzählung**, und das Story-Modul steht ausdrücklich unter *Out of scope*. Der Ausbau ist
additiv: `goto` an der Option plus eine Knotenebene, die vorhandenen Optionen werden der Startknoten.
**`open_shop` wird die elfte Aktion** statt eines Sonderfelds an der Option — sie steht dann in
derselben Liste wie `start_quest` und läuft über denselben Eingang.
**Angesprochen wird auf dem eigenen Feld.** Eine Regel für drei Kommandos: `g` hebt auf, was hier
liegt, `>` steigt ab, wo hier eine Treppe ist, `t` spricht an, wer hier steht. Ein NPC blockiert nicht
— er steht in einem Laden, nicht in einer Tür.
**Befund nebenbei: `stackable` war nie implementiert.** Ticket 02 gab Gegenständen das Feld, Ticket 07
die Stapelregel, Ticket 09 erklärte Gold zum gewöhnlichen Gegenstand — und `loot.rs` schob für
`count = "50-120"` bis zu 120 einzelne Einträge ins Inventar. `ItemInstance` bekommt deshalb ein
`count`, und die Regel aus 07 (`stackable` **und** gleiche `def` **und** gleiche `affixes`) wird zum
ersten Mal ausgeführt statt nur beschrieben.
## Not yet specified ## Not yet specified
- **Balancing** — konkrete Zahlen für Attribute, Schaden, Preise und Erfahrung über fünf Etagen. Wartet auf Kampfformeln (14), Progression (15) und Loot (16); vorher gibt es nichts zu balancieren. - **Balancing** — konkrete Zahlen für Attribute, Schaden, Preise und Erfahrung über fünf Etagen. Wartet auf Kampfformeln (14), Progression (15) und Loot (16); vorher gibt es nichts zu balancieren.
- **Boss-Design** — ein Boss ist derzeit ein Wesen mit `ai`-Liste (14) in einem Prefab mit `core:boss`-Tag (10). Was fehlt, sind **Phasen**: „ab 50 % Leben ein anderes Verhalten" wäre mit der `ai`-Bedingungsliste sogar ausdrückbar, „ab 50 % ändern sich die Werte" nicht. Berührt Stufe (c) des Mod-Supports. - **Boss-Design** — ein Boss ist derzeit ein Wesen mit `ai`-Liste (14) in einem Prefab mit `core:boss`-Tag (10). Was fehlt, sind **Phasen**: „ab 50 % Leben ein anderes Verhalten" wäre mit der `ai`-Bedingungsliste sogar ausdrückbar, „ab 50 % ändern sich die Werte" nicht. Berührt Stufe (c) des Mod-Supports.
- **NPC- und Handelsmodell** — Warenbestand, Dialogdaten. Wartet auf das Event-System (08). Die Preisformel steht bereits in 07; weil 07 Backtracking ausgeschlossen hat, muss ein Händler auf **jeder** Etage erreichbar sein. - ~~**NPC- und Handelsmodell**~~ — **entschieden während M4**, siehe unten.
- **Inventar-UX** — Gewicht oder Kapazität (`carry_capacity` ist seit 01 ein Attribut ohne Regel), Sortierung, Vergleichsansicht. 11 hat nur den Screen und die Commands festgelegt, nicht was darin steht. - **Inventar-UX** — Gewicht oder Kapazität (`carry_capacity` ist seit 01 ein Attribut ohne Regel), Sortierung, Vergleichsansicht. 11 hat nur den Screen und die Commands festgelegt, nicht was darin steht.
- **Mod-Verteilung** — durch 03 auf „Ordner ins Profil entpacken" geschrumpft, ohne Versionsprüfung. Offen ist nur noch, ob 1.0 mehr braucht. - **Mod-Verteilung** — durch 03 auf „Ordner ins Profil entpacken" geschrumpft, ohne Versionsprüfung. Offen ist nur noch, ob 1.0 mehr braucht.

View File

@@ -170,6 +170,24 @@ keine Zeit.
Es gibt keine getrennte Kampfrunden-Uhr: Befristete [Modifikatoren](#modifikator) ticken auch beim Es gibt keine getrennte Kampfrunden-Uhr: Befristete [Modifikatoren](#modifikator) ticken auch beim
Erkunden, damit „ich warte den Debuff draußen ab" eine Entscheidung mit Kosten bleibt. Erkunden, damit „ich warte den Debuff draußen ab" eine Entscheidung mit Kosten bleibt.
## NPC
Eine ansprechbare Figur auf einer [Etage](#etage) — kein [Wesen](#wesen): Ein NPC hat keine
[Attribute](#attribut), keine [Initiative](#initiative) und keinen Kampfzustand. Ihn in `[[creatures]]`
zu pressen hieße, jedem Gegner ein leeres Dialogfeld und jedem Händler leere Grundwerte zu geben.
Seine Antwortliste ist **flach**, kein Knotengraph: Was erst nach einer Antwort erscheint, ist eine
Option mit einer [Bedingung](#bedingung). Jede Option trägt [Aktionen](#aktion) — `open_shop` ist die
elfte davon und öffnet den Laden, statt als Sonderfeld daneben zu stehen.
Ein NPC mit `shop` hat einen **Warenkorb**, der einmal bei der Erzeugung der Etage aus einer
[Loot-Tabelle](#loot-tabelle) gezogen wird und danach feststeht — sonst würfelte jedes Öffnen neu, und
der Spieler könnte sich das Angebot herbeischließen. Er hat **kein Goldkonto**: Die Spanne zwischen
Kauf- und Verkaufspreis, nicht ein Vorrat, begrenzt den Handel.
Angesprochen wird der NPC auf dem Feld, auf dem der Held **steht** — dieselbe Regel wie beim Aufheben
und beim Abstieg.
## Prefab ## Prefab
Ein von Hand gezeichneter Raum als ASCII-Block, moddbarer namespaced Inhalt. Wände, Boden und Türen Ein von Hand gezeichneter Raum als ASCII-Block, moddbarer namespaced Inhalt. Wände, Boden und Türen
@@ -237,8 +255,8 @@ Es gibt **keinen Seltenheitsbegriff**: Abstufungen sind eigene Einträge mit kle
## Registry ## Registry
Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **dreizehn**, Eine benannte Sammlung moddbarer Definitionen, geschlüsselt nach namespaced ID. Es gibt **vierzehn**,
und das hier ist die kanonische Liste — sie ist über sieben Tickets und drei Meilensteine gewachsen und und das hier ist die kanonische Liste — sie ist über sieben Tickets und vier Meilensteine gewachsen und
stand sonst nirgends vollständig: stand sonst nirgends vollständig:
| Registry | TOML-Tabelle | | Registry | TOML-Tabelle |
@@ -255,6 +273,7 @@ stand sonst nirgends vollständig:
| [Etagen](#etage) | `[[floors]]` | | [Etagen](#etage) | `[[floors]]` |
| [Loot-Tabellen](#loot-tabelle) | `[[loot_tables]]` | | [Loot-Tabellen](#loot-tabelle) | `[[loot_tables]]` |
| Benannte [Modifikatoren](#modifikator) | `[[modifiers]]` | | Benannte [Modifikatoren](#modifikator) | `[[modifiers]]` |
| [NPCs](#npc) | `[[npcs]]` |
| Scripts | `[[scripts]]` | | Scripts | `[[scripts]]` |
Jede dieser Tabellen darf in jeder beliebigen `*.toml` eines [Mods](#mod) stehen. Jede dieser Tabellen darf in jeder beliebigen `*.toml` eines [Mods](#mod) stehen.

View File

@@ -86,3 +86,18 @@ entries = [
{ item = "core:gold", weight = 10, count = "50-120" }, { item = "core:gold", weight = 10, count = "50-120" },
{ item = "core:health_potion", weight = 6, count = "2-4" }, { item = "core:health_potion", weight = 6, count = "2-4" },
] ]
# --- Laden ----------------------------------------------------------------
# Der Warenkorb wird EINMAL bei der Erzeugung der Etage gezogen. Kein Gold in
# der Tabelle: Der Haendler verkauft Waren, er verschenkt keine Waehrung.
[[loot_tables]]
id = "blacksmith_stock"
rolls = "2-4"
entries = [
{ item = "core:health_potion", guaranteed = true, count = "3-5" },
{ item = "core:iron_sword", weight = 5, affixes = "core:weapon_affixes" },
{ item = "core:leather_cap", weight = 5, affixes = "core:armor_affixes" },
{ item = "core:thorn_mail", weight = 2, affixes = "core:armor_affixes" },
{ item = "core:bone_blade", weight = 1, affixes = "core:weapon_affixes" },
]

27
mods/core/npcs.toml Normal file
View File

@@ -0,0 +1,27 @@
# NPCs — die vierzehnte Registry (M4).
#
# Ein NPC ist kein Wesen: Er hat keine Attribute, keine Initiative und keinen
# Kampfzustand. Ihn in `creatures` zu pressen hiesse, jedem Gegner ein leeres
# `options`-Feld und jedem Haendler leere Grundwerte zu geben.
#
# Die Antwortliste ist FLACH, kein Knotengraph: Was erst nach einer Antwort
# erscheint, ist eine Option mit einer Bedingung. Ein Graph braeuchte Knoten-IDs,
# einen Zeiger auf den aktuellen Knoten im Speicherstand und eine Kreispruefung
# beim Laden — fuer einen Haendler mit vier Saetzen.
[[npcs]]
id = "blacksmith"
name = "Borin"
symbol = "B"
color = "yellow"
greeting = "Stahl kostet. Rede oder kauf."
shop = "core:blacksmith_stock"
options = [
{ text = "Zeig mir deine Waren.", actions = [ { action = "open_shop" } ] },
{ text = "Was gibt es hier unten?" },
# Bedingte Option statt eines zweiten Dialogknotens — genau der Fall, fuer
# den der Knotengraph angeblich noetig waere.
{ text = "Diese Knochenklinge — kennst du sie?",
condition = [ { check = "has_item", item = "core:bone_blade" } ] },
{ text = "Nichts weiter." },
]

View File

@@ -36,6 +36,9 @@ layout = """
[[prefabs]] [[prefabs]]
id = "market" id = "market"
tags = ["core:merchant"] tags = ["core:merchant"]
# Der i-te `n`-Platz bekommt den i-ten Eintrag. Anders als bei `e` nennt das
# Prefab den NPC: Ein Schmied skaliert nicht mit der Tiefe.
npcs = ["core:blacksmith"]
layout = """ layout = """
####+#### ####+####
#.......# #.......#

View File

@@ -16,7 +16,7 @@ use toml::Spanned;
use super::registries::{ use super::registries::{
Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef, Ability, Affix, Category, CharacterOption, CreatureDef, FloorDef, Item, LootTable, ModifierDef,
Prefab, Registries, Slot, Npc, Prefab, Registries, Slot,
}; };
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
@@ -261,6 +261,8 @@ struct ModFile {
affixes: Vec<Spanned<toml::Value>>, affixes: Vec<Spanned<toml::Value>>,
#[serde(default)] #[serde(default)]
loot_tables: Vec<Spanned<toml::Value>>, loot_tables: Vec<Spanned<toml::Value>>,
#[serde(default)]
npcs: Vec<Spanned<toml::Value>>,
/// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der /// **Keine** Tabelle mit `[[...]]`, sondern ein einzelner Block, der
/// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03. /// feldweise verschmilzt — die ausdrueckliche Ausnahme zu Ticket 03.
#[serde(default)] #[serde(default)]
@@ -300,6 +302,7 @@ impl_entry!(CreatureDef);
impl_entry!(ModifierDef); impl_entry!(ModifierDef);
impl_entry!(Affix); impl_entry!(Affix);
impl_entry!(LootTable); impl_entry!(LootTable);
impl_entry!(Npc);
struct FileCtx<'a> { struct FileCtx<'a> {
module: &'a str, module: &'a str,
@@ -510,6 +513,7 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings); parse_entries(parsed.modifiers, &ctx, &mut reg.modifiers, &mut warnings);
parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings); parse_entries(parsed.affixes, &ctx, &mut reg.affixes, &mut warnings);
parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings); parse_entries(parsed.loot_tables, &ctx, &mut reg.loot_tables, &mut warnings);
parse_entries(parsed.npcs, &ctx, &mut reg.npcs, &mut warnings);
// `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der // `[balance]` verschmilzt **feldweise** (Ticket 14): Ein Mod, der
// einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in // einen Knopf dreht, schreibt eine Zeile — und ein neuer Knopf in

View File

@@ -6,5 +6,5 @@ pub mod registries;
pub use loader::{load_mods, write_log}; pub use loader::{load_mods, write_log};
pub use registries::{ pub use registries::{
Balance, CreatureDef, FloorDef, LootEntry, Prefab, Registries, parse_range, Balance, CreatureDef, FloorDef, DialogOption, LootEntry, Npc, Prefab, Registries, parse_range,
}; };

View File

@@ -212,7 +212,13 @@ pub struct Balance {
pub resistance_cap: f32, pub resistance_cap: f32,
pub hit_floor: f32, pub hit_floor: f32,
pub hit_ceiling: f32, pub hit_ceiling: f32,
// TODO(M4/M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor` /// Anteil des Preises, den ein Haendler beim Ankauf zahlt. **Die Spanne**:
/// Ohne sie ist Handel ein Exploit oder ein No-op — kaufen und sofort
/// zurueckverkaufen waere entweder gratis oder eine Geldmaschine.
pub verkauf: f32,
/// Wie stark `charisma` auf `price_modifier` wirkt (Ticket 02).
pub charisma_preis: f32,
// TODO(M5): `exp_base`, `exp_exponent` (Ticket 15) und `luck_factor`
// (Ticket 16) — sie kommen mit ihren Lesern, nicht davor. // (Ticket 16) — sie kommen mit ihren Lesern, nicht davor.
} }
@@ -225,10 +231,54 @@ impl Default for Balance {
resistance_cap: 0.8, resistance_cap: 0.8,
hit_floor: 0.05, hit_floor: 0.05,
hit_ceiling: 0.95, hit_ceiling: 0.95,
verkauf: 0.4,
charisma_preis: 1.5,
} }
} }
} }
/// Eine Wahlmoeglichkeit im Gespraech. **Flach, kein Knotengraph** — was erst
/// ein Graph koennte, ist Erzaehlung, und das Story-Modul steht unter
/// *Out of scope*.
#[derive(Debug, Clone, Deserialize)]
pub struct DialogOption {
pub text: String,
#[serde(default)]
pub condition: Vec<Condition>,
#[serde(default)]
pub actions: Vec<Action>,
}
/// Die **vierzehnte** Registry. Bewusst kein `creatures`-Eintrag mit
/// `hostile = false`: Ein Haendler truege sonst `health`, `initiative`,
/// `cooldowns`, `ai` und `exp_reward` als dauerhaft toten Ballast.
#[derive(Debug, Clone, Deserialize)]
pub struct Npc {
pub id: String,
pub name: String,
#[serde(default = "npc_symbol")]
pub symbol: char,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub greeting: String,
/// **Der Warenbestand ist eine Beutetabelle** — dieselben Gewichte,
/// dieselbe Tabelle-in-Tabelle, dieselben Affixe. Kein neuer
/// „Waren"-Begriff.
#[serde(default)]
pub shop: Option<String>,
#[serde(default)]
pub options: Vec<DialogOption>,
// TODO(M5): `triggers` — erst wenn `triggers_for` NPCs einsammelt. Als
// Feld ohne Leser waere es eine Zusage, die niemand einloest.
#[serde(default)]
pub overrides: Option<String>,
}
fn npc_symbol() -> char {
'n'
}
/// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt — /// Eine benannte Modifikatorliste, die an einem **einzelnen Exemplar** haengt —
/// „Flammendes Eisenschwert" (Ticket 07). /// „Flammendes Eisenschwert" (Ticket 07).
/// ///
@@ -323,6 +373,11 @@ pub struct Prefab {
/// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16). /// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16).
#[serde(default)] #[serde(default)]
pub loot: Option<String>, pub loot: Option<String>,
/// Wer auf den `n`-Plaetzen steht. Anders als bei `e` nennt das **Prefab**
/// den NPC, nicht die Etage: NPCs skalieren nicht mit der Tiefe — ein
/// Schmied auf Etage 5 ist derselbe wie auf Etage 1 (Ticket 10).
#[serde(default)]
pub npcs: Vec<String>,
#[serde(default)] #[serde(default)]
pub overrides: Option<String>, pub overrides: Option<String>,
} }
@@ -397,6 +452,7 @@ pub struct Registries {
pub modifiers: HashMap<String, ModifierDef>, pub modifiers: HashMap<String, ModifierDef>,
pub affixes: HashMap<String, Affix>, pub affixes: HashMap<String, Affix>,
pub loot_tables: HashMap<String, LootTable>, pub loot_tables: HashMap<String, LootTable>,
pub npcs: HashMap<String, Npc>,
/// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen. /// Keine Registry — ein Block mit festem Schema, feldweise verschmolzen.
pub balance: Balance, pub balance: Balance,
// TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13). // TODO(M5+): `quests` (Ticket 08) und `scripts` (Ticket 13).

View File

@@ -66,8 +66,8 @@ pub enum Target {
/// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15); /// Der Aktionskatalog. Zehn Eintraege sind entschieden (01, 08, 13, 14, 15);
/// M3 braucht fuenf davon. /// M3 braucht fuenf davon.
/// ///
// TODO(M4+): `give_item`, `remove_item` (Inventar), `start_quest` (08), // TODO(M5+): `give_item`, `remove_item`, `start_quest` (08), `give_exp` (15),
// `give_exp` (15), `run_script` (13). // `run_script` (13).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")] #[serde(tag = "action", rename_all = "snake_case")]
pub enum Action { pub enum Action {
@@ -109,6 +109,9 @@ pub enum Action {
/// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit /// Ticket 14: achte Aktion, weil Ticket 11 Flucht zu einer Faehigkeit
/// gemacht hat und die etwas braucht, das sie ausfuehrt. /// gemacht hat und die etwas braucht, das sie ausfuehrt.
Flee, Flee,
/// Elfte Aktion (M4): Eine Dialogoption oeffnet den Laden. Als Aktion steht
/// sie in derselben Liste wie `start_quest`, statt als Sonderfeld daneben.
OpenShop,
} }
/// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein** /// Die beiden Zustandsgroessen, die eine Aktion aendern darf. Bewusst **kein**
@@ -127,7 +130,7 @@ impl Action {
| Action::ChangeAttribute { target, .. } | Action::ChangeAttribute { target, .. }
| Action::ApplyModifier { target, .. } | Action::ApplyModifier { target, .. }
| Action::RemoveModifier { target, .. } => *target, | Action::RemoveModifier { target, .. } => *target,
Action::Flee => Target::Zelf, Action::Flee | Action::OpenShop => Target::Zelf,
} }
} }
} }

View File

@@ -124,7 +124,7 @@ pub struct Modifier {
pub flat: f32, pub flat: f32,
#[serde(default)] #[serde(default)]
pub percent: f32, pub percent: f32,
// TODO(M4): `target` fuer Auren (`all_enemies`) und `condition` fuer // TODO(M5): `target` fuer Auren (`all_enemies`) und `condition` fuer
// bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide // bedingte Modifikatoren („+5 gegen Untote", Ticket 08). Beide
// brauchen `Game::attribute`s `against`-Parameter, der bereits // brauchen `Game::attribute`s `against`-Parameter, der bereits
// in der Signatur steht. // in der Signatur steht.

View File

@@ -13,7 +13,7 @@ pub enum Direction {
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon. /// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
/// ///
// TODO(M5+): `Talk`, `Buy`, `Sell` — und `AssignPoints` aus Ticket 15. // TODO(M5+): `AssignPoints` aus Ticket 15.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
Move(Direction), Move(Direction),
@@ -27,6 +27,15 @@ pub enum Command {
Equip(usize), Equip(usize),
Unequip(String), Unequip(String),
Drop(usize), Drop(usize),
/// Wirkt auf das Feld, **auf dem der Held steht** — dieselbe Regel wie
/// `PickUp` und `Descend`. Ein NPC blockiert nicht: Er steht in einem Laden,
/// nicht in einer Tuer.
Talk,
/// Index in `Game::dialog_options` — die **gefilterte** Liste, damit der
/// Screen keine Bedingungen auswerten muss.
Choose(usize),
Buy(usize),
Sell(usize),
/// Der normale Angriff ist **keine eigene Variante**, sondern /// Der normale Angriff ist **keine eigene Variante**, sondern
/// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine /// `UseAbility("core:attack")` — Faehigkeiten sind seit Ticket 02 eine
/// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen. /// Registry, also gehoert er dorthin, und ein Mod kann ihn ersetzen.

View File

@@ -20,11 +20,30 @@ pub struct ItemInstance {
pub def: String, pub def: String,
#[serde(default)] #[serde(default)]
pub affixes: Vec<String>, pub affixes: Vec<String>,
/// Stapelmenge. Stand bis M4 nicht hier — der Beutezieher schob fuer
/// „50-120 Gold" bis zu 120 einzelne Eintraege ins Inventar.
#[serde(default = "one")]
pub count: u32,
}
fn one() -> u32 {
1
} }
impl ItemInstance { impl ItemInstance {
pub fn new(def: impl Into<String>) -> Self { pub fn new(def: impl Into<String>) -> Self {
Self { def: def.into(), affixes: Vec::new() } Self { def: def.into(), affixes: Vec::new(), count: 1 }
}
pub fn stack(def: impl Into<String>, count: u32) -> Self {
Self { def: def.into(), affixes: Vec::new(), count }
}
/// Die Stapelregel aus Ticket 07: gleiche `def` **und** gleiche `affixes`.
/// Dass ein Trank mit Zufallsaffix nicht mehr stapelt, ist die logische
/// Folge — und das Problem dessen, der ihn so gebaut hat.
pub fn stacks_with(&self, other: &ItemInstance) -> bool {
self.def == other.def && self.affixes == other.affixes
} }
} }

View File

@@ -38,6 +38,16 @@ impl Rect {
} }
} }
/// Ein NPC auf der Etage. Sein Warenkorb wird **einmal bei der Erzeugung**
/// gezogen und liegt danach fest — sonst wuerfelte jedes Oeffnen des Ladens neu
/// und der Spieler koennte sich das Angebot herbeischliessen (M4).
#[derive(Debug)]
pub struct NpcInstance {
pub pos: (u16, u16),
pub def: String,
pub stock: Vec<super::creature::ItemInstance>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Room { pub struct Room {
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10). /// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
@@ -57,6 +67,9 @@ pub struct Room {
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb /// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
/// stehen hier Positionen und keine Gegenstaende. /// stehen hier Positionen und keine Gegenstaende.
pub loot: Vec<(u16, u16)>, pub loot: Vec<(u16, u16)>,
/// Die `n`-Plaetze, schon aufgeloest. Anders als bei `e` nennt das Prefab
/// den NPC — ein Schmied skaliert nicht mit der Tiefe.
pub npcs: Vec<NpcInstance>,
pub cleared: bool, pub cleared: bool,
} }

View File

@@ -107,6 +107,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
for p in &placed { for p in &placed {
let mut enemy_spawns = Vec::new(); let mut enemy_spawns = Vec::new();
let mut loot = Vec::new(); let mut loot = Vec::new();
let mut npc_plaetze = Vec::new();
for y in 0..p.stamp.h { for y in 0..p.stamp.h {
for x in 0..p.stamp.w { for x in 0..p.stamp.w {
@@ -124,6 +125,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
match c { match c {
'e' => enemy_spawns.push((gx, gy)), 'e' => enemy_spawns.push((gx, gy)),
'l' => loot.push((gx, gy)), 'l' => loot.push((gx, gy)),
'n' => npc_plaetze.push((gx, gy)),
_ => {} _ => {}
} }
} }
@@ -152,6 +154,23 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
.or_else(|| def.loot.clone()) .or_else(|| def.loot.clone())
.unwrap_or_default(); .unwrap_or_default();
// Der i-te `n`-Platz bekommt den i-ten Eintrag aus `prefab.npcs` —
// Positionsbindung statt eines zweiten Markers je NPC.
let npcs = npc_plaetze
.into_iter()
.enumerate()
.filter_map(|(i, pos)| {
let id = reg.prefabs.get(&p.prefab)?.npcs.get(i)?.clone();
let stock = reg
.npcs
.get(&id)
.and_then(|n| n.shop.as_deref())
.map(|t| crate::game::loot::roll(t, reg, rng))
.unwrap_or_default();
Some(super::floor::NpcInstance { pos, def: id, stock })
})
.collect();
rooms.push(Room { rooms.push(Room {
prefab: p.prefab.clone(), prefab: p.prefab.clone(),
rect: p.rect, rect: p.rect,
@@ -159,6 +178,7 @@ pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor {
enemies, enemies,
loot_table, loot_table,
loot, loot,
npcs,
cleared: false, cleared: false,
}); });
} }

View File

@@ -87,9 +87,24 @@ fn emit(
None => Vec::new(), None => Vec::new(),
}; };
// Ein Eintrag mit `count = "50-120"` liefert **einen** Stapel, nicht 120
// Eintraege — die Stapelregel aus Ticket 07 wird hier zum ersten Mal
// gebraucht (Befund aus M4).
let (lo, hi) = parse_range(entry.count.as_ref()); let (lo, hi) = parse_range(entry.count.as_ref());
for _ in 0..rng.range(lo, hi) { let menge = rng.range(lo, hi);
out.push(ItemInstance { def: item.clone(), affixes: affixes.clone() }); if menge == 0 {
return;
}
let stapelbar = reg.items.get(item).is_some_and(|d| d.stackable);
if stapelbar {
out.push(ItemInstance { def: item.clone(), affixes, count: menge });
} else {
// Nicht stapelbar: jedes Exemplar einzeln, damit Affixe je Stueck
// unterschiedlich sein koennten.
for _ in 0..menge {
out.push(ItemInstance { def: item.clone(), affixes: affixes.clone(), count: 1 });
}
} }
} }

View File

@@ -48,7 +48,12 @@ pub struct Game {
pub last: String, pub last: String,
/// Kampflog, die letzten Zeilen. /// Kampflog, die letzten Zeilen.
pub journal: Vec<String>, pub journal: Vec<String>,
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an /// Mit wem gerade gesprochen wird — Index in `Room.npcs`.
pub talking_to: Option<usize>,
/// Absicht aus `open_shop`. Die Schleife liest sie und schiebt den Screen;
/// `game/` kennt keine Screens.
pub open_shop: bool,
// TODO(M5): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`). // `Game`, nicht am `Creature`).
} }
@@ -73,6 +78,8 @@ impl Game {
events: std::collections::VecDeque::new(), events: std::collections::VecDeque::new(),
last: String::from("—"), last: String::from("—"),
journal: Vec::new(), journal: Vec::new(),
talking_to: None,
open_shop: false,
}) })
} }
@@ -372,6 +379,10 @@ impl Game {
self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier); self.creature_mut(victim).modifiers.retain(|m| m.id != *modifier);
} }
// Der Laden ist reine UI-Folge: Die Aktion setzt eine Absicht,
// die Schleife schiebt den Screen. `game/` kennt ratatui nicht.
Action::OpenShop => self.open_shop = true,
Action::Flee => { Action::Flee => {
let own = self.attribute(source, Attribute::Dexterity, None, reg); let own = self.attribute(source, Attribute::Dexterity, None, reg);
// Gerechnete Werte, nicht Basiswerte — sonst ignoriert die // Gerechnete Werte, nicht Basiswerte — sonst ignoriert die
@@ -556,10 +567,85 @@ impl Game {
Command::Unequip(slot) => { Command::Unequip(slot) => {
(!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts") (!self.player.equipment.contains_key(slot)).then_some("Dort ist nichts")
} }
Command::Talk if in_combat => Some("Nicht im Kampf"),
Command::Talk => {
let (x, y) = self.player_pos;
self.npc_here(x, y).is_none().then_some("Hier steht niemand")
}
Command::Choose(i) => {
(*i >= self.dialog_options(reg).len()).then_some("Keine solche Antwort")
}
Command::Buy(i) => {
let Some(stock) = self.shop_stock() else {
return Some("Kein Laden offen");
};
let Some(inst) = stock.get(*i) else { return Some("Nicht im Angebot") };
let preis = self.buy_price(inst, reg);
(self.gold() < preis).then_some("Zu wenig Gold")
}
Command::Sell(i) if *i >= self.player.inventory.len() => {
Some("Kein solcher Gegenstand")
}
// Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn.
Command::Sell(i) => (self.player.inventory[*i].def == "core:gold")
.then_some("Gold verkauft man nicht"),
Command::Wait => None, Command::Wait => None,
} }
} }
pub fn npc_here(&self, x: u16, y: u16) -> Option<usize> {
let room = self.floor.player_room?;
self.floor.rooms[room].npcs.iter().position(|n| n.pos == (x, y))
}
fn npc_instance(&self) -> Option<&crate::game::floor::NpcInstance> {
self.floor.rooms[self.floor.player_room?].npcs.get(self.talking_to?)
}
/// Die **erfuellbaren** Optionen des laufenden Gesprraechs. Filtern gehoert
/// hierhin und nicht in den Screen — sonst wertete die Anzeige Bedingungen
/// aus (Ticket 11).
pub fn dialog_options<'r>(&self, reg: &'r Registries) -> Vec<&'r crate::data::DialogOption> {
let Some(npc) = self.talking_npc(reg) else { return Vec::new() };
npc.options
.iter()
.filter(|o| self.conditions_hold(&o.condition, CreatureRef::Player, None, reg))
.collect()
}
pub fn talking_npc<'r>(&self, reg: &'r Registries) -> Option<&'r crate::data::Npc> {
reg.npcs.get(&self.npc_instance()?.def)
}
pub fn shop_stock(&self) -> Option<&[ItemInstance]> {
Some(&self.npc_instance()?.stock)
}
/// `kauf = item_price × (1 − price_modifier/100)`.
///
/// Die Charisma-Formel lebt **hier**, nicht in `Game::attribute` — dort
/// waere `price_modifier` das einzige Attribut mit einem Sonderfall, und ein
/// generischer Pfad mit einer Ausnahme ist die Stelle, an der spaeter jemand
/// raet.
pub fn price_modifier(&self, reg: &Registries) -> f32 {
let p = CreatureRef::Player;
self.attribute(p, Attribute::PriceModifier, None, reg)
+ self.attribute(p, Attribute::Charisma, None, reg) * reg.balance.charisma_preis
}
pub fn buy_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32;
(roh * (1.0 - self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// `verkauf = item_price × VERKAUF × (1 + price_modifier/100)` — die
/// **Spanne** verhindert die Geldmaschine, deshalb braucht der Haendler
/// keinen Goldvorrat.
pub fn sell_price(&self, inst: &ItemInstance, reg: &Registries) -> u32 {
let roh = self.item_price(inst, reg) as f32 * reg.balance.verkauf;
(roh * (1.0 + self.price_modifier(reg) / 100.0)).max(1.0).round() as u32
}
/// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge. /// Alle Faehigkeiten eines Wesens, in stabiler Reihenfolge.
/// ///
/// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt** /// `core:attack` und `core:flee` sind **jedem Wesen implizit gewaehrt**
@@ -646,6 +732,10 @@ impl Game {
Command::UseItem(i) => self.use_item(*i, reg), Command::UseItem(i) => self.use_item(*i, reg),
Command::Equip(i) => self.equip(*i, reg), Command::Equip(i) => self.equip(*i, reg),
Command::Unequip(slot) => self.unequip(&slot.clone()), Command::Unequip(slot) => self.unequip(&slot.clone()),
Command::Talk => self.talk(reg),
Command::Choose(i) => self.choose(*i, reg),
Command::Buy(i) => self.buy(*i, reg),
Command::Sell(i) => self.sell(*i, reg),
Command::Drop(i) => { Command::Drop(i) => {
let inst = self.player.inventory.remove(*i); let inst = self.player.inventory.remove(*i);
let name = self.item_label(&inst, reg); let name = self.item_label(&inst, reg);
@@ -677,11 +767,74 @@ impl Game {
} }
let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect(); let namen: Vec<String> = beute.iter().map(|i| self.item_label(i, reg)).collect();
self.player.inventory.extend(beute); for inst in beute {
self.give(inst, reg);
}
self.log(format!("Aufgehoben: {}", namen.join(", "))); self.log(format!("Aufgehoben: {}", namen.join(", ")));
self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player)); self.events.push_back(Event::new(EventKind::ItemPickedUp, CreatureRef::Player));
} }
fn talk(&mut self, reg: &Registries) {
let (x, y) = self.player_pos;
let Some(i) = self.npc_here(x, y) else { return };
let room = self.floor.rooms.get(self.floor.player_room.unwrap()).unwrap();
let id = room.npcs[i].def.clone();
self.talking_to = Some(i);
if let Some(npc) = reg.npcs.get(&id)
&& !npc.greeting.is_empty()
{
self.log(format!("{}: {}", npc.name, npc.greeting));
}
}
fn choose(&mut self, index: usize, reg: &Registries) {
let Some(option) = self.dialog_options(reg).get(index).copied() else { return };
let text = option.text.clone();
let actions = option.actions.clone();
self.log(format!("> {text}"));
for action in &actions {
self.apply(action, CreatureRef::Player, CreatureRef::Player, reg);
}
}
fn buy(&mut self, index: usize, reg: &Registries) {
let Some(stock) = self.shop_stock() else { return };
let inst = stock[index].clone();
let preis = self.buy_price(&inst, reg);
// Wird erst genommen, dann gegeben: Ein Fehlschlag hier waere ein
// Gegenstand aus dem Nichts. `rejects` hat die Deckung schon geprueft.
if !self.take("core:gold", preis) {
return;
}
let name = self.item_label(&inst, reg);
self.give(inst, reg);
let room = self.floor.player_room.unwrap();
self.floor.rooms[room].npcs[self.talking_to.unwrap()].stock.remove(index);
self.log(format!("{name} gekauft fuer {preis} Gold"));
}
fn sell(&mut self, index: usize, reg: &Registries) {
// Ein Stapel wird stueckweise verkauft — sonst gaebe es keinen Weg,
// fuenf von fuenfzig Traenken loszuwerden.
let inst = ItemInstance { count: 1, ..self.player.inventory[index].clone() };
let preis = self.sell_price(&inst, reg);
let name = self.item_label(&inst, reg);
// Ueber den Index, nicht ueber `take`: Zwei Traenke mit verschiedenen
// Affixen teilen sich die `def`, und `take` naehme den erstbesten.
let slot = &mut self.player.inventory[index];
slot.count -= 1;
if slot.count == 0 {
self.player.inventory.remove(index);
}
self.give(ItemInstance::stack("core:gold", preis), reg);
// Der Haendler hat kein Gold-Konto: Die Spanne, nicht ein Vorrat,
// begrenzt den Handel.
self.log(format!("{name} verkauft fuer {preis} Gold"));
}
fn use_item(&mut self, index: usize, reg: &Registries) { fn use_item(&mut self, index: usize, reg: &Registries) {
let inst = self.player.inventory[index].clone(); let inst = self.player.inventory[index].clone();
let Some(def) = reg.items.get(&inst.def) else { return }; let Some(def) = reg.items.get(&inst.def) else { return };
@@ -724,6 +877,49 @@ impl Game {
} }
} }
/// Legt einen Stapel ins Inventar und **verschmilzt ihn**, wenn die
/// Stapelregel aus Ticket 07 hält: `stackable` und gleiche `def` und
/// gleiche `affixes`.
pub fn give(&mut self, inst: ItemInstance, reg: &Registries) {
let stapelbar = reg.items.get(&inst.def).is_some_and(|d| d.stackable);
if stapelbar
&& let Some(vorhanden) =
self.player.inventory.iter_mut().find(|i| i.stacks_with(&inst))
{
vorhanden.count += inst.count;
return;
}
self.player.inventory.push(inst);
}
/// Nimmt `menge` Stueck eines Gegenstands aus dem Inventar. Gibt zurueck,
/// ob genug da war — Gold ist ein gewoehnlicher Gegenstand (Ticket 09),
/// also laeuft Bezahlen hier durch.
pub fn take(&mut self, def: &str, menge: u32) -> bool {
if self.count_of(def) < menge {
return false;
}
let mut rest = menge;
self.player.inventory.retain_mut(|i| {
if rest == 0 || i.def != def {
return true;
}
let weg = rest.min(i.count);
i.count -= weg;
rest -= weg;
i.count > 0
});
true
}
pub fn count_of(&self, def: &str) -> u32 {
self.player.inventory.iter().filter(|i| i.def == def).map(|i| i.count).sum()
}
pub fn gold(&self) -> u32 {
self.count_of("core:gold")
}
/// Der Anzeigename eines Exemplars: Affixe werden vorangestellt — /// Der Anzeigename eines Exemplars: Affixe werden vorangestellt —
/// „Flammendes Eisenschwert" (Ticket 07). /// „Flammendes Eisenschwert" (Ticket 07).
pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String { pub fn item_label(&self, inst: &ItemInstance, reg: &Registries) -> String {
@@ -734,7 +930,8 @@ impl Game {
.filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str())) .filter_map(|a| reg.affixes.get(a).map(|d| d.name.as_str()))
.collect(); .collect();
teile.push(base); teile.push(base);
teile.join(" ") let name = teile.join(" ");
if inst.count > 1 { format!("{name} ×{}", inst.count) } else { name }
} }
/// Preis eines Exemplars: `base_price × Π price_factor` der Affixe /// Preis eines Exemplars: `base_price × Π price_factor` der Affixe
@@ -1275,4 +1472,91 @@ mod tests {
assert!(matches!(game.execute(Command::Wait, &reg), Outcome::TurnPassed)); assert!(matches!(game.execute(Command::Wait, &reg), Outcome::TurnPassed));
assert_eq!(game.turn, 1); assert_eq!(game.turn, 1);
} }
/// Ticket 07 beschrieb die Stapelregel, aber niemand hatte sie umgesetzt —
/// der Beutezieher schob fuer "50-120 Gold" bis zu 120 Eintraege ins
/// Inventar. Verschiedene Affixe stapeln bewusst **nicht**.
#[test]
fn gleiche_gegenstaende_stapeln_verschiedene_affixe_nicht() {
let (mut game, reg) = zwergenkrieger();
game.give(ItemInstance::stack("core:gold", 30), &reg);
game.give(ItemInstance::stack("core:gold", 12), &reg);
assert_eq!(game.gold(), 42);
assert_eq!(game.player.inventory.len(), 1, "Gold muss ein Stapel sein");
let mut scharf = ItemInstance::new("core:health_potion");
scharf.affixes = vec![String::from("core:sharp")];
game.give(ItemInstance::new("core:health_potion"), &reg);
game.give(scharf, &reg);
assert_eq!(
game.player.inventory.iter().filter(|i| i.def == "core:health_potion").count(),
2,
"verschiedene Affixe duerfen nicht stapeln"
);
assert!(game.take("core:gold", 40));
assert_eq!(game.gold(), 2);
assert!(!game.take("core:gold", 5), "zu wenig muss fehlschlagen");
assert_eq!(game.gold(), 2, "der Fehlschlag darf nichts abziehen");
}
/// Die **Spanne** ist der ganze Grund, warum der Haendler kein Goldkonto
/// braucht: Kaufen und sofort zurueckverkaufen muss Verlust machen.
#[test]
fn handel_kostet_die_spanne() {
let (game, reg) = zwergenkrieger();
let schwert = ItemInstance::new("core:iron_sword");
let kauf = game.buy_price(&schwert, &reg);
let verkauf = game.sell_price(&schwert, &reg);
assert!(verkauf < kauf, "Ankauf {verkauf} muss unter Verkauf {kauf} liegen");
}
/// Der ganze Weg: ansprechen, Laden oeffnen, kaufen. Prueft nebenbei, dass
/// der Warenkorb aus der Erzeugung stammt und nicht beim Oeffnen entsteht.
#[test]
fn kaufen_zieht_gold_ab_und_leert_das_regal() {
let (mut game, reg) = zwergenkrieger();
// Den Schmied auf irgendeiner Etage suchen — welches Prefab wo landet,
// entscheidet der Wurf.
let mut gefunden = None;
for _ in 0..40 {
if let Some((r, pos)) = game
.floor
.rooms
.iter()
.enumerate()
.find_map(|(r, room)| room.npcs.first().map(|n| (r, n.pos)))
{
gefunden = Some((r, pos));
break;
}
let def = reg.floor_number(1).expect("Etage 1");
game.floor = generate::generate(def, &reg, &mut game.rng);
}
let (raum, pos) = gefunden.expect("mindestens ein Marktraum in 40 Etagen");
game.player_pos = pos;
game.floor.player_room = Some(raum);
assert!(matches!(game.execute(Command::Talk, &reg), Outcome::TurnPassed));
assert_eq!(game.talking_to, Some(0));
let laden = game.dialog_options(&reg);
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
assert!(matches!(game.execute(Command::Choose(0), &reg), Outcome::TurnPassed));
assert!(game.open_shop, "die erste Option oeffnet den Laden");
let ware = game.shop_stock().expect("Warenkorb").to_vec();
assert!(!ware.is_empty(), "der Korb wird bei der Erzeugung gezogen");
// Ohne Gold ist jeder Kauf eine Abweisung, mit Gold geht er durch.
assert!(matches!(game.execute(Command::Buy(0), &reg), Outcome::Rejected(_)));
let preis = game.buy_price(&ware[0], &reg);
game.give(ItemInstance::stack("core:gold", preis), &reg);
assert!(matches!(game.execute(Command::Buy(0), &reg), Outcome::TurnPassed));
assert_eq!(game.gold(), 0, "der Preis muss abgezogen sein");
assert_eq!(game.shop_stock().expect("Korb").len(), ware.len() - 1);
assert!(game.player.inventory.iter().any(|i| i.def == ware[0].def));
}
} }

View File

@@ -38,6 +38,23 @@ impl App {
self.screens.retain(|s| !matches!(s, Screen::Combat { .. })); self.screens.retain(|s| !matches!(s, Screen::Combat { .. }));
} }
// Ticket 11: `Game.talking_to` ist die Wahrheit, der Screen folgt —
// dieselbe Regel wie beim Kampf.
let talking = self.game.talking_to.is_some();
let shows_dialog = self.screens.iter().any(|s| matches!(s, Screen::Dialog { .. }));
if talking && !shows_dialog {
self.screens.push(Screen::Dialog { pick: 0 });
} else if !talking {
self.screens
.retain(|s| !matches!(s, Screen::Dialog { .. } | Screen::Shop { .. }));
}
// Die Aktion `open_shop` setzt nur eine Absicht; den Screen schiebt die
// Schleife, weil `game/` keine Screens kennt.
if std::mem::take(&mut self.game.open_shop) {
self.screens.push(Screen::Shop { pick: 0, selling: false });
}
if self.game.over.is_some() if self.game.over.is_some()
&& !self.screens.iter().any(|s| matches!(s, Screen::GameOver)) && !self.screens.iter().any(|s| matches!(s, Screen::GameOver))
{ {
@@ -75,6 +92,11 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
match screen.handle_key(key.code, game, registries) { match screen.handle_key(key.code, game, registries) {
Response::None => {} Response::None => {}
Response::Pop => { Response::Pop => {
// Das Gespraech zu verlassen beendet es auch im Kern —
// sonst schoebe `sync_screens` den Screen sofort zurueck.
if matches!(app.screens.last(), Some(Screen::Dialog { .. })) {
app.game.talking_to = None;
}
app.screens.pop(); app.screens.pop();
if app.screens.is_empty() { if app.screens.is_empty() {
break Ok(()); break Ok(());
@@ -113,6 +135,8 @@ fn draw(frame: &mut Frame, app: &App) {
Screen::Combat { pick } => draw_combat(frame, area, app, *pick), Screen::Combat { pick } => draw_combat(frame, area, app, *pick),
Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick), Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick),
Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick),
Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling),
Screen::GameOver => draw_game_over(frame, area, app), Screen::GameOver => draw_game_over(frame, area, app),
} }
} }
@@ -150,7 +174,11 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
frame.render_widget( frame.render_widget(
GridWidget { floor: &app.game.floor, player: app.game.player_pos }, GridWidget {
floor: &app.game.floor,
player: app.game.player_pos,
reg: &app.registries,
},
grid, grid,
); );
@@ -226,6 +254,94 @@ fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
); );
} }
/// **Flache Optionsliste** — was erst nach einer Antwort erscheint, ist eine
/// Option mit einer Bedingung (M4).
fn draw_dialog(frame: &mut Frame, area: Rect, app: &App, pick: usize) {
let Some(npc) = app.game.talking_npc(&app.registries) else { return };
let mut zeilen = vec![
Line::from(npc.greeting.clone()).style(Style::new().fg(Color::Gray)),
Line::from(""),
];
for (i, option) in app.game.dialog_options(&app.registries).iter().enumerate() {
let zeiger = if i == pick { "›" } else { " " };
let farbe = if i == pick { Color::Yellow } else { Color::Reset };
zeilen.push(
Line::from(format!("{zeiger} {}. {}", i + 1, option.text))
.style(Style::new().fg(farbe)),
);
}
zeilen.push(Line::from(""));
zeilen.push(
Line::from("↑↓ wählen · Enter/1-9 antworten · Esc beenden")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(zeilen).block(Block::bordered().title(format!(" {} ", npc.name))),
area,
);
}
/// Zwei Spalten, damit die **Spanne** sichtbar ist: derselbe Gegenstand steht
/// links teurer als er rechts einbringt.
fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: bool) {
let block = Block::bordered().title(format!(" Laden · {} Gold ", app.game.gold()));
let inner = block.inner(area);
frame.render_widget(block, area);
let [links, rechts] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Min(0)]).areas(inner);
let zeile = |i: usize, text: String, aktiv: bool| {
let zeiger = if aktiv && i == pick { "›" } else { " " };
let farbe = if aktiv && i == pick { Color::Yellow } else { Color::Reset };
Line::from(format!("{zeiger} {text}")).style(Style::new().fg(farbe))
};
let mut angebot: Vec<Line> = app
.game
.shop_stock()
.unwrap_or(&[])
.iter()
.enumerate()
.map(|(i, inst)| {
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.buy_price(inst, &app.registries);
zeile(i, format!("{name:<28} {preis:>5} G"), !selling)
})
.collect();
if angebot.is_empty() {
angebot.push(Line::from("ausverkauft").style(Style::new().fg(Color::DarkGray)));
}
let mut eigenes: Vec<Line> = app
.game
.player
.inventory
.iter()
.enumerate()
.map(|(i, inst)| {
let name = app.game.item_label(inst, &app.registries);
let preis = app.game.sell_price(inst, &app.registries);
zeile(i, format!("{name:<28} {preis:>5} G"), selling)
})
.collect();
eigenes.push(Line::from(""));
eigenes.push(
Line::from("Tab wechseln · Enter handeln · Esc")
.style(Style::new().fg(Color::DarkGray)),
);
frame.render_widget(
Paragraph::new(angebot).block(Block::bordered().title(" Angebot ")),
links,
);
frame.render_widget(
Paragraph::new(eigenes).block(Block::bordered().title(" Verkaufen ")),
rechts,
);
}
/// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit /// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit
/// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt /// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt
/// aus dem Spielkern, nicht aus der UI (Ticket 14). /// aus dem Spielkern, nicht aus der UI (Ticket 14).

View File

@@ -7,11 +7,15 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use crate::data::Registries;
use crate::game::{Floor, Tile}; use crate::game::{Floor, Tile};
pub struct GridWidget<'a> { pub struct GridWidget<'a> {
pub floor: &'a Floor, pub floor: &'a Floor,
pub player: (u16, u16), pub player: (u16, u16),
/// Nur fuer die NPC-Symbole. Anders als Beutehaufen tragen NPCs ihr eigenes
/// Zeichen — wer im Laden steht, soll man vor dem Ansprechen sehen.
pub reg: &'a Registries,
} }
impl Widget for GridWidget<'_> { impl Widget for GridWidget<'_> {
@@ -53,6 +57,24 @@ impl Widget for GridWidget<'_> {
} }
} }
for room in &self.floor.rooms {
for npc in &room.npcs {
let (nx, ny) = npc.pos;
if nx < ox || ny < oy {
continue;
}
let (px, py) = (area.x + nx - ox, area.y + ny - oy);
if px < area.right() && py < area.bottom() {
let def = self.reg.npcs.get(&npc.def);
let symbol = def.map_or('n', |n| n.symbol);
let color = super::theme::color(def.and_then(|n| n.color.as_deref()));
buf[(px, py)]
.set_symbol(&symbol.to_string())
.set_style(Style::new().fg(color).add_modifier(Modifier::BOLD));
}
}
}
let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy); let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
if sx < area.right() && sy < area.bottom() { if sx < area.right() && sy < area.bottom() {
buf[(sx, sy)] buf[(sx, sy)]

View File

@@ -25,8 +25,20 @@ pub enum Screen {
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
/// Lauf am Ende *erzaehlt* wird (Ticket 14). /// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver, GameOver,
// TODO(M3+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`, /// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer
// `Shop`, `Dialog`, `Confirm` — elf sind es am Ende (11/14). /// Antwort erscheint, ist eine Option mit einer Bedingung.
Dialog {
pick: usize,
},
/// Zwei Spalten: Angebot links, eigenes Inventar rechts. `Tab` wechselt.
Shop {
pick: usize,
/// `false` = Angebot, `true` = Inventar. Ein `bool` statt eines Enums
/// mit zwei Werten, die nie mehr werden.
selling: bool,
},
// TODO(M5+): `CharacterCreation`, `QuestLog`, `Confirm` — elf sind es am
// Ende (11/14).
} }
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein /// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
@@ -78,6 +90,7 @@ impl Screen {
KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('>') => Response::Command(Command::Descend), KeyCode::Char('>') => Response::Command(Command::Descend),
KeyCode::Char('g') => Response::Command(Command::PickUp), KeyCode::Char('g') => Response::Command(Command::PickUp),
KeyCode::Char('t') => Response::Command(Command::Talk),
// Screen-Navigation ist reine UI und fasst `Game` nie an. // Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }), KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }),
@@ -165,6 +178,65 @@ impl Screen {
_ => Response::None, _ => Response::None,
}, },
Screen::Dialog { pick } => {
let options = game.dialog_options(reg);
match key {
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(options.len().saturating_sub(1));
Response::None
}
KeyCode::Enter | KeyCode::Char('1'..='9') => {
let i = match key {
KeyCode::Char(c @ '1'..='9') => (c as u8 - b'1') as usize,
_ => *pick,
};
match options.get(i) {
Some(_) => Response::Command(Command::Choose(i)),
None => Response::None,
}
}
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::Shop { pick, selling } => {
let n = if *selling {
game.player.inventory.len()
} else {
game.shop_stock().map_or(0, |s| s.len())
};
if n > 0 {
*pick = (*pick).min(n - 1);
}
match key {
KeyCode::Tab | KeyCode::Left | KeyCode::Right => {
*selling = !*selling;
*pick = 0;
Response::None
}
KeyCode::Up | KeyCode::Char('k') => {
*pick = pick.saturating_sub(1);
Response::None
}
KeyCode::Down | KeyCode::Char('j') => {
*pick = (*pick + 1).min(n.saturating_sub(1));
Response::None
}
KeyCode::Enter if n > 0 && *selling => {
Response::Command(Command::Sell(*pick))
}
KeyCode::Enter if n > 0 => Response::Command(Command::Buy(*pick)),
KeyCode::Esc | KeyCode::Char('q') => Response::Pop,
_ => Response::None,
}
}
Screen::GameOver => Response::Quit, Screen::GameOver => Response::Quit,
} }
} }