-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.py
executable file
·1503 lines (1235 loc) · 57.1 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
import tcod.bsp
import tcod.console
import tcod.context
import tcod.event
import tcod.image
import tcod.libtcodpy
import tcod.tileset
from tcod.event import KeySym
from llama_cpp import Llama, LlamaGrammar
import json
import math
import os
import sys
import types
import random
from enum import IntEnum
from contextlib import contextmanager
from queue import Queue, Empty
from threading import Thread, Lock
from typing import Optional, Generator
from urllib.request import urlopen
GAME_NAME = "False Ghost"
class ProceduralGenerator:
MODEL_URLS = {
"tinyllama-1.1b-1t-openorca.Q4_K_M.gguf": "https://huggingface.co/TheBloke/TinyLlama-1.1B-1T-OpenOrca-GGUF/resolve/main/tinyllama-1.1b-1t-openorca.Q4_K_M.gguf",
"mistral-7b-v0.1.Q4_K_M.gguf": "https://huggingface.co/TheBloke/Mistral-7B-v0.1-GGUF/resolve/main/mistral-7b-v0.1.Q4_K_M.gguf"
}
#MODEL="tinyllama-1.1b-1t-openorca.Q4_K_M.gguf"
MODEL="mistral-7b-v0.1.Q4_K_M.gguf"
# What special characters could we use.
# TODO: Actually use these. They can't be in the grammar. Try rolling them ourselves and showing the model?
SPECIAL_CHARS="☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼"
# This holds a prompt template for each type of object.
PROMPTS = {
"enemy": "An enemy type for my 7DRL roguelike, way better than \"{}\" or \"{}\" from the last game. I tried to make it fit in with its elemental domain.",
"obstacle": "An obstacle type in a level for my 7DRL roguelike, way better than \"{}\" or \"{}\" from the last game. I tried to make it fit in with its elemental domain.",
"loot": "A loot item type that the player can carry, use, and sell, for my 7DRL roguelike, way better than \"{}\" or \"{}\" from the last game. I tried to make it fit in with its elemental domain."
}
# This holds example item types used to vary the prompt.
EXAMPLES = {
"enemy": [
"dire cat",
"angry boss",
"CEO",
"mecha CEO",
"master chef",
"nasal demon",
"tiger shark",
"wombat",
"dire accountant",
],
"obstacle": [
"boring wall",
"portcullis",
"moderately large stick",
"hole in the ground",
"missing floor tile",
"pile of rusty metal",
"immovable rod",
"road work",
],
"loot": [
"double-ended sword",
"pointy rock",
"small pile of gold",
"portable hole",
"Spear of Justice",
"stapler",
"gun",
]
}
# What elemental domains are there?
ELEMENTAL_DOMAINS = ["normal", "earth", "air", "fire", "water", "good", "evil", "business"]
# How likely is a thing to differ from the domain of its parent?
DIFFERENT_DOMAIN_CHANCE = 0.15
# Which domains beat which others?
BEAT_PAIRS = {
("water", "fire"),
("fire", "air"),
("air", "earth"),
("earth", "water"),
("evil", "good"),
("good", "business"),
("business", "evil")
}
# What symbols represent the elemental domains?
DOMAIN_SYMBOLS = {
"normal": "•",
"earth": "♦",
"air": "♠",
"fire": "♣",
"water": "♥",
"good": "▲",
"evil": "▼",
"business": "☼"
}
# What colors represent the elemental domains?
DOMAIN_COLORS = {
"normal": (127, 127, 127),
"earth": (16, 160, 64),
"air": (255, 255, 64),
"fire": (255, 64, 64),
"water": (16, 64, 160),
"good": (0, 255, 255),
"evil": (127, 0, 0),
"business": (128, 255, 64)
}
# What symbols represent the rarity levels?
RARITY_SYMBOLS = {
"common": "○",
"uncommon": "♪",
"rare": "♫",
"ultra-rare": "‼"
}
# What colors represent the rarity levels?
RARITY_COLORS = {
"common": (127, 127, 127),
"uncommon": (0, 255, 0),
"rare": (255, 127, 0),
"ultra-rare": (255, 0, 255)
}
@classmethod
def strong_against(cls, domain_a: str, domain_b: str) -> bool:
"""
Return True if the first elemental domain should deal more damage than usual to the second.
"""
return (domain_a, domain_b) in cls.BEAT_PAIRS
@classmethod
def weak_against(cls, domain_a: str, domain_b: str) -> bool:
"""
Return True if the first elemental domain should deal less damage than usual to the second.
"""
return cls.strong_against(domain_b, domain_a)
def __init__(self) -> None:
"""
Make a new generator, which is responsible for remembering and producing object types.
"""
# We lazily load special grammars for each type of object
self.grammars: dict[str, LlamaGrammar] = {}
# And we lazily load the model to generate new things
self.model: Optional[Llama] = None
@types.coroutine
def download_model(self) -> Generator[tuple[int, int, str], None, None]:
"""
Download the model file we are meant to use.
Doesn't download it twice.
Yields progress events.
"""
source_url = self.MODEL_URLS[self.MODEL]
dest_path = self.MODEL
MEGABYTE = 1024 * 1024
if not os.path.exists(dest_path):
with open(dest_path + ".tmp", "wb") as out_handle:
yield (0, 0, f"Downloading {dest_path}")
with urlopen(source_url) as handle:
expected_length = int(handle.headers.get('Content-Length'))
bytes_read = 0
while True:
yield (bytes_read // MEGABYTE, expected_length // MEGABYTE, f"Downloading {dest_path}")
buffer = handle.read(MEGABYTE)
if len(buffer) == 0:
# Hit EOF
break
bytes_read += len(buffer)
out_handle.write(buffer)
yield (bytes_read // MEGABYTE, expected_length // MEGABYTE, f"Downloading {dest_path}")
os.rename(dest_path + ".tmp", dest_path)
def get_model(self) -> Llama:
"""
Get the model to generate with.
"""
if self.model is None:
if not os.path.exists(self.MODEL):
print("Download model")
task = self.download_model()
for _ in task:
# Run the generator to completion
pass
print("Load model")
self.model = Llama(
model_path=self.MODEL,
)
return self.model
def get_grammar(self, object_type: str) -> LlamaGrammar:
"""
Get the grammar to use to generate the given type of object.
"""
if object_type not in self.grammars:
object_grammar = open(os.path.join("grammars", f"{object_type}.gbnf")).read()
common_grammar = open(os.path.join("grammars", "common.gbnf")).read()
self.grammars[object_type] = LlamaGrammar.from_string("\n".join([object_grammar, common_grammar]))
return self.grammars[object_type]
def invent_object(self, object_type: str, **features) -> dict:
"""
Generate a new, never-before-seen object of the given type, with the given parameters.
"""
# Get a prompt with two random examples in it
chosen_examples = random.sample(self.EXAMPLES[object_type], 2)
prompt = self.PROMPTS[object_type].format(chosen_examples[0], chosen_examples[1])
# Add any existing keys, leaving off the closing brace and adding a trailing comma
prompt += "\n\n```\n" + json.dumps(features, indent=2)[:-1].rstrip() + "," if len(features) > 0 else ""
# Run the model
result = self.get_model()(prompt, grammar=self.get_grammar(object_type), stop=["\n\n"], max_tokens=-1, mirostat_mode=2, temperature=0.7)
# Grab the text
result_text = result["choices"][0]["text"]
print(result_text)
# Load up whatever keys it came up with
new_keys = json.loads("{" + result_text)
# Combine the keys together
obj = dict(features)
obj.update(new_keys)
print(f"Invented: {obj}")
return obj
def select_rarity(self, level_number: int) -> str:
"""
Select a rarity level for an item at the given dungeon level.
"""
if random.random() < 0.01:
return "ultra-rare"
else:
return random.choice(["common"] * 16 + ["uncommon"] * 8 * level_number + ["rare"] * level_number * level_number)
def select_object(self, object_type: str, rarity: str, elemental_domain: Optional[str] = None) -> dict:
"""
Get a dict defining an object of the given type and rarity.
Rarity can be "common", "uncommon", "rare", or "ultra-rare".
All types have:
"symbol",
"name",
"definite_article",
"indefinite_article",
"nominative_pronoun",
"elemental_domain"
"enemy": additionally has "health".
"obstacle": has no additional fields.
"loot": additionally has a "value".
"""
if elemental_domain is None:
# Pick an elemental domain if not provided
elemental_domain = self.select_domain()
# Within each we have some number of types.
type_count = {
"common": 7,
"uncommon": 5,
"rare": 3,
"ultra-rare": 1
}[rarity]
type_num = random.randrange(0, type_count)
# Where should that file be?
path = os.path.join("objects", object_type, elemental_domain, rarity, f"{type_num}.json")
if not os.path.exists(path):
# Make directory
os.makedirs(os.path.dirname(path), exist_ok=True)
# Invent the object type
obj = self.invent_object(object_type, rarity=rarity, elemental_domain=elemental_domain)
# And save it
json.dump(obj, open(path, 'w'))
return json.load(open(path))
def select_domain(self, parent_domain: Optional[str] = None):
"""
Pick an elemental domain, possibly given an elemental domain of the containing thing.
"""
if parent_domain is None or random.random() < self.DIFFERENT_DOMAIN_CHANCE:
# Don't match the parent
return random.choice(self.ELEMENTAL_DOMAINS)
else:
# Match the parent
return parent_domain
class GameState:
"""
Game state base class.
Can be swapped between for a game state state machine.
"""
def render_to(self, console: tcod.console.Console) -> None:
"""
Render this state to the given console.
"""
raise NotImplementedError()
def handle_event(self, event: Optional[tcod.event.Event]) -> Optional["GameState"]:
"""
Handle the given user input event.
Event can be None if we are calling this method because it hasn't been
called in a while; it is also the tick method.
Returns the next state, or None to keep the current state.
"""
raise NotImplementedError()
def get_wait(self) -> float:
return 0.1
class WorldObject:
"""
Represents an object in the world.
"""
NOMINATIVE_TO_ACCUSATIVE = {
"it": "it",
"he": "him",
"she": "her",
"they": "them"
}
HAS_HAVE = {
"it": "has",
"he": "has",
"she": "has",
"they": "have"
}
IS_ARE = {
"it": "is",
"he": "is",
"she": "is",
"they": "are"
}
def __init__(
self,
x: int,
y: int,
symbol: str = "?",
color: str = "#ffffff",
name: str = "object",
indefinite_article: Optional[str] = None,
definite_article: Optional[str] = None,
nominative_pronoun: str = "it",
rarity: str = "common",
elemental_domain: str = "normal",
z_layer: int = 0
) -> None:
self.x = x
self.y = y
self.symbol = symbol
self.fg = self.hex_to_rgb(color)
self.name = name
if self.name is None:
# Oops we rolled a none name with a bad grammar
self.name = "(unnamed)"
# Drop articles from the name itself, the model loves to put them there.
if self.name.startswith("the "):
self.name = self.name[4:]
if self.name.startswith("a "):
self.name = self.name[2:]
if self.name.startswith("an "):
self.name = self.name[3:]
self.indefinite_article = indefinite_article
if self.indefinite_article == "a" and self.name[0].lower() in "aeiou":
# Fix article
self.indefinite_article = "an"
elif self.indefinite_article == "an" and self.name[0].lower() not in "aeiou":
# Fix article the other way
self.indefinite_article = "a"
self.definite_article = definite_article
self.nominative_pronoun = nominative_pronoun
self.accusative_pronoun = self.NOMINATIVE_TO_ACCUSATIVE[nominative_pronoun]
self.has_have = self.HAS_HAVE[nominative_pronoun]
self.is_are = self.IS_ARE[nominative_pronoun]
self.rarity = rarity
self.elemental_domain = elemental_domain
self.z_layer = z_layer
def hex_to_rgb(self, hex_code: str) -> tuple[int, int, int]:
"""
Convert a hex color code with leading # to an RGB tuple out of 255.
See <https://stackoverflow.com/a/71804445>
"""
return tuple(int(hex_code[i:i+2], 16) for i in (1, 3, 5))
def definite_name(self) -> str:
"""
Get the name of the object formatted with a definite article, if applicable.
"""
parts = []
if self.definite_article:
parts.append(self.definite_article)
parts.append(self.name)
return " ".join(parts)
def indefinite_name(self) -> str:
"""
Get the name of the object formatted with an indefinite article, if applicable.
"""
parts = []
if self.indefinite_article is not None:
parts.append(self.indefinite_article)
parts.append(self.name)
return " ".join(parts)
def rarity_symbol(self) -> str:
"""
Get a symbol denoting the object's rarity level.
"""
return ProceduralGenerator.RARITY_SYMBOLS[self.rarity]
def rarity_color(self) -> tuple[int, int, int]:
"""
Get a color denoting the object's rarity level.
"""
return ProceduralGenerator.RARITY_COLORS[self.rarity]
def domain_symbol(self) -> str:
"""
Get a symbol denoting the object's elemental domain.
"""
return ProceduralGenerator.DOMAIN_SYMBOLS[self.elemental_domain]
def domain_color(self) -> tuple[int, int, int]:
"""
Get a color denoting the object's elemental domain.
"""
return ProceduralGenerator.DOMAIN_COLORS[self.elemental_domain]
def hit(self, other: "GameObject") -> tuple[int, str]:
"""
Hit one game object with another.
Return the damage that should be dealt, and an adverb describing the effectiveness, like "brutally" or "futilely", or an empty string for normal.
"""
effectivenes = ""
if isinstance(self, Player):
# Unarmed players can't do much damage
max_damage = 4
effectivenes = "ineffectively"
else:
# Rarer items get bigger dice
max_damage = {
"common": 8,
"uncommon": 10,
"rare": 12,
"ultra-rare": 20
}[self.rarity]
base_damage = random.randint(1, max_damage)
if isinstance(other, Loot):
# The damage is being parried.
base_damage = base_damage // 2
if ProceduralGenerator.strong_against(self.elemental_domain, other.elemental_domain):
return base_damage * 2, "brutally"
elif ProceduralGenerator.weak_against(self.elemental_domain, other.elemental_domain):
return base_damage // 4, "futilely"
else:
return base_damage, effectivenes
class Loot(WorldObject):
"""
Represents a lootable inventory item.
"""
def __init__(self, x: int, y: int, value: int = 0, **kwargs) -> None:
super().__init__(x, y, **kwargs)
# Save the gold value
self.value = value
def ready_message(self) -> str:
"""
Return the message to use when the player readies the item.
"""
rarity_article = "an" if self.rarity.startswith("u") else "a"
return f"You ready {self.definite_name()}, {rarity_article} {self.rarity} item of the {self.elemental_domain} domain."
class Enemy(WorldObject):
"""
Represents an enemy that can be attacked.
"""
def __init__(self, x: int, y: int, health: int = 10, **kwargs):
super().__init__(x, y, **kwargs)
self.max_health = health
self.health = health
# An enemy can be carrying loot items
self.inventory: list[Loot] = []
class Player(WorldObject):
def __init__(self) -> None:
super().__init__(0, 0, symbol="@", name="Player", z_layer=1)
# We collect loot items from enemies
self.inventory: list[Loot] = []
# We have a selected item index, or None for no item.
self.held_item_index: Optional[int] = None
# And we have health
self.max_health = 200
self.health = self.max_health
# And we have a total value score for the items we got
self.value = 0
# And also money
self.money = 0
def acquire_loot(self, loot: Loot) -> None:
"""
Add the given loot to the player's inventory.
"""
self.inventory.append(loot)
self.value += loot.value
def remove_loot(self, loot: Loot) -> None:
"""
Add the given loot to the player's inventory.
"""
# Get rid of it
self.inventory.remove(loot)
# Make sure we're still holding an allowed item, if any.
if self.held_item_index is not None and self.held_item_index >= len(self.inventory):
if len(self.inventory) == 0:
self.held_item_index = None
else:
self.held_item_index -= 1
def get_held_item(self) -> Optional[Loot]:
"""
Get the item the player is using.
"""
if self.held_item_index is None:
return None
return self.inventory[self.held_item_index]
def next_item(self) -> None:
"""
Hold the next item.
"""
if self.held_item_index is None:
if len(self.inventory) > 0:
# Pick first item
self.held_item_index = 0
elif self.held_item_index + 1 < len(self.inventory):
# Pick next item
self.held_item_index += 1
else:
# Pick no item
self.held_item_index = None
def previous_item(self) -> None:
"""
Hold the previous item.
"""
if self.held_item_index is None:
if len(self.inventory) > 0:
# Pick last item
self.held_item_index = len(self.inventory) - 1
elif self.held_item_index - 1 >= 0:
# Pick previous item
self.held_item_index -= 1
else:
# Pick no item
self.held_item_index = None
class Terrain(IntEnum):
VOID = 0
FLOOR = 1
WALL = 2
@staticmethod
def to_symbol(value: "Terrain") -> str:
"""
Get the symbol to represent a kind of terrain.
"""
return {
Terrain.VOID: " ",
Terrain.FLOOR: ".",
Terrain.WALL: "#"
}[value]
class GameWorld:
"""
World of the game.
Holds the player and also the other things on the level.
"""
def __init__(self, level_number: int, player: Player) -> None:
"""
Set up a fresh world, using the given player.
"""
# Remember the level number
self.level_number = level_number
# And the domain which will get filled in when we generate it.
self.level_domain: Optional[str] = None
# Hang on to the player specifically
self.player = player
# Make sure the player starts at full health
self.player.health = self.player.max_health
# But put them in the list of all point objects.
self.objects: List[WorldObject] = [self.player]
# Holds a map of terrins
self.terrain: list[list[Terrain]] = []
# Holds a list of room x, y, width, height tuples
self.rooms: list[tuple[int, int, int, int]] = []
def clear_terrain(self, x: int, y: int) -> None:
"""
Make a new empty terrain of the given size.
"""
self.terrain = [[Terrain.VOID for _ in range(y)] for __ in range(x)]
def get_map_width(self) -> int:
"""
Get the width of the current map.
"""
return len(self.terrain)
def get_map_height(self) -> int:
"""
Get the height of the current map.
"""
return len(self.terrain[0]) if self.terrain else 0
def set_terrain(self, x: int, y: int, value: Terrain, if_value: Optional[Terrain] = None) -> None:
"""
Set a terrain cell to the given value.
If if_value is set, only changes from the given terrain type.
"""
if if_value is None or self.terrain[x][y] == if_value:
self.terrain[x][y] = value
def set_terrain_region(self, x: int, y: int, width: int, height: int, value: Terrain, if_value: Optional[Terrain] = None) -> None:
"""
Set terrain in an area to the given type.
If if_value is set, only changes from the given terrain type.
"""
for i in range(x, x + width):
for j in range (y, y + height):
self.set_terrain(i, j, value, if_value=if_value)
def set_terrain_walls(self, x: int, y: int, width: int, height: int, value: Terrain, if_value: Optional[Terrain] = None) -> None:
"""
Set terrain around the edges of an area to the given type.
If if_value is set, only changes from the given terrain type.
"""
# Just draw 4 wall lines
self.set_terrain_region(x, y, width, 1, value, if_value=if_value)
self.set_terrain_region(x, y + height - 1, width, 1, value, if_value=if_value)
self.set_terrain_region(x, y, 1, height, value, if_value=if_value)
self.set_terrain_region(x + width - 1, y, 1, height, value, if_value=if_value)
def terrain_at(self, x: int, y: int) -> Terrain:
"""
Get the value of the terrain at a location, which may not be in the terrain bounds.
"""
if x < 0 or x >= len(self.terrain):
return Terrain.VOID
if y < 0 or y >= len(self.terrain[x]):
return Terrain.VOID
return self.terrain[x][y]
@types.coroutine
def generate_map(self) -> Generator[tuple[int, int, str], None, None]:
"""
Make a terrain map to paly on.
Structured as a coroutine generator; level is done when it stops.
It yields progress tuples of completed out of total, and progress message.
"""
yield (0, 0, "Generating terrain")
MAP_WIDTH = 32
MAP_HEIGHT = 32
MAP_LEVELS = 5
ROOM_MIN_INTERIOR_SIZE = 3
ROOM_CHANCE = 0.5
self.clear_terrain(MAP_WIDTH, MAP_HEIGHT)
self.rooms = []
bsp = tcod.bsp.BSP(x=0, y=0, width=MAP_WIDTH, height=MAP_HEIGHT)
bsp.split_recursive(
depth=MAP_LEVELS,
min_width=ROOM_MIN_INTERIOR_SIZE + 1,
min_height=ROOM_MIN_INTERIOR_SIZE + 1,
max_horizontal_ratio=1.5,
max_vertical_ratio=1.5
)
node_total = len(list(bsp.post_order()))
node_count = 0
yield (node_count, node_total, "Generating terrain")
for node in bsp.post_order():
# Post-order visits the parent after the children, so we make rooms and then dig to connect them.
if node.children:
# Connect the two child rooms somehow
node1, node2 = node.children
center1 = (node1.x + node1.width // 2, node1.y + node1.height // 2)
center2 = (node2.x + node2.width // 2, node2.y + node2.height // 2)
if not node.horizontal:
# This node is not from a horizontal split itself, so its children will be.
# node1 is left of node2
self.set_terrain_walls(center1[0] - 1, center1[1] - 1, center2[0] - center1[0] + 2, 3, Terrain.WALL, if_value=Terrain.VOID)
self.set_terrain_region(center1[0], center1[1], center2[0] - center1[0], 1, Terrain.FLOOR)
else:
# node1 is above node2
self.set_terrain_walls(center1[0] - 1, center1[1] - 1, 3, center2[1] - center1[1] + 2, Terrain.WALL, if_value=Terrain.VOID)
self.set_terrain_region(center1[0], center1[1], 1, center2[1] - center1[1], Terrain.FLOOR)
else:
# Maybe make a room out of this node.
if random.random() < ROOM_CHANCE:
# Carve out this room
self.set_terrain_region(node.x + 1, node.y + 1, node.width - 2, node.height - 2, Terrain.FLOOR)
self.set_terrain_walls(node.x, node.y, node.width, node.height, Terrain.WALL)
# And remember it to populate.
self.rooms.append((node.x + 1, node.y + 1, node.width - 2, node.height - 2))
# Otherwise leave it alone and just connect to its center
node_count += 1
yield (node_count, node_total, "Generating terrain")
def free_spaces_in(self, x: int, y: int, width: int, height: int, count: int) -> Generator[tuple[int, int], None, None]:
"""
Get the given number of free spaces in the given region.
"""
found = 0
missed = 0
while found < count:
chosen_x = random.randrange(x, x + width)
chosen_y = random.randrange(y, y + height)
if self.free_space_at(chosen_x, chosen_y):
yield (chosen_x, chosen_y)
found += 1
else:
missed += 1
if missed > 100 * count:
raise RuntimeError(f"Extreme bad luck in {x}, {y}, {width}, {height}")
@types.coroutine
def populate_room(self, x: int, y: int, width: int, height: int, generator: ProceduralGenerator) -> Generator[tuple[int, int, str], None, None]:
"""
Place some objects of the gievn type in the given area.
"""
print(f"Populate {x}, {y}, {width}, {height}")
room_domain = generator.select_domain(self.level_domain)
# We keep obstacles walls to stay out of the doors.
free_spaces = (width - 2) * (height - 2)
# Make obstacles
object_count = 0
desired_object_count = random.choice([0] * 7 + [1] * 2 + [3])
if free_spaces >= desired_object_count:
yield (object_count, desired_object_count, "Making obstacles")
for pos in self.free_spaces_in(x + 1, y + 1, width - 2, height - 2, desired_object_count):
# Put something here
object_type = generator.select_object(
"obstacle",
rarity=generator.select_rarity(self.level_number),
elemental_domain=generator.select_domain(room_domain)
)
self.add_object(WorldObject(pos[0], pos[1], **object_type))
object_count += 1
yield (object_count, desired_object_count, "Making obstacles")
# Enemies can block doors
free_spaces = width * height - object_count
# Make enemies
enemy_count = 0
hard_room_count = 2 if self.level_number < 2 else (3 if self.level_number < 5 else 4)
desired_enemy_count = random.choice([0] * 5 + [1] * 2 + [2] + [hard_room_count] * self.level_number)
if free_spaces >= desired_enemy_count:
yield (enemy_count, desired_enemy_count, "Making enemies")
for pos in self.free_spaces_in(x, y, width, height, desired_enemy_count):
# Pick a rarity for the enemy and its item
rarity = generator.select_rarity(self.level_number)
# Make an enemy for probably the room's domain
enemy_domain = generator.select_domain(room_domain)
enemy_type = generator.select_object("enemy", rarity=rarity, elemental_domain=enemy_domain)
if self.level_number > 6:
# Make the enemies stronger until the player loses
enemy_type["health"] *= self.level_number // 3
if enemy_type["health"] > self.level_number * 20:
# Don't let them be too strong too soon
enemy_type["health"] = self.level_number * 20
enemy = Enemy(pos[0], pos[1], **enemy_type)
yield (enemy_count, desired_enemy_count, "Making enemies")
# Make a loot for probably the enemy's domain
loot_domain = generator.select_domain(enemy_domain)
loot_type = generator.select_object("loot", rarity=rarity, elemental_domain=loot_domain)
enemy.inventory.append(Loot(pos[0], pos[1], **loot_type))
self.add_object(enemy)
enemy_count += 1
yield (enemy_count, desired_enemy_count, "Making enemies")
@types.coroutine
def generate_level(self, generator: ProceduralGenerator) -> Generator[tuple[int, int, str], None, None]:
"""
Make a level to play.
Structured as a coroutine generator; level is done when it stops.
It yields progress tuples of completed out of total, and progress message.
"""
# Make sure the model is available
yield from generator.download_model()
# Throw out the old objects, except the player who is here
self.objects = [self.player]
# Pick an elemental domain
self.level_domain = generator.select_domain()
# Make some terrain
yield from self.generate_map()
for room_number, room in enumerate(self.rooms):
# Put stuff in each room
for (done, total, message) in self.populate_room(room[0], room[1], room[2], room[3], generator):
yield (room_number, len(self.rooms), message + f" ({done}/{total}) in room {room_number + 1}")
# Put the player somewhere
for x, y in self.free_spaces_in(0, 0, self.get_map_width(), self.get_map_height(), 1):
# Found a place for the player
self.player.x = x
self.player.y = y
if not self.has_enemies():
# Make sure we have at least one enemy somewhere.
for pos in self.free_spaces_in(0, 0, self.get_map_width(), self.get_map_height(), 1):
# Make just one ultra-rare enemy with nothing
enemy_type = generator.select_object("enemy", rarity="ultra-rare", elemental_domain=self.level_domain)
# Buff it!
enemy_type["health"] *= 2
enemy = Enemy(pos[0], pos[1], **enemy_type)
self.add_object(enemy)
def object_at(self, x: int, y: int) -> Optional[WorldObject]:
"""
Get the object at the given coordinates, or None.
"""
for obj in self.objects:
if obj.x == x and obj.y == y:
return obj
return None
def free_space_at(self, x: int, y: int) -> bool:
"""
Return True if there is free space for an object at the given position.
"""
return self.terrain_at(x, y) == Terrain.FLOOR and self.object_at(x, y) is None
def has_enemies(self) -> bool:
"""
Return True if any enemies are left.
"""
for obj in self.objects:
if isinstance(obj, Enemy):
return True
return False
def remove_object(self, obj: WorldObject) -> None:
"""
Remove the given object from the world.
"""
self.objects.remove(obj)
def add_object(self, obj: WorldObject) -> None:
"""
Add the given object to the world.
"""
self.objects.append(obj)
def draw(self, console: tcod.console.Console, x: int, y: int, width: int, height: int) -> None:
"""
Draw the world centere don the player into a region of the given console.
"""
# Make sure higher-Z objects draw on top
self.objects.sort(key=lambda o: o.z_layer)
# Find where to put the view upper left corner to center the player
view_x = self.player.x - width // 2
view_y = self.player.y - height // 2
# Draw the terrain
for x_in_view in range(width):
world_x = x_in_view + view_x
for y_in_view in range(height):
world_y = y_in_view + view_y
console.print(x_in_view + x, y_in_view + y, Terrain.to_symbol(self.terrain_at(world_x, world_y)))
for to_render in self.objects:
# Draw all the objects
x_in_view = to_render.x - view_x
y_in_view = to_render.y - view_y
if x_in_view >= 0 and x_in_view < width and y_in_view >= 0 and y_in_view < height:
console.print(x_in_view + x, y_in_view + y, to_render.symbol, fg=to_render.fg)