Merge branch 'feat/m4-handel'
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
23
CONTEXT.md
23
CONTEXT.md
@@ -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.
|
||||||
|
|||||||
@@ -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
27
mods/core/npcs.toml
Normal 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." },
|
||||||
|
]
|
||||||
@@ -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 = """
|
||||||
####+####
|
####+####
|
||||||
#.......#
|
#.......#
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
290
src/game/mod.rs
290
src/game/mod.rs
@@ -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, ®), Outcome::TurnPassed));
|
assert!(matches!(game.execute(Command::Wait, ®), 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), ®);
|
||||||
|
game.give(ItemInstance::stack("core:gold", 12), ®);
|
||||||
|
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"), ®);
|
||||||
|
game.give(scharf, ®);
|
||||||
|
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, ®);
|
||||||
|
let verkauf = game.sell_price(&schwert, ®);
|
||||||
|
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, ®, &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, ®), Outcome::TurnPassed));
|
||||||
|
assert_eq!(game.talking_to, Some(0));
|
||||||
|
|
||||||
|
let laden = game.dialog_options(®);
|
||||||
|
assert!(laden.len() >= 3, "die bedingte Option faellt raus, drei bleiben");
|
||||||
|
assert!(matches!(game.execute(Command::Choose(0), ®), 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), ®), Outcome::Rejected(_)));
|
||||||
|
let preis = game.buy_price(&ware[0], ®);
|
||||||
|
game.give(ItemInstance::stack("core:gold", preis), ®);
|
||||||
|
assert!(matches!(game.execute(Command::Buy(0), ®), 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/ui/app.rs
118
src/ui/app.rs
@@ -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).
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user