Python script to generate simple "dungeon maps"
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

CellMap.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. """ A tool for randomly generating maps.
  2. It starts by populating a grid with randomized True/False values and
  3. then uses a "cellular automata"-based smoothing algorithm to build a
  4. map.
  5. Maps are rectangles, but can be of any size. Naturally, larger maps
  6. take longer to generate.
  7. By default, the mapper will print to the screen as a grid of "I"s (walls)
  8. and spaces (paths). You can tell the mapper to print to an image instead.
  9. If you do, the following apply:
  10. You can tell the mapper to make a map "chunky", which keeps the T/F
  11. grid the same size but uses four pixels instead of one for each point
  12. on the grid, doubling the size of the final generated image.
  13. Maps are two-color: black and white by default, but it will use random
  14. contrasting colors if told to.
  15. You can tell the mapper to insert treasure, which appears as a third
  16. color on the map.
  17. """
  18. __author__ = "Noëlle Anthony"
  19. __version__ = "1.0.0"
  20. import random
  21. import sys
  22. import os
  23. from PIL import Image
  24. class CellMap:
  25. initial = []
  26. genmap = []
  27. treasurelist = []
  28. def __init__(self, height=None, width=None, seed=None, death=None,
  29. birth=None, reps=0, out=None, color=None, chunky=None,
  30. treasure=None):
  31. self.height = height if height != None else 0
  32. self.width = width if width != None else 0
  33. self.seed = seed if seed != None else 0
  34. self.death = death if death != None else 0
  35. self.birth = birth if birth != None else 0
  36. self.reps = reps if reps != None else 0
  37. self.out = out if out != None else False
  38. self.color = color if color != None else False
  39. self.chunky = chunky if chunky != None else False
  40. self.treasure = treasure if treasure != None else False
  41. self.id = filename()
  42. @property
  43. def height(self):
  44. return self.__height
  45. @height.setter
  46. def height(self, height):
  47. self.__height = int(height) if int(height) > 0 else 0
  48. @property
  49. def width(self):
  50. return self.__width
  51. @width.setter
  52. def width(self, width):
  53. self.__width = int(width) if int(width) > 0 else 0
  54. @property
  55. def seed(self):
  56. return self.__seed
  57. @ seed.setter
  58. def seed(self, seed):
  59. self.__seed = int(seed) if int(seed) > 0 else 0
  60. @property
  61. def death(self):
  62. return self.__death
  63. @death.setter
  64. def death(self, death):
  65. self.__death = int(death) if int(death) > 0 else 0
  66. @property
  67. def birth(self):
  68. return self.__birth
  69. @birth.setter
  70. def birth(self, birth):
  71. self.__birth = int(birth) if int(birth) > 0 else 0
  72. @property
  73. def reps(self):
  74. return self.__reps
  75. @reps.setter
  76. def reps(self, reps):
  77. self.__reps = int(reps) if int(reps) > 0 else 0
  78. @property
  79. def out(self):
  80. return self.__out
  81. @out.setter
  82. def out(self, out):
  83. self.__out = bool(out)
  84. @property
  85. def color(self):
  86. return self.__color
  87. @color.setter
  88. def color(self, color):
  89. self.__color = bool(color)
  90. @property
  91. def chunky(self):
  92. return self.__chunky
  93. @chunky.setter
  94. def chunky(self, chunky):
  95. self.__chunky = bool(chunky)
  96. @property
  97. def treasure(self):
  98. return self.__treasure
  99. @treasure.setter
  100. def treasure(self, treasure):
  101. self.__treasure = bool(treasure)
  102. def generateFullMap(self):
  103. """ Puts everything together. Runs the smoothing routine a number
  104. of times equal to self.reps, generates treasure (if self.treasure
  105. is set), and creates and saves an image of the map if self.out is
  106. set or prints the map to stdout if it isn't.
  107. """
  108. self.createMap()
  109. for _ in range(self.reps):
  110. self.smoothMap()
  111. if self.treasure:
  112. self.generateTreasure()
  113. if self.out:
  114. self.createImage()
  115. else:
  116. self.printScreen()
  117. def resetMap(self):
  118. """ Resets the map to its initial state, allowing the user to experiment
  119. with death/birth limits and number of repetitions on a single map.
  120. """
  121. self.genmap = list(self.initial)
  122. def createMap(self):
  123. """ Initializes an x by y grid.
  124. x is width, y is height
  125. seed is the chance that a given cell will be "live" and should be
  126. an integer between 1-99.
  127. If True is equivalent to "wall", then higher seeds make more walls.
  128. """
  129. if self.__height == 0 or self.__width == 0 or self.__seed == 0:
  130. print("Height, width, and seed must be set before creating a map.")
  131. print("Current values: height: {}, width: {}, seed: {}".format(self.height, self.width, self.seed))
  132. return
  133. y = self.height
  134. x = self.width
  135. seed = self.seed
  136. new_map = []
  137. for j in range(y):
  138. new_row = []
  139. for i in range(x):
  140. new_row.append(True if random.randint(1,99) <= seed else False)
  141. new_map.append(new_row)
  142. self.initial = new_map
  143. self.genmap = new_map
  144. def smoothMap(self):
  145. """ Refines the grid using cellular-automaton rules.
  146. If a wall doesn't have enough wall neighbors, it "dies" and
  147. becomes a path. If a path has too many wall neighbors, it turns
  148. into a wall. This is controlled by the values in self.death and
  149. self.birth, respectively.
  150. """
  151. if self.death == 0 or self.birth == 0:
  152. print("The 'death' limit is currently {} and the 'birth' limit is {}.".format(self.death,self.birth))
  153. print("Smoothing with the 'death' or 'birth' limit set to 0 is not recommended.")
  154. print("Do you want to proceed? (y/N) ", end="")
  155. cont = input().strip()
  156. if cont.lower() != "y":
  157. print("Aborting.")
  158. return
  159. d_lmt = self.death
  160. a_lmt = self.birth
  161. new_map = []
  162. for j in range(len(self.genmap)):
  163. new_line = []
  164. for i in range(len(self.genmap[j])):
  165. x, y = i, j
  166. n_count = self.countWalls(x, y)
  167. if self.genmap[y][x]:
  168. # It's a wall.
  169. if n_count < d_lmt:
  170. # It has too few wall neighbors, so kill it.
  171. new_line.append(False)
  172. else:
  173. # It has enough wall neighbors, so keep it.
  174. new_line.append(True)
  175. else:
  176. # It's a path.
  177. if n_count > a_lmt:
  178. # It has too many wall neighbors, so it becomes a wall.
  179. new_line.append(True)
  180. else:
  181. # It's not too crowded, so it stays a path.
  182. new_line.append(False)
  183. new_map.append(new_line)
  184. self.genmap = new_map
  185. def countWalls(self, x, y):
  186. """ Counts the number of wall neighbors a cell has and returns that count.
  187. """
  188. count = 0
  189. for j in range(-1,2):
  190. for i in range(-1,2):
  191. n_x, n_y = x+i, y+j
  192. if i == 0 and j == 0:
  193. continue
  194. if n_x < 0 or n_x >= len(self.genmap[j]) or n_y == 0 or n_y >= len(self.genmap):
  195. # The target cell is at the edge of the map and this neighbor is off the edge.
  196. # So we make this neighbor count as a wall.
  197. count += 1
  198. #pass
  199. elif self.genmap[n_y][n_x] and self.genmap[n_y][n_x] not in ("Gold","Diam"):
  200. # This neighbor is on the map and is a wall.
  201. count += 1
  202. return count
  203. def generateTreasure(self):
  204. """ If a path cell has 5 wall neighbors, put a treasure there.
  205. If a path cell has at least 6 wall neighbors, put a rare treasure.
  206. """
  207. for j in range(len(self.genmap)):
  208. for i in range(len(self.genmap[j])):
  209. if not self.genmap[j][i]:
  210. self.genmap[j][i] = "Gold" if self.countWalls(i,j) == 5 else self.genmap[j][i]
  211. self.genmap[j][i] = "Diam" if self.countWalls(i,j) >= 6 else self.genmap[j][i]
  212. def printScreen(self):
  213. """ Prints the map to standard out, using "II" for a wall
  214. and " " for a path.
  215. The "color", "chunky", and "treasure" options don't affect
  216. this mode.
  217. """
  218. wall = "II"
  219. path = " "
  220. gold = "GG"
  221. diam = "DD"
  222. for line in self.genmap:
  223. print("".join([path if not x
  224. else (gold if x == "Gold"
  225. else (diam if x == "Diam"
  226. else wall)) for x in line]))
  227. print()
  228. def createImage(self):
  229. """ Creates and saves an image of the map.
  230. If self.color is True, the map uses randomized complementary
  231. colors; otherwise, it uses black for walls, white for paths, and
  232. light grey for treasures.
  233. If self.chunky is True, the map uses 4 pixels for each cell
  234. instead of one. This results in an image that's twice as large,
  235. and is useful for enlarging smaller maps without the added runtime
  236. of actually generating a larger map.
  237. If an image with the current map's name already exists, the script
  238. will add digits after the filename but before the extension, to
  239. avoid a collision. While the possibility of a name collision is
  240. low, this allows you to make several copies of a given map (for
  241. example, with different settings) without fear of overwriting
  242. your previous maps.
  243. """
  244. x, y = len(self.genmap[0]), len(self.genmap)
  245. if self.chunky:
  246. true_x, true_y = x*2, y*2
  247. else:
  248. true_x, true_y = x, y
  249. img = Image.new("RGB",(true_x,true_y),(0,0,0))
  250. lst = []
  251. # Walls are black by default
  252. c_wall = [random.randint(0,255), random.randint(0,255), random.randint(0,255)] if self.color else [0,0,0]
  253. # Paths are white by default
  254. c_space = [255-x for x in c_wall]
  255. c_gold = [(x+64)%255 for x in c_space]
  256. c_diam = [(x+64)%255 for x in c_gold]
  257. if self.chunky:
  258. for line in self.genmap:
  259. for _ in range(2):
  260. for val in line:
  261. for _ in range(2):
  262. if not val:
  263. lst.append(tuple(c_space))
  264. elif val == "Gold":
  265. lst.append(tuple(c_gold))
  266. elif val == "Diam":
  267. lst.append(tuple(c_diam))
  268. else:
  269. lst.append(tuple(c_wall))
  270. else:
  271. for line in self.genmap:
  272. for val in line:
  273. if not val:
  274. lst.append(tuple(c_space))
  275. elif val == "Gold":
  276. lst.append(tuple(c_gold))
  277. elif val == "Diam":
  278. lst.append(tuple(c_diam))
  279. else:
  280. lst.append(tuple(c_wall))
  281. img.putdata(lst)
  282. if not os.path.exists("maps"):
  283. os.makedirs("maps")
  284. fn = self.id
  285. i = 0
  286. while os.path.exists("maps/{}.png".format(fn)):
  287. i += 1
  288. fn = self.id + "-" + str(i)
  289. img.save('maps/{}.png'.format(fn))
  290. print("Saved maps/{}.png".format(fn))
  291. def printArray(self):
  292. """ This prints the map as a list of lists of True/False values,
  293. possibly useful for importing into other scripts or for uses
  294. other than generating maps.
  295. """
  296. print("[",end="\n")
  297. for line in self.genmap:
  298. print("\t{},".format(line))
  299. print("]")
  300. def filename():
  301. """ Creates a 16-character hexadecimal ID.
  302. Since the number of results is so large (16^16), the chance of
  303. a collision is very small.
  304. """
  305. hexes = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"]
  306. fn = []
  307. for _ in range(16):
  308. fn.append(random.choice(hexes))
  309. return "".join(fn)
  310. def parseArgs(args):
  311. """ Parses the command-line arguments sent to the script.
  312. Discards anything that isn't a recognized as a valid flag.
  313. """
  314. flags = {
  315. "--height" : 20,
  316. "--width" : 20,
  317. "--seed" : 45,
  318. "--death" : 4,
  319. "--birth" : 4,
  320. "--reps" : 2,
  321. "--out" : False,
  322. "--color" : False,
  323. "--chunky" : False,
  324. "--treasure": False,
  325. }
  326. for flag, default in flags.items():
  327. if flag in args:
  328. if flag == "--out":
  329. flags["--out"] = True
  330. elif flag == "--color":
  331. flags["--color"] = True
  332. elif flag == "--chunky":
  333. flags["--chunky"] = True
  334. elif flag == "--treasure":
  335. flags["--treasure"] = True
  336. else:
  337. flags[flag] = args[args.index(flag) + 1]
  338. return flags
  339. def main(args):
  340. flags = parseArgs(args)
  341. my_map = CellMap(flags["--height"],flags["--width"],flags["--seed"],flags["--death"],
  342. flags["--birth"],flags["--reps"],flags["--out"],flags["--color"],
  343. flags["--chunky"],flags["--treas"],)
  344. my_map.generateFullMap()
  345. if __name__ == "__main__":
  346. main(sys.argv)