├── .gitignore
├── images
└── roguelike_image.png
├── LICENSE
├── README.md
├── effect.py
├── utils.py
├── entity.py
├── board.py
├── roguelike.py
├── gameobj.py
├── items.py
├── player.py
└── monster.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | *.pyc
3 | *.pickle
4 | __pycache__/
--------------------------------------------------------------------------------
/images/roguelike_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fungamer2-2/VeraDungeon-Rogue/HEAD/images/roguelike_image.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 fungamer2-2
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VeraDungeon Rogue
2 |
3 | 
4 | 
5 | 
6 |
7 | A terminal-based CLI roguelike game in Python
8 |
9 | Discord Server: https://discord.gg/dKRGTgxT65
10 |
11 | Current version: 0.5
12 | (Note: This is currently a work in progress)
13 |
14 | 
15 |
16 | ### How to run the game
17 | - Install Python from https://www.python.org/
18 | (Note: Must have at least Python 3.8)
19 |
20 | - Download the repository, extract the zip file, then run the command `python3 roguelike.py`
21 |
22 | The game controls can be found in the game.
23 |
24 | ### Gameplay
25 | Your goal is to fight the monsters in a randomly generated dungeon. Move into a monster to attack.
26 | Monsters will start off unaware of you, but depending on the result of a stealth check, there is a chance they will notice you.
27 | Monsters who don't yet notice you will have a white background. If you manage to attack an unaware monster, you may be able to make a sneak attack to deal bonus damage.
28 | There are also various potions that you can use to help you.
29 | There is no end goal at the moment, right now, it's just "see how long you can survive." I may consider eventually adding an end goal in the future.
30 |
--------------------------------------------------------------------------------
/effect.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | class Effect:
4 | name = "Generic Effect"
5 |
6 | def __init__(self, duration, add_msg, rem_msg):
7 | self.duration = duration
8 | self.add_msg = add_msg
9 | self.rem_msg = rem_msg
10 |
11 | def on_expire(self, player):
12 | pass
13 |
14 | class Lethargy(Effect):
15 | name = "Lethargy"
16 |
17 | def __init__(self, duration):
18 | super().__init__(duration, "You begin to feel lethargic.", "Your energy returns.")
19 |
20 | class Haste(Effect):
21 | name = "Haste"
22 |
23 | def __init__(self, duration):
24 | super().__init__(duration, "You begin to move faster.", "Your extra speed runs out.")
25 |
26 | def on_expire(self, player):
27 | g = player.g
28 | player.gain_effect("Lethargy", random.randint(4, 10))
29 |
30 | class Resistance(Effect):
31 | name = "Resistance"
32 |
33 | def __init__(self, duration):
34 | super().__init__(duration, "You feel more resistant to damage.", "You feel vulnerable again.")
35 |
36 | class Invisible(Effect):
37 | name = "Invisible"
38 |
39 | def __init__(self, duration):
40 | super().__init__(duration, "You become invisible.", "You become visible again.")
41 |
42 | class Rejuvenated(Effect):
43 | name = "Rejuvenated"
44 |
45 | def __init__(self, duration):
46 | super().__init__(duration, "You begin to feel extremely rejuvenated.", "The rejuvenation wears off.")
47 |
48 | class Clairvoyance(Effect):
49 | name = "Clairvoyance"
50 |
51 | def __init__(self, duration):
52 | super().__init__(duration, "You feel much more perceptive.", "Your clairvoyance fades.")
53 |
54 | class Confused(Effect):
55 | name = "Confused"
56 |
57 | def __init__(self, duration):
58 | super().__init__(duration, "You feel confused.", "You are no longer confused.")
59 |
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | import random, math
2 |
3 | def dice(num, sides):
4 | "Rolls a given number of dice with a given number of dice and takes the sum"
5 | return sum(random.randint(1, sides) for _ in range(num))
6 |
7 | def div_rand(x, y):
8 | "Computes x/y then randomly rounds the result up or down depending on the remainder"
9 | sign = 1
10 | if (x > 0) ^ (y > 0):
11 | sign = -1
12 | x = abs(x)
13 | y = abs(y)
14 | mod = x % y
15 | return sign * (x//y + (random.randint(1, y) <= mod))
16 |
17 | def mult_rand_frac(num, x, y):
18 | return div_rand(num*x, y)
19 |
20 | def rand_weighted(*pairs):
21 | names, weights = list(zip(*pairs))
22 | return random.choices(names, weights=weights)[0]
23 |
24 | def d20_prob(DC, mod, nat1=False, nat20=False):
25 | num_over = 21 - DC + mod
26 | if nat1:
27 | num_over = min(num_over, 19)
28 | if nat20:
29 | num_over = max(num_over, 1)
30 | return max(0, min(1, num_over/20))
31 |
32 | def to_hit_prob(AC, hit_mod=0, adv=False, disadv=False):
33 | """
34 | Calculates the percentage chance of successfully landing a hit
35 | adv - If true, calculates the probability with advantage
36 | disadv - If true, calculates the probability with disadvantage
37 | """
38 | if adv and disadv:
39 | adv = False
40 | disadv = False
41 | res = d20_prob(AC, hit_mod, True, True)
42 | if adv:
43 | res = 1-((1 - res)**2)
44 | elif disadv:
45 | res = res**2
46 | return round(res, 3)
47 |
48 | def calc_mod(stat, avg=False):
49 | m = stat - 10
50 | if avg:
51 | return m / 2
52 | else:
53 | return div_rand(m, 2)
54 |
55 | def one_in(x):
56 | return x <= 1 or random.randint(1, x) == 1
57 |
58 | def x_in_y(x, y):
59 | return random.randint(1, y) <= x
60 |
61 | def binomial(num, x, y=100):
62 | return sum(1 for _ in range(num) if x_in_y(x, y))
63 |
64 | def display_prob(perc):
65 | if perc <= 0:
66 | return "0%"
67 | if perc >= 100:
68 | return "100%"
69 | if perc <= 0.5:
70 | return "<1%"
71 | if perc >= 99.5:
72 | return ">99%"
73 | if perc < 50:
74 | perc = math.ceil(perc - 0.5)
75 | else:
76 | perc = math.floor(perc + 0.5)
77 | return f"{perc}%"
78 |
79 | class Dice:
80 |
81 | def __init__(self, num, sides):
82 | self.num = num
83 | self.sides = sides
84 |
85 | def avg(self):
86 | return self.num * (self.sides + 1) // 2
87 |
88 | def roll(self):
89 | return dice(self.num, self.sides)
90 |
91 | def max(self):
92 | return self.num*self.sides
--------------------------------------------------------------------------------
/entity.py:
--------------------------------------------------------------------------------
1 | import random
2 | from collections import deque
3 | from board import pathfind
4 |
5 | class Entity:
6 |
7 | def __init__(self, g):
8 | self.g = g
9 | self.x = 0
10 | self.y = 0
11 | self.curr_target = None
12 | self.curr_path = deque()
13 | self.placed = False
14 | self.energy = 0 #How many energy points this entity has. Used to control movement speed.
15 | self.fov = set()
16 |
17 | def calc_fov(self):
18 | "Calculates all tiles an entity can see from the current position"
19 | board = self.g.board
20 | fov = set()
21 | fov.add((self.x, self.y))
22 | #Raycasting step
23 | for x in range(board.cols):
24 | for point in board.line_between((self.x, self.y), (x, 0), skipfirst=True):
25 | fov.add(point)
26 | if board.blocks_sight(*point):
27 | break
28 | for point in board.line_between((self.x, self.y), (x, board.rows - 1), skipfirst=True):
29 | fov.add(point)
30 | if board.blocks_sight(*point):
31 | break
32 | for y in range(1, board.rows - 1):
33 | for point in board.line_between((self.x, self.y), (0, y), skipfirst=True):
34 | fov.add(point)
35 | if board.blocks_sight(*point):
36 | break
37 | for point in board.line_between((self.x, self.y), (board.cols - 1, y), skipfirst=True):
38 | fov.add(point)
39 | if board.blocks_sight(*point):
40 | break
41 |
42 | #Post-processing step
43 | seen = set()
44 | for cell in fov.copy():
45 | if board.blocks_sight(*cell):
46 | continue
47 | x, y = cell
48 | dx = x - self.x
49 | dy = y - self.y
50 | neighbors = {(x-1, y), (x+1, y), (x, y-1), (x, y+1)}
51 | neighbors -= seen
52 | neighbors -= fov
53 | for xp, yp in neighbors:
54 | seen.add((xp, yp))
55 | if not (0 <= xp < board.cols):
56 | continue
57 | if not (0 <= yp < board.cols):
58 | continue
59 | if board.blocks_sight(xp, yp):
60 | visible = False
61 | dxp = xp - x
62 | dyp = yp - y
63 | if dx <= 0 and dy <= 0:
64 | visible = dxp <= 0 or dyp <= 0
65 | if dx >= 0 and dy <= 0:
66 | visible = dxp >= 0 or dyp <= 0
67 | if dx <= 0 and dy >= 0:
68 | visible = dxp <= 0 or dyp >= 0
69 | if dx >= 0 and dy >= 0:
70 | visible = dxp >= 0 or dyp >= 0
71 | if visible:
72 | fov.add((xp, yp))
73 |
74 | return fov
75 |
76 | def can_see(self, x, y):
77 | return (x, y) in self.fov
78 |
79 | def distance(self, other, manhattan=True):
80 | dx = abs(self.x - other.x)
81 | dy = abs(self.y - other.y)
82 | if manhattan:
83 | return dx + dy
84 | return max(dx, dy)
85 |
86 | def distance_pos(self, pos):
87 | return abs(self.x - pos[0]) + abs(self.y - pos[1])
88 |
89 | def clear_path(self):
90 | self.curr_path.clear()
91 |
92 | def path_towards(self, x, y, maxlen=None):
93 | if self.curr_target == (x, y) and self.curr_path and self.move_to(*self.curr_path.popleft()):
94 | if (self.x, self.y) == (x, y):
95 | self.clear_path()
96 | return
97 | path = pathfind(self.g.board, (self.x, self.y), (x, y), rand=True)
98 | if len(path) < 2:
99 | return
100 | if maxlen and len(path) > maxlen+1:
101 | return
102 | currX, currY = self.x, self.y
103 | self.curr_target = (x, y)
104 | self.curr_path = deque(path[1:])
105 | newX, newY = self.curr_path.popleft()
106 | dx = newX - currX
107 | dy = newY - currY
108 | self.move(dx, dy)
109 |
110 | def set_path(self, path):
111 | self.curr_path = deque(path)
112 |
113 | def can_place(self, x, y):
114 | if (x, y) == (self.g.player.x, self.g.player.y):
115 | return False
116 | board = self.g.board
117 | if not board.is_passable(x, y):
118 | return False
119 | neighbors = [(x+1, y), (x-1, y), (x, y+1), (x, y-1)]
120 | for xp, yp in neighbors:
121 | if board.is_passable(xp, yp):
122 | return True
123 | return False
124 |
125 | def place_randomly(self):
126 | board = self.g.board
127 | for _ in range(200):
128 | x = random.randint(1, board.cols - 2)
129 | y = random.randint(1, board.rows - 2)
130 | if self.can_place(x, y):
131 | break
132 | else: #We couldn't place the player randomly, so let's search all possible positions in a random order
133 | row_ind = list(range(1, board.rows - 1))
134 | random.shuffle(row_ind)
135 | found = False
136 | for ypos in row_ind:
137 | col_ind = list(range(1, board.cols - 1))
138 | random.shuffle(col_ind)
139 | for xpos in col_ind:
140 | if self.can_place(xpos, ypos):
141 | x, y = xpos, ypos
142 | found = True
143 | break
144 | if found:
145 | break
146 | else:
147 | return False
148 | self.place_at(x, y)
149 | return True
150 |
151 | def place_at(self, x, y):
152 | old = (self.x, self.y)
153 | self.x = x
154 | self.y = y
155 | if self.placed:
156 | self.g.board.swap_cache(old, (self.x, self.y))
157 | else:
158 | self.placed = True
159 | self.g.board.set_cache(x, y, self)
160 |
161 | def swap_with(self, other):
162 | tmp = (self.x, self.y)
163 | self.x, self.y = other.x, other.y
164 | other.x, other.y = tmp
165 |
166 | def can_move(self, x, y):
167 | return self.g.board.is_passable(x, y)
168 |
169 | def move_to(self, x, y):
170 | board = self.g.board
171 | if self.can_move(x, y):
172 | oldpos = (self.x, self.y)
173 | self.x = x
174 | self.y = y
175 | self.g.board.swap_cache(oldpos, (self.x, self.y))
176 | return True
177 | return False
178 |
179 | def move(self, dx, dy):
180 | return self.move_to(self.x + dx, self.y + dy)
--------------------------------------------------------------------------------
/board.py:
--------------------------------------------------------------------------------
1 | import random, math
2 | from utils import *
3 |
4 | class Tile:
5 |
6 | def __init__(self, passable, symbol, stair=False):
7 | self.passable = passable
8 | assert len(symbol) == 1, "Symbol must be exactly one character"
9 | self.symbol = symbol
10 | self.revealed = False
11 | self.walked = False
12 | self.stair = stair
13 | self.items = []
14 |
15 | class Board:
16 |
17 | def __init__(self, g, cols, rows):
18 | self.g = g
19 | self.cols = cols
20 | self.rows = rows
21 | self.data = [[Tile(True, " ") for x in range(cols)] for y in range(rows)]
22 | self.clear_cache()
23 |
24 | def clear_cache(self):
25 | self.mons_cache = [[None for x in range(self.cols)] for y in range(self.cols)]
26 |
27 | def line_between(self, pos1, pos2, skipfirst=False, skiplast=False):
28 | x1, y1 = pos1
29 | x2, y2 = pos2
30 | dx = abs(x2 - x1)
31 | sx = 1 if x1 < x2 else -1
32 | dy = -abs(y2 - y1)
33 | sy = 1 if y1 < y2 else -1
34 | error = dx + dy
35 | while True:
36 | doyield = True
37 | if (skipfirst and (x1, y1) == pos1) or (skiplast and (x1, y1) == pos2):
38 | doyield = False
39 | if doyield:
40 | yield (x1, y1)
41 | if (x1, y1) == (x2, y2):
42 | return
43 | e2 = 2 * error
44 | if e2 >= dy:
45 | if x1 == x2:
46 | return
47 | error += dy
48 | x1 += sx
49 | if e2 <= dx:
50 | if y1 == y2:
51 | return
52 | error += dx
53 | y1 += sy
54 |
55 | def in_bounds(self, x, y):
56 | return 0 <= x < self.cols and 0 <= y < self.rows
57 |
58 | def line_of_sight(self, pos1, pos2):
59 | for point in self.line_between(pos1, pos2, skiplast=True):
60 | if self.blocks_sight(*point):
61 | return False
62 | return True
63 |
64 | def is_clear_path(self, pos1, pos2):
65 | for point in self.line_between(pos1, pos2, skipfirst=True, skiplast=True):
66 | if not self.is_passable(*point):
67 | return False
68 | return True
69 |
70 | def get_in_radius(self, pos, radius):
71 | x, y = pos
72 | for px in range(x-radius, x+radius+1):
73 | for py in range(y-radius, y+radius+1):
74 | if (px, py) != pos and self.in_bounds(x, y):
75 | yield (px, py)
76 |
77 | def get_in_circle(self, pos, radius):
78 | cx, cy = pos
79 | for x, y in self.get_in_radius(pos, radius):
80 | dx = x - cx
81 | dy = y - cy
82 | dist = math.sqrt(dx**2 + dy**2)
83 | if round(dist) > radius or not self.in_bounds(x, y):
84 | continue
85 | yield x, y
86 |
87 | def get_in_cone(self, pos, radius, angle, widthdeg=45):
88 | cx, cy = pos
89 | angle %= 360
90 | for x, y in self.get_in_radius(pos, radius):
91 | dx = x - cx
92 | dy = y - cy
93 | dist = math.sqrt(dx**2 + dy**2)
94 | if round(dist) > radius or not self.in_bounds(x, y):
95 | continue
96 | dir = math.degrees(math.atan2(dy, dx))
97 | half = widthdeg/2
98 | if dir < 0:
99 | dir += 360
100 |
101 | if abs(angle - dir) <= half:
102 | yield x, y
103 | elif angle + half >= 360 and dir <= (angle + half) % 360:
104 | yield x, y
105 | elif angle - half < 0 and dir >= angle-half+360:
106 | yield x, y
107 |
108 | #A monster collision cache is used to improve the performance of detecting collisions with monsters
109 | #This way, checking if there's a monster at a position can be O(1) instead of O(m)
110 |
111 | def set_cache(self, x, y, mon):
112 | self.mons_cache[y][x] = mon
113 |
114 | def unset_cache(self, x, y):
115 | self.mons_cache[y][x] = None
116 |
117 | def get_mon_cache(self, x, y):
118 | return self.mons_cache[y][x]
119 |
120 | def swap_cache(self, pos1, pos2):
121 | if pos1 == pos2:
122 | return
123 | x1, y1 = pos1
124 | x2, y2 = pos2
125 | tmp = self.mons_cache[y1][x1]
126 | self.mons_cache[y1][x1] = self.mons_cache[y2][x2]
127 | self.mons_cache[y2][x2] = tmp
128 |
129 | def blocks_sight(self, col, row):
130 | if (col, row) == (self.g.player.x, self.g.player.y):
131 | return False
132 | return not self.get(col, row).passable
133 |
134 | def is_passable(self, col, row):
135 | if self.blocks_sight(col, row):
136 | return False
137 | return not self.mons_cache[row][col]
138 |
139 | def generate(self):
140 | self.data = [[Tile(False, "#") for x in range(self.cols)] for y in range(self.rows)]
141 | self.clear_cache()
142 | WIDTH_RANGE = (5, 10)
143 | HEIGHT_RANGE = (3, 5)
144 | ATTEMPTS = 100
145 | NUM = random.randint(5, 8)
146 | rooms = []
147 | randchance = dice(2, 10)
148 | if one_in(7):
149 | randchance = 100
150 | for i in range(NUM):
151 | for _ in range(ATTEMPTS):
152 | width = random.randint(*WIDTH_RANGE)
153 | height = random.randint(*HEIGHT_RANGE)
154 | xpos = random.randint(1, self.cols - width - 1)
155 | ypos = random.randint(1, self.rows - height - 1)
156 | for x, y, w, h in rooms:
157 | flag = True
158 | if x + w < xpos or xpos + width < x:
159 | flag = False
160 | elif y + h < ypos or ypos + height < y:
161 | flag = False
162 | if flag:
163 | break
164 | else:
165 | for x in range(width):
166 | for y in range(height):
167 | self.carve_at(xpos + x, ypos + y)
168 | if i > 0:
169 | prev = rooms[-1]
170 | if random.randint(1, randchance) == 1:
171 | prev = random.choice(rooms)
172 | x, y, w, h = prev
173 | pos1_x = x + random.randint(1, w - 2)
174 | pos1_y = y + random.randint(1, h - 2)
175 | pos2_x = xpos + random.randint(1, width - 2)
176 | pos2_y = ypos + random.randint(1, height - 2)
177 | dx = 1 if pos1_x < pos2_x else -1
178 | dy = 1 if pos1_y < pos2_y else -1
179 | if one_in(2):
180 | x = pos1_x
181 | while x != pos2_x:
182 | self.carve_at(x, pos1_y)
183 | x += dx
184 | y = pos1_y
185 | while y != pos2_y:
186 | self.carve_at(pos2_x, y)
187 | y += dy
188 | else:
189 | y = pos1_y
190 | while y != pos2_y:
191 | self.carve_at(pos1_x, y)
192 | y += dy
193 | x = pos1_x
194 | while x != pos2_x:
195 | self.carve_at(x, pos2_y)
196 | x += dx
197 |
198 | rooms.append((xpos, ypos, width, height))
199 | break
200 |
201 | def carve_at(self, col, row):
202 | if not (0 <= col < self.cols and 0 <= row < self.rows):
203 | raise ValueError(f"carve_at coordinate out of range: ({col}, {row})")
204 | self.data[row][col] = Tile(True, " ")
205 |
206 | def get(self, col, row):
207 | return self.data[row][col]
208 |
209 | ###############
210 | #Pathfinding
211 | #Algorithm used is A* Search
212 | from collections import defaultdict
213 |
214 | class OpenSet:
215 |
216 | def __init__(self, key=None):
217 | self._data = []
218 | self._dup = set()
219 | self.key = key or (lambda v: v)
220 |
221 | def add(self, value):
222 | if value in self._dup:
223 | return
224 | self._dup.add(value)
225 | a = self._data
226 | key = self.key
227 | i = len(a)
228 | a.append(value)
229 | while i > 0:
230 | parent = i // 2
231 | if key(a[parent]) < key(a[i]):
232 | break
233 | a[parent], a[i] = a[i], a[parent]
234 | i = parent
235 |
236 | def pop(self):
237 | if len(self._data) == 0:
238 | raise IndexError("pop from an empty heap")
239 | a = self._data
240 | val = a[0]
241 | a[0] = a[-1]
242 | a.pop()
243 | key = self.key
244 | i = 0
245 | while True:
246 | left = 2 * i + 1
247 | right = 2 * i + 2
248 | if left >= len(a):
249 | break
250 | node = left
251 | if right < len(a) and key(a[right]) < key(a[left]):
252 | node = right
253 | if key(a[i]) > key(a[node]):
254 | a[i], a[node] = a[node], a[i]
255 | i = node
256 | else:
257 | break
258 | self._dup.remove(val)
259 | return val
260 |
261 | def __contains__(self, value):
262 | return value in self._dup
263 |
264 | def __bool__(self):
265 | return len(self._data) > 0
266 |
267 | def pathfind(board, start, end, *, rand=False):
268 | #Actual A* Search algorithm
269 | def h(a, b):
270 | return abs(a[0] - b[0]) + abs(a[1] - b[1])
271 | gScore = defaultdict(lambda: float("inf"))
272 | gScore[start] = 0
273 | fScore = defaultdict(lambda: float("inf"))
274 | fScore[start] = h(start, end)
275 | open_set = OpenSet(fScore.__getitem__)
276 | open_set.add(start)
277 | came_from = {}
278 | rows = board.rows
279 | cols = board.cols
280 | def can_pass(x, y):
281 | if (x, y) == end:
282 | return not board.blocks_sight(x, y)
283 | return board.is_passable(x, y)
284 | while open_set:
285 | curr = open_set.pop()
286 | if curr == end:
287 | path = [curr]
288 | while curr in came_from:
289 | curr = came_from[curr]
290 | path.append(curr)
291 | path.reverse()
292 | return path
293 | neighbors = []
294 | x, y = curr
295 | if x + 1 < cols and can_pass(x + 1, y):
296 | neighbors.append((x + 1, y))
297 | if x - 1 >= 0 and can_pass(x - 1, y):
298 | neighbors.append((x - 1, y))
299 | if y + 1 < rows and can_pass(x, y + 1):
300 | neighbors.append((x, y + 1))
301 | if y - 1 >= 0 and can_pass(x, y - 1):
302 | neighbors.append((x, y - 1))
303 | if rand:
304 | random.shuffle(neighbors)
305 |
306 | for n in neighbors:
307 | cost = 1
308 | t = gScore[curr] + cost
309 | if t < gScore[n]:
310 | came_from[n] = curr
311 | gScore[n] = t
312 | fScore[n] = t + h(n, end)
313 |
314 | if n not in open_set:
315 | open_set.add(n)
316 | return []
317 |
318 | #End pathfinding
319 | ###############
--------------------------------------------------------------------------------
/roguelike.py:
--------------------------------------------------------------------------------
1 | try:
2 | import curses
3 | except:
4 | print("The builtin curses module is not supported on Windows.")
5 | print("However, you can install the windows-curses module in order to play on Windows.")
6 | while True:
7 | print("Would you like to install windows-curses? (Y/N)")
8 | choice = input(">> ")
9 | if choice:
10 | c = choice[0].lower()
11 | if c == "y":
12 | print("Beginning installation...")
13 | import subprocess
14 | code = subprocess.call(["pip", "install", "windows-curses"])
15 | if code:
16 | print("Failed to install windows-curses.")
17 | exit(code)
18 | break
19 | elif c == "n":
20 | exit()
21 | else:
22 | print("Please enter Y or N")
23 | import curses
24 | os.system("cls" if os.name == "nt" else "clear")
25 |
26 | import random, time
27 | import math
28 | from collections import deque
29 | from os import get_terminal_size
30 |
31 | from utils import *
32 | from board import *
33 | from gameobj import *
34 | from entity import *
35 | from items import *
36 | from monster import *
37 |
38 | if __name__ == "__main__":
39 | g = Game()
40 | try:
41 | g.print_msg("Welcome to VeraDugeon Rogue v0.5")
42 | g.print_msg("Press \"?\" if you want to view the controls.")
43 | if g.has_saved_game():
44 | g.maybe_load_game()
45 | if not g.has_saved_game(): #Either it failed to load or the player decided to start a new game
46 | g.generate_level()
47 | for w in dup_warnings:
48 | g.print_msg(f"WARNING: {w}", "yellow")
49 | g.draw_board()
50 | g.refresh_cache()
51 | player = g.player
52 | g.player.recalc_passives()
53 | while not player.dead:
54 | refresh = False
55 | lastenergy = player.energy
56 | if player.resting:
57 | g.screen.nodelay(True)
58 | char = g.screen.getch()
59 | done = False
60 | if char != -1 and chr(char) == "r":
61 | g.screen.nodelay(False)
62 | if g.yes_no("Really cancel your rest?"):
63 | done = True
64 | g.print_msg("You stop resting.")
65 | else:
66 | g.print_msg("You continue resting.")
67 | g.screen.nodelay(True)
68 | time.sleep(0.005)
69 | player.energy = 0
70 | if not done and player.HP >= player.get_max_hp():
71 | g.print_msg("HP restored.", "green")
72 | done = True
73 | if done:
74 | g.screen.nodelay(False)
75 | g.player.resting = False
76 | player.energy = random.randint(1, player.get_speed())
77 | refresh = True
78 | g.save_game()
79 | elif g.player.activity:
80 | time.sleep(0.01)
81 | player.energy = 0
82 | player.activity.time -= 1
83 | if player.activity.time <= 0:
84 | player.activity.on_finished(player)
85 | player.activity = None
86 | refresh = True
87 | player.energy = random.randint(1, player.get_speed())
88 | g.save_game()
89 | else:
90 | g.screen.nodelay(False)
91 | curses.flushinp()
92 | char = chr(g.screen.getch())
93 | if char == "w":
94 | player.move(0, -1)
95 | elif char == "s":
96 | player.move(0, 1)
97 | elif char == "a":
98 | player.move(-1, 0)
99 | elif char == "d":
100 | player.move(1, 0)
101 | elif char == "q": #Scroll up
102 | g.msg_cursor -= 1
103 | if g.msg_cursor < 0:
104 | g.msg_cursor = 0
105 | refresh = True
106 | elif char == "z": #Scroll down
107 | g.msg_cursor += 1
108 | if g.msg_cursor > (limit := max(0, len(g.msg_list) - g.get_max_lines())):
109 | g.msg_cursor = limit
110 | refresh = True
111 | elif char == "f": #View info of monster types in view
112 | fov_mons = list(player.monsters_in_fov(clairvoyance=True))
113 | refresh = True
114 | if not fov_mons:
115 | g.print_msg("You don't see any monsters right now")
116 | else:
117 | fov_mons.sort(key=lambda m: m.name)
118 | fov_mons.sort(key=lambda m: m.diff)
119 | dup = set()
120 | rem_dup = []
121 | for m in fov_mons:
122 | if m.name not in dup:
123 | rem_dup.append(m)
124 | dup.add(m.name)
125 | fov_mons = rem_dup[:]
126 | del rem_dup
127 | ac_bonus = player.get_ac_bonus(avg=True)
128 | mod = player.attack_mod(avg=True)
129 | str_mod = calc_mod(g.player.STR, avg=True)
130 | AC = 10 + ac_bonus
131 | mon_AC = m.get_ac(avg=True)
132 | for m in fov_mons:
133 | hit_prob = to_hit_prob(mon_AC, mod)
134 | hit_adv = to_hit_prob(mon_AC, mod, adv=True) #Probability with advantage
135 | be_hit = to_hit_prob(AC, m.to_hit)
136 | be_hit_disadv = to_hit_prob(AC, m.to_hit, disadv=True)
137 | string = f"{m.symbol} - {m.name} "
138 | string += f"| To hit: {display_prob(hit_prob*100)} ({display_prob(hit_adv*100)} w/adv.)"
139 | string += f" | {display_prob(be_hit*100)} to hit you ({display_prob(be_hit_disadv*100)} w/disadv.)"
140 | string += " | Attacks: "
141 | for i in range(len(m.attacks)):
142 | att = m.attacks[i]
143 | if isinstance(att, list):
144 | d = []
145 | for a in att:
146 | x, y = a.dmg
147 | d.append(f"{x}d{y}")
148 | if i < len(att) - 1:
149 | d.append(", ")
150 | d = "".join(d)
151 | string += f"({d})"
152 | else:
153 | x, y = att.dmg
154 | string += f"{x}d{y}"
155 | if i < len(m.attacks) - 1:
156 | string += ", "
157 | if m.armor > 0:
158 | string += f" | Armor: {m.armor}"
159 | g.print_msg(string)
160 | elif char == "i": #Inventory menu
161 | if player.inventory:
162 | player.inventory_menu()
163 | else:
164 | g.print_msg("You don't have anything in your inventory.")
165 | refresh = True
166 | elif char == "r" and player.HP < player.MAX_HP: #Rest and wait for HP to recover
167 | aware_count = 0
168 | for m in player.monsters_in_fov():
169 | if m.is_aware:
170 | aware_count += 1
171 | if aware_count == 0:
172 | g.print_msg("You begin resting.")
173 | player.resting = True
174 | else:
175 | num_msg = "there are monsters" if aware_count > 1 else "there's a monster"
176 | g.print_msg(f"You can't rest when {num_msg} nearby!", "yellow")
177 | refresh = True
178 | elif char == "p": #Pick up item
179 | tile = g.board.get(player.x, player.y)
180 | if tile.items:
181 | item = tile.items.pop()
182 | g.player.add_item(item)
183 | g.print_msg(f"You pick up a {item.name}.")
184 | g.player.energy -= g.player.get_speed()
185 | else:
186 | g.print_msg("There's nothing to pick up.")
187 | refresh = True
188 | elif char == " ": #Go down to next level
189 | if g.board.get(player.x, player.y).stair:
190 | was_any_allies = any(m.summon_timer is not None for m in g.monsters)
191 | time.sleep(0.3)
192 | g.generate_level()
193 | g.level += 1
194 | if was_any_allies:
195 | g.print_msg("You descend deeper into the dungeon, leaving your summoned allies behind.")
196 | else:
197 | g.print_msg("You descend deeper into the dungeon.")
198 | for m in player.monsters_in_fov():
199 | if x_in_y(4, g.level):
200 | continue
201 | if dice(1, 20) + calc_mod(player.DEX) - 4 < m.passive_perc:
202 | m.is_aware = True
203 | else:
204 | g.print_msg("You can't go down here.")
205 | refresh = True
206 | elif char == "?":
207 | g.help_menu()
208 | elif char == ".": #Wait a turn
209 | player.energy = 0
210 | elif char == "Q": #Quit
211 | if g.yes_no("Are you sure you want to quit the game?"):
212 | g.save_game()
213 | curses.nocbreak()
214 | curses.echo()
215 | exit()
216 | elif char == "+": #Display worn rings
217 | if player.worn_rings:
218 | num = len(player.worn_rings)
219 | g.print_msg(f"You are wearing {num} ring{'s' if num != 1 else ''}:")
220 | g.print_msg(", ".join(r.name for r in player.worn_rings))
221 | passives = player.calc_ring_passives()
222 | if passives:
223 | g.print_msg("Your rings are providing the following passive bonuses:")
224 | keys = sorted(passives.keys(), key=lambda k: k.lower())
225 | g.print_msg(", ".join(f"+{passives[k]} {'to-hit' if k == 'to_hit' else k}" for k in keys))
226 | else:
227 | g.print_msg("You aren't wearing any rings.")
228 | refresh = True
229 | moved = player.energy < lastenergy
230 | if moved:
231 | busy = player.resting or player.activity
232 | g.do_turn()
233 | g.autosave()
234 | if not busy or player.ticks % 10 == 0:
235 | g.draw_board()
236 | elif refresh:
237 | g.draw_board()
238 | g.delete_saved_game()
239 | g.input("Press enter to continue...")
240 | g.game_over()
241 | except Exception as e:
242 | curses.nocbreak()
243 | curses.echo()
244 | curses.endwin()
245 | import os, traceback
246 | os.system("clear")
247 | print("An error has occured:")
248 | print()
249 | msg = traceback.format_exception(type(e), e, e.__traceback__)
250 | msg = "".join(msg)
251 | print(msg)
252 | print()
253 | filename = "roguelike_error.log"
254 | f = open(filename, "w")
255 | try:
256 | f.write(msg)
257 | print(f"The error message has been written to {filename}")
258 | except:
259 | pass
260 | except KeyboardInterrupt:
261 | curses.nocbreak()
262 | curses.echo()
263 | curses.endwin()
264 | import os
265 | os.system("cls" if os.name == "nt" else "clear")
266 | raise
267 | else:
268 | curses.nocbreak()
269 | curses.echo()
--------------------------------------------------------------------------------
/gameobj.py:
--------------------------------------------------------------------------------
1 | import random, curses, textwrap, time
2 | from os import get_terminal_size, path
3 | from itertools import islice
4 | from collections import deque
5 |
6 | from utils import *
7 | from board import Board
8 | from player import Player
9 | from effect import Effect
10 | from monster import Monster
11 | from items import *
12 |
13 | import pickle
14 |
15 | class GameTextMenu:
16 |
17 | def __init__(self, g):
18 | self.screen = g.screen
19 | self.g = g
20 | size = get_terminal_size()
21 | self.termwidth = size.columns
22 | self.msg = []
23 |
24 | def add_text(self, txt):
25 | txt = str(txt)
26 | self.msg.extend(textwrap.wrap(txt, self.termwidth))
27 |
28 | def add_line(self):
29 | self.msg.append("")
30 |
31 | def clear_msg(self):
32 | self.msg.clear()
33 |
34 | def display(self):
35 | self.screen.clear()
36 | for i in range(len(self.msg)):
37 | self.screen.addstr(i, 0, self.msg[i])
38 | self.screen.refresh()
39 |
40 | def close(self):
41 | self.g.draw_board()
42 |
43 | def getch(self):
44 | return self.screen.getch()
45 |
46 | def getchar(self):
47 | return chr(self.getch())
48 |
49 | def wait_for_enter(self):
50 | while self.getch() != 10: pass
51 |
52 | class Game:
53 | _INST = None
54 |
55 | def __new__(cls):
56 | if cls._INST:
57 | return cls._INST
58 | obj = object.__new__(cls)
59 | cls._INST = obj
60 | return obj
61 |
62 | def __init__(self):
63 | self.screen = curses.initscr()
64 | curses.start_color()
65 | curses.init_pair(1, curses.COLOR_RED, 0)
66 | curses.init_pair(2, curses.COLOR_GREEN, 0)
67 | curses.init_pair(3, curses.COLOR_YELLOW, 0)
68 | curses.init_pair(4, curses.COLOR_BLUE, 0)
69 | curses.init_pair(5, curses.COLOR_MAGENTA, 0)
70 | curses.init_pair(6, curses.COLOR_CYAN, 0)
71 |
72 | self.screen.clear()
73 | curses.noecho()
74 | self.board = Board(self, 40, 16)
75 | self.player = Player(self)
76 | self.monsters = []
77 | self.msg_list = deque(maxlen=50)
78 | self.msg_cursor = 0
79 | self.blast = set()
80 | self.projectile = None
81 | self.select = None
82 | self.level = 1
83 | self.revealed = []
84 | self.last_save = time.time()
85 | types = Effect.__subclasses__()
86 | self.effect_types = {t.name:t for t in types}
87 | self.mon_types = Monster.__subclasses__()
88 |
89 | def __getstate__(self):
90 | d = self.__dict__.copy()
91 | del d["screen"]
92 | return d
93 |
94 | def __setstate__(self, state):
95 | self.__dict__.update(state)
96 | self.screen = curses.initscr()
97 |
98 | def load_game(self):
99 | try:
100 | obj = pickle.load(open("save.pickle", "rb"))
101 | self.__dict__.update(obj.__dict__)
102 | except:
103 | self.print_msg("Unable to load saved game.", "yellow")
104 | self.delete_saved_game()
105 |
106 | def save_game(self):
107 | pickle.dump(self, open("save.pickle", "wb"))
108 | self.last_save = time.time()
109 |
110 | def autosave(self):
111 | if time.time() - self.last_save > 1:
112 | self.save_game()
113 |
114 | def has_saved_game(self):
115 | return path.exists("save.pickle")
116 |
117 | def delete_saved_game(self):
118 | if self.has_saved_game():
119 | import os
120 | os.remove("save.pickle")
121 |
122 | def help_menu(self):
123 | menu = GameTextMenu(self)
124 | menu.add_text("Use the wasd keys to move")
125 | menu.add_text("Use the q and z keys to scroll the message log")
126 | menu.add_text("f - view info about monsters currently in view")
127 | menu.add_text("r - rest until HP is recovered")
128 | menu.add_text("p - pick up item")
129 | menu.add_text("i - inventory menu")
130 | menu.add_text("space - go down to next level (when standing on a \">\" symbol)")
131 | menu.add_text("? - brings up this menu again")
132 | menu.add_text(". - wait a turn")
133 | menu.add_text("+ - view equipped rings (and bonuses from them)")
134 | menu.add_text("Q - quit the game")
135 | menu.add_line()
136 | menu.add_text("Press enter to continue")
137 | menu.display()
138 | menu.wait_for_enter()
139 | menu.close()
140 |
141 | def maybe_load_game(self):
142 | if not self.has_saved_game():
143 | return
144 | menu = GameTextMenu(self)
145 | while True:
146 | menu.clear_msg()
147 |
148 | menu.add_text("Continue Saved Game")
149 | menu.add_line()
150 | menu.add_text("You have a saved game.")
151 | menu.add_line()
152 | menu.add_text("Press 1 to load saved game.")
153 | menu.add_text("Press 2 to start a new game.")
154 | menu.display()
155 | while (user := chr(menu.getch())) not in ["1", "2"]: pass
156 | if user == "1":
157 | self.load_game()
158 | break
159 | else:
160 | menu.clear_msg()
161 | menu.add_text("Really start a new game? All progress will be lost!")
162 | menu.add_line()
163 | menu.add_text("Enter Y or N")
164 | menu.display()
165 | while (newgame := chr(menu.getch()).upper()) not in ["Y", "N"]: pass
166 | if newgame == "Y":
167 | self.delete_saved_game()
168 | break
169 |
170 | menu.close()
171 |
172 | def game_over(self):
173 | menu = GameTextMenu(self)
174 | p = self.player
175 | menu.add_text("GAME OVER")
176 | menu.add_line()
177 | menu.add_text(f"You reached Dungeon Level {self.level}")
178 | menu.add_text(f"You attained XP level {p.level}")
179 | menu.add_line()
180 | menu.add_text(f"Your final stats were:")
181 | menu.add_text(f"STR {p.STR}, DEX {p.DEX}")
182 | menu.add_line()
183 | menu.add_text("Press enter to quit")
184 | menu.display()
185 | menu.wait_for_enter()
186 |
187 | def set_projectile_pos(self, x, y):
188 | self.projectile = (x, y)
189 |
190 | def clear_projectile(self):
191 | self.projectile = None
192 |
193 | def spawn_item(self, item, pos):
194 | self.board.get(*pos).items.append(item)
195 |
196 | def input(self, message=None):
197 | if message:
198 | self.print_msg(message)
199 | self.draw_board()
200 | curses.echo()
201 | string = self.screen.getstr()
202 | curses.noecho()
203 | self.draw_board()
204 | return string.decode()
205 |
206 | def yes_no(self, message):
207 | while (choice := self.input(message + " (Y/N)").lower()) not in ["y", "n"]:
208 | self.print_msg("Please enter \"Y\" or \"N\"")
209 | return choice == "y"
210 |
211 | def select_monster_target(self, cond=None, error="None of the monsters are eligible targets."):
212 | monsters = list(self.player.monsters_in_fov())
213 | if not monsters:
214 | self.print_msg("You don't see any monsters to target.")
215 | return None
216 | if cond:
217 | monsters = list(filter(cond, monsters))
218 | if not monsters:
219 | self.print_msg(error)
220 | return None
221 | self.print_msg("Target which monster?")
222 | self.print_msg("Use the a and d keys to select")
223 | monsters.sort(key=lambda m: m.y)
224 | monsters.sort(key=lambda m: m.x)
225 | index = random.randrange(len(monsters))
226 | last = -1
227 | while True:
228 | self.select = monsters[index]
229 | if last != index:
230 | self.draw_board()
231 | last = index
232 | curses.flushinp()
233 | num = self.screen.getch()
234 | char = chr(num)
235 | if char == "a":
236 | index -= 1
237 | if index < 0:
238 | index += len(monsters)
239 | elif char == "d":
240 | index += 1
241 | if index >= len(monsters):
242 | index -= len(monsters)
243 | if num == 10:
244 | break
245 | self.select = None
246 | return monsters[index]
247 |
248 | def add_monster(self, m):
249 | if m.place_randomly():
250 | self.monsters.append(m)
251 |
252 | def add_monster_at(self, m, pos):
253 | if m.place_randomly():
254 | self.monsters.append(m)
255 |
256 | def place_monster(self, typ):
257 | m = typ(self)
258 | if m.place_randomly():
259 | self.monsters.append(m)
260 | return m
261 | return None
262 |
263 | def generate_level(self):
264 | self.monsters.clear()
265 | self.board.generate()
266 | self.player.rand_place()
267 | self.player.fov = self.player.calc_fov()
268 | num = random.randint(3, 4) + random.randint(0, int(1.4*(self.level - 1)**0.65))
269 | monsters = self.mon_types
270 | pool = []
271 | for t in monsters:
272 | lev = self.level
273 | if lev >= t.min_level:
274 | pool.append(t)
275 | assert len(pool) > 0
276 | for _ in range(num):
277 | typ = random.choice(pool)
278 | m = typ(self)
279 | fuzz = max(1, m.MAX_HP//10)
280 | delta = random.randint(0, fuzz) - random.randint(0, fuzz)
281 | new_HP = max(1, m.MAX_HP + delta)
282 | m.HP = m.MAX_HP = new_HP
283 | if m.place_randomly():
284 | if one_in(2) and x_in_y(8, self.level):
285 | los_tries = 100
286 | while los_tries > 0:
287 | if not self.player.sees((m.x, m.y)):
288 | break
289 | m.place_randomly()
290 | los_tries -= 1
291 | self.monsters.append(m)
292 |
293 | def place_item(typ):
294 | for j in range(600):
295 | x = random.randint(1, self.board.cols - 2)
296 | y = random.randint(1, self.board.rows - 2)
297 | if self.board.is_passable(x, y):
298 | tile = self.board.get(x, y)
299 | if not tile.items:
300 | tile.items.append(item := typ())
301 | return item
302 | return None
303 |
304 | def apply_rand_enchant(item):
305 | if isinstance(item, Weapon):
306 | enchants = ["speed", "life stealing"]
307 | if item.dmg_type in ["pierce", "slash"]:
308 | enchants.append("armor piercing")
309 | item.ench_type = random.choice(enchants)
310 |
311 | if not one_in(8):
312 | types = [
313 | (HealthPotion, 55),
314 | (ResistPotion, 20),
315 | (SpeedPotion, 20),
316 | (InvisibilityPotion, 12),
317 | (RejuvPotion, 3),
318 | (ClairPotion, 9)
319 | ]
320 | for _ in range(4):
321 | if x_in_y(45, 100):
322 | typ = rand_weighted(*types)
323 | place_item(typ)
324 | elif x_in_y(60, 100):
325 | if one_in(2):
326 | place_item(HealthPotion)
327 | break
328 |
329 | if one_in(5):
330 | typ = random.choice([StrengthRing, ProtectionRing, DexterityRing])
331 | place_item(typ)
332 |
333 | if self.level > dice(1, 6) and x_in_y(3, 8):
334 | typ = rand_weighted(
335 | (MagicMissile, 10),
336 | (PolymorphWand, 5),
337 | (WandOfFear, 3),
338 | (LightningWand, 2)
339 | )
340 | place_item(typ)
341 |
342 | if one_in(2):
343 | typ = rand_weighted(
344 | (TeleportScroll, 3),
345 | (SleepScroll, 2),
346 | (ConfusionScroll, 3),
347 | (SummonScroll, 2),
348 | (EnchantScroll, 5)
349 | )
350 | place_item(typ)
351 |
352 | types = [
353 | (Club, 65),
354 | (Dagger, 35),
355 | (Greatclub, 35),
356 | (Handaxe, 17),
357 | (Javelin, 17),
358 | (Mace, 17),
359 | (Battleaxe, 11),
360 | (Shortsword, 11),
361 | (Longsword, 9),
362 | (Morningstar, 9),
363 | (Glaive, 8),
364 | (Greataxe, 7),
365 | ]
366 | types = [t for t in types if t[1] >= int(65/self.level)]
367 | num = binomial(random.randint(2, 3), 50)
368 | for _ in range(num):
369 | if (weapon := place_item(rand_weighted(*types))):
370 | if one_in(20):
371 | for _ in range(3):
372 | weapon.enchant += 1
373 | if not one_in(3):
374 | break
375 |
376 | if self.level > 1 and x_in_y(min(55 + self.level, 80), 100):
377 | types = [LeatherArmor]
378 | if self.level > 2:
379 | types.append(HideArmor)
380 | if self.level > 5:
381 | types.append(ChainShirt)
382 | if self.level > 8:
383 | types.append(ScaleMail)
384 | if self.level > 10:
385 | types.append(HalfPlate)
386 | if self.level > 13:
387 | types.append(SplintArmor)
388 | if self.level > 15:
389 | types.append(PlateArmor)
390 | num = 1
391 | if self.level > random.randint(1, 3) and one_in(3):
392 | num += 1
393 | if self.level > random.randint(1, 6) and one_in(3):
394 | num += 1
395 | for _ in range(num):
396 | place_item(random.choice(types))
397 |
398 |
399 | self.revealed.clear()
400 | self.draw_board()
401 | self.refresh_cache()
402 |
403 | def monster_at(self, x, y, include_player=False):
404 | if (x, y) == (self.player.x, self.player.y):
405 | return include_player
406 | return self.board.get_mon_cache(x, y) is not None
407 |
408 | def get_monster(self, x, y):
409 | if (x, y) == (self.player.x, self.player.y):
410 | return None
411 | return self.board.get_mon_cache(x, y)
412 |
413 | def remove_monster(self, m):
414 | mons = self.monsters
415 | try:
416 | ind = mons.index(m)
417 | except ValueError:
418 | pass
419 | else:
420 | mons[ind], mons[-1] = mons[-1], mons[ind]
421 | del mons[-1]
422 | self.board.unset_cache(m.x, m.y)
423 |
424 | def print_msg_if_sees(self, pos, msg, color=None):
425 | assert len(pos) == 2 and type(pos) == tuple
426 | if self.player.sees(pos, clairv=True):
427 | self.print_msg(msg, color=color)
428 |
429 | def print_msg(self, msg, color=None):
430 | m = {
431 | "red": 1,
432 | "green": 2,
433 | "yellow": 3
434 | }
435 | color = m.get(color, 0)
436 | size = get_terminal_size()
437 | termwidth = size.columns
438 | for line in str(msg).splitlines():
439 | self.msg_list.extend(map(lambda s: (s, color), textwrap.wrap(line, termwidth)))
440 | self.msg_cursor = max(0, len(self.msg_list) - self.get_max_lines())
441 |
442 | def get_max_lines(self):
443 | return min(8, get_terminal_size().lines - (self.board.rows + 2))
444 |
445 | def draw_board(self):
446 | screen = self.screen
447 | board = self.board
448 | screen.clear()
449 |
450 | p = self.player
451 | hp_str = f"HP {p.HP}/{p.get_max_hp()}"
452 | c = 0
453 | if p.HP <= p.get_max_hp()//8:
454 | c = curses.color_pair(1) | curses.A_BOLD
455 | elif p.HP <= p.get_max_hp()//4:
456 | c = curses.color_pair(3)
457 | width = get_terminal_size().columns
458 | screen.addstr(0, 0, hp_str, c)
459 | dr = ""
460 | if p.hp_drain > 0:
461 | extent = p.hp_drain//10+1
462 | dr = f" (Drain {extent})"
463 | screen.addstr(0, len(hp_str), f"{dr} | DG. LV {self.level} | XP {p.exp}/{p.max_exp()} ({p.level})")
464 | wd = min(width, 60)
465 | str_string = f"STR {p.STR}"
466 | screen.addstr(0, wd - len(str_string), str_string, self._stat_mod_color(p.mod_str))
467 | dex_string = f"DEX {p.DEX}"
468 | screen.addstr(1, wd - len(dex_string), dex_string, self._stat_mod_color(p.mod_dex))
469 | dmgdice = p.weapon.dmg
470 | X = dmgdice.num
471 | Y = dmgdice.sides
472 | w = f"{p.weapon.non_ench_name} ({X}d{Y})"
473 | screen.addstr(2, wd - len(w), w)
474 | armor = self.player.armor
475 | if armor:
476 | ar_str = f"{armor.name} ({armor.protect})"
477 | screen.addstr(3, wd - len(ar_str), ar_str)
478 | detect = p.detectability()
479 | if detect is not None:
480 | stealth = round(1/max(detect, 0.01) - 1, 1)
481 | det_str = f"{stealth} stealth"
482 | screen.addstr(4, wd - len(det_str), det_str)
483 |
484 |
485 | fov = self.player.fov.copy()
486 | if self.player.has_effect("Clairvoyance"):
487 | for point in self.board.get_in_circle((self.player.x, self.player.y), 8):
488 | x, y = point
489 | neighbors = [(x+1, y), (x-1, y), (x, y+1), (x, y-1), (x+1, y+1), (x+1, y-1), (x-1, y+1), (x-1, y-1)]
490 | surrounded = True
491 | for xp, yp in neighbors:
492 | if not self.board.in_bounds(xp, yp):
493 | continue
494 | if not board.blocks_sight(xp, yp):
495 | surrounded = False
496 | break
497 | if not surrounded:
498 | fov.add(point)
499 |
500 | for point in fov:
501 | tile = board.get(*point)
502 | if not tile.revealed:
503 | tile.revealed = True
504 | self.revealed.append(point)
505 | offset = 1
506 | marked = set()
507 | for col, row in self.revealed:
508 | tile = board.get(col, row)
509 | s = tile.symbol
510 | color = 0
511 | if (col, row) == (self.player.x, self.player.y):
512 | s = "P"
513 | if not self.player.has_effect("Invisible"):
514 | color = curses.A_REVERSE
515 | else:
516 | color = curses.color_pair(4)
517 | elif tile.items:
518 | item = tile.items[-1]
519 | s = item.symbol
520 | color = curses.color_pair(2)
521 | if isinstance(item, (Scroll, Armor)):
522 | color = curses.color_pair(4) | curses.A_BOLD
523 | elif isinstance(item, Wand):
524 | color = curses.color_pair(5) | curses.A_BOLD
525 | elif isinstance(item, Weapon):
526 | color = curses.color_pair(5) | curses.A_REVERSE
527 | elif tile.symbol == " ":
528 | if (col, row) in fov:
529 | s = "."
530 | if self.projectile:
531 | x, y = self.projectile
532 | if (col, row) == (x, y):
533 | s = "*"
534 | if (col, row) in self.blast:
535 | color = curses.color_pair(2)
536 | color |= curses.A_REVERSE
537 | marked.add((col, row))
538 | try:
539 | screen.addstr(row + offset, col, s, color)
540 | except curses.error:
541 | pass
542 | monpos = set()
543 | for m in self.monsters:
544 | x, y = m.x, m.y
545 | if (x, y) in fov:
546 | monpos.add((x, y))
547 | color = curses.color_pair(3) if m.ranged else 0
548 | if m.has_effect("Confused"):
549 | color = curses.color_pair(4)
550 | elif m.has_effect("Stunned"):
551 | color = curses.color_pair(5)
552 | elif not m.is_aware:
553 | if m.has_effect("Asleep"):
554 | color = curses.color_pair(4)
555 | color |= curses.A_REVERSE
556 | elif m.is_friendly():
557 | color = curses.color_pair(6)
558 | if m is self.select or (m.x, m.y) in self.blast:
559 | color = curses.color_pair(2)
560 | color |= curses.A_REVERSE
561 | try:
562 | screen.addstr(y+offset, x, m.symbol, color)
563 | except curses.error:
564 | pass
565 | for x, y in (self.blast - monpos - marked):
566 | if not self.board.in_bounds(x, y):
567 | continue
568 | try:
569 | screen.addstr(y+offset, x, " ", curses.color_pair(2) | curses.A_REVERSE)
570 | except curses.error:
571 | pass
572 |
573 | max_lines = self.get_max_lines()
574 | messages = list(islice(self.msg_list, self.msg_cursor, self.msg_cursor+self.get_max_lines()))
575 | for i, msg in enumerate(messages):
576 | message, color = msg
577 | c = curses.color_pair(color)
578 | if color == 1:
579 | c |= curses.A_BOLD
580 | if i == len(messages) - 1 and self.msg_cursor < max(0, len(self.msg_list) - self.get_max_lines()):
581 | message += " (↓)"
582 | try:
583 | screen.addstr(board.rows + i + offset + 1, 0, message, c)
584 | except:
585 | pass
586 |
587 | try:
588 | screen.move(board.rows + offset, 0)
589 | except curses.error:
590 | pass
591 | screen.refresh()
592 |
593 | def _stat_mod_color(self, mod):
594 | if mod > 0:
595 | return curses.color_pair(2)
596 | if mod < 0:
597 | return curses.color_pair(1)
598 | return 0
599 |
600 | def refresh_cache(self):
601 | "Refreshes the monster collision cache"
602 | board = self.board
603 | board.clear_cache()
604 | board.set_cache(self.player.x, self.player.y, self.player)
605 | for m in self.monsters[:]:
606 | board.set_cache(m.x, m.y, m)
607 |
608 | def do_turn(self):
609 | while self.player.energy <= 0:
610 | if one_in(10): #In case anything goes wrong, refresh the monster collision cache every so often
611 | self.refresh_cache()
612 | self.player.do_turn()
613 | order = self.monsters[:]
614 | random.shuffle(order)
615 | order.sort(key=lambda m: m.get_speed(), reverse=True)
616 | self.player.energy += self.player.get_speed()
617 | for m in order:
618 | if m.HP > 0:
619 | m.do_turn()
620 | else:
621 | self.remove_monster(m)
622 | if self.player.dead:
623 | return
--------------------------------------------------------------------------------
/items.py:
--------------------------------------------------------------------------------
1 |
2 | import random, time
3 | from utils import *
4 |
5 | class Item:
6 | description = "This is a generic item that does nothing special. You shouldn't see this in-game."
7 |
8 | def __init__(self, name, symbol):
9 | self._name = name
10 | self.symbol = symbol
11 | self.enchant = 0
12 |
13 | @property
14 | def name(self):
15 | name = self._name
16 | if isinstance(self, Enchantable) and self.ench_type:
17 | name += f" of {self.ench_type}"
18 | if self.enchant > 0:
19 | name += f" +{self.enchant}"
20 | return name
21 |
22 | @property
23 | def non_ench_name(self):
24 | name = self._name
25 | if self.enchant > 0:
26 | name += f" +{self.enchant}"
27 | return name
28 |
29 | def can_enchant(self):
30 | return False
31 |
32 | def use(self, player):
33 | g = player.g
34 | g.print_msg("You use an item. Nothing interesting seems to happen")
35 | return True
36 |
37 | class Enchantable(Item):
38 |
39 | def __init__(self, name, symbol):
40 | super().__init__(name, symbol)
41 | self.ench_type = None
42 |
43 | def can_enchant(self):
44 | return self.enchant < 3
45 |
46 | class Scroll(Item):
47 | description = "This is a regular scroll that does nothing. If you see this, it's a bug."
48 |
49 | def __init__(self, name):
50 | super().__init__(name, "@")
51 |
52 | def use(self, player):
53 | g = player.g
54 | g.print_msg("You look at the blank scroll. It crumbles to dust immediately because it's so useless.")
55 | return True
56 |
57 | class HealthPotion(Item):
58 | description = "Consuming this potions increases the HP of the one who drinks it."
59 |
60 | def __init__(self):
61 | super().__init__("health potion", "P")
62 |
63 | def use(self, player):
64 | g = player.g
65 | MAX_HP = player.get_max_hp()
66 | if player.HP >= MAX_HP:
67 | g.print_msg("Your HP is already full!")
68 | return False
69 | else:
70 | recover = 10 + dice(2, 40)
71 | g.print_msg("You recover some HP.")
72 | player.HP = min(MAX_HP, player.HP + recover)
73 | return True
74 |
75 | class SpeedPotion(Item):
76 | description = "Consuming this potion temporarily speeds the movement of the one who drinks it. However, once the effect wears off, they will feel lethargic for a short period."
77 |
78 | def __init__(self):
79 | super().__init__("speed potion", "S")
80 |
81 | def use(self, player):
82 | g = player.g
83 | g.print_msg("You drink a speed potion.")
84 | player.lose_effect("Lethargy", silent=True)
85 | if player.has_effect("Haste"):
86 | g.print_msg("Your speed begins to last even longer.")
87 | player.gain_effect("Haste", random.randint(40, 60))
88 | return True
89 |
90 | class ResistPotion(Item):
91 | description = "Consuming this potion temporarily reduces damage taken by the one who drinks it."
92 |
93 | def __init__(self):
94 | super().__init__("resistance potion", "R")
95 |
96 | def use(self, player):
97 | g = player.g
98 | g.print_msg("You drink a resistance potion.")
99 | if player.has_effect("Resistance"):
100 | g.print_msg("Your resistance begins to last even longer.")
101 | player.gain_effect("Resistance", random.randint(30, 45))
102 | return True
103 |
104 | class InvisibilityPotion(Item):
105 | description = "Consuming this potion makes the one who drinks it temporarily invisible. However, attacking a monster will reduce the duration of this effect."
106 |
107 | def __init__(self):
108 | super().__init__("invisibility potion", "C")
109 |
110 | def use(self, player):
111 | g = player.g
112 | g.print_msg("You drink an invisibility potion.")
113 | if player.has_effect("Invisible"):
114 | g.print_msg("Your invisibility begins to last even longer.")
115 | player.gain_effect("Invisible", random.randint(45, 70))
116 | return True
117 |
118 | class RejuvPotion(Item):
119 | description = "Consuming this potion significantly improves regeneration for a short duration."
120 |
121 | def __init__(self):
122 | super().__init__("potion of rejuvenation", "J")
123 |
124 | def use(self, player):
125 | g = player.g
126 | g.print_msg("You drink a potion of rejuvenation.")
127 | if player.has_effect("Rejuvenated"):
128 | player.lose_effect("Rejuvenated", silent=True) #This doesn't stack
129 | g.print_msg("You drink a potion of rejuvenation.")
130 | player.gain_effect("Rejuvenated", random.randint(20, 25))
131 | return True
132 |
133 | class ClairPotion(Item):
134 | description = "Consuming this potion allows you to see beyond ehat you can normally see."
135 |
136 | def __init__(self):
137 | super().__init__("potion of clairvoyance", "Y")
138 |
139 | def use(self, player):
140 | g = player.g
141 | g.print_msg("You drink a clairvoyance potion.")
142 | if player.has_effect("Clairvoyance"):
143 | g.print_msg("You feel even more clairvoyant.")
144 | player.gain_effect("Clairvoyance", random.randint(45, 80))
145 | return True
146 |
147 | class ConfusionScroll(Scroll):
148 | description = "Reading this scroll may cause nearby monsters to become confused."
149 |
150 | def __init__(self):
151 | super().__init__("scroll of confusion")
152 |
153 | def use(self, player):
154 | g = player.g
155 | g.print_msg("You read a scroll of confusion. The scroll crumbles to dust.")
156 | for m in player.monsters_in_fov():
157 | if m.is_eff_immune("Confused"):
158 | g.print_msg(f"The {m.name} is unaffected.")
159 | elif dice(1, 20) + calc_mod(m.WIS) >= 15:
160 | g.print_msg(f"The {m.name} resists.")
161 | else:
162 | g.print_msg(f"The {m.name} is confused!")
163 | m.gain_effect("Confused", random.randint(30, 45))
164 | return True
165 |
166 | class SleepScroll(Scroll):
167 | description = "Reading this scroll may cause some of the nearby monsters to fall asleep."
168 |
169 | def __init__(self):
170 | super().__init__("scroll of sleep")
171 |
172 | def use(self, player):
173 | g = player.g
174 | g.print_msg("You read a scroll of sleep. The scroll crumbles to dust.")
175 | mons = list(player.monsters_in_fov())
176 | random.shuffle(mons)
177 | mons.sort(key=lambda m: m.HP)
178 | power = dice(10, 8)
179 | to_affect = []
180 | for m in mons:
181 | if m.has_effect("Asleep") or m.is_eff_immune("Asleep"):
182 | continue
183 | power -= m.HP
184 | if power < 0:
185 | break
186 | to_affect.append(m)
187 | if to_affect:
188 | random.shuffle(to_affect)
189 | for m in to_affect:
190 | g.print_msg(f"The {m.name} falls asleep!")
191 | m.gain_effect("Asleep", random.randint(30, 45))
192 | m.reset_check_timer()
193 | m.is_aware = False
194 | else:
195 | g.print_msg("Nothing seems to happen.")
196 | return True
197 |
198 | class TeleportScroll(Scroll):
199 | description = "Reading this scroll will randomly teleport the one who reads it."
200 |
201 | def __init__(self):
202 | super().__init__("scroll of teleportation")
203 |
204 | def use(self, player):
205 | g = player.g
206 | g.print_msg("You read a scroll of teleportation. The scroll crumbles to dust.")
207 | player.teleport()
208 | player.energy -= player.get_speed()
209 | return True
210 |
211 | class SummonScroll(Scroll):
212 | description = "Reading this scroll will summon friendly creatures."
213 |
214 | def __init__(self):
215 | super().__init__("scroll of summoning")
216 |
217 | def use(self, player):
218 | g = player.g
219 | g.print_msg("You read a scroll of summoning. The scroll crumbles to dust.")
220 |
221 | points = list(player.fov)
222 | points.remove((player.x, player.y))
223 | types = list(filter(lambda t: t.diff <= 7 and g.level > t.min_level, g.mon_types))
224 | num = random.randint(2, 3)
225 | random.shuffle(points)
226 | points.sort(key=lambda p: abs(p[0] - player.x) + abs(p[1] - player.y))
227 | ind = 0
228 | while ind < len(points) and num > 0:
229 | typ = random.choice(types)
230 | duration = random.randint(50, 80)
231 | pos = points[ind]
232 | if g.monster_at(*pos):
233 | ind += 1
234 | continue
235 | m = typ(g)
236 | m.ranged = False
237 | m.place_at(*pos)
238 | m.summon_timer = duration
239 | g.monsters.append(m)
240 | ind += random.randint(1, 2)
241 | num -= 1
242 | return True
243 |
244 |
245 | class Activity:
246 |
247 | def __init__(self, name, time):
248 | self.name = name
249 | self.time = time
250 |
251 | def on_finished(self, player):
252 | pass
253 |
254 | class WearArmor(Activity):
255 |
256 | def __init__(self, armor):
257 | super().__init__(f"putting on your {armor.name}", 30)
258 | self.armor = armor
259 |
260 | def on_finished(self, player):
261 | player.armor = self.armor
262 | g = player.g
263 | g.print_msg(f"You finish putting on your {self.armor.name}.")
264 |
265 | class RemArmor(Activity):
266 |
267 | def __init__(self, armor):
268 | super().__init__(f"removing your {armor.name}", 20)
269 | self.armor = armor
270 |
271 | def on_finished(self, player):
272 | player.armor = None
273 | g = player.g
274 | g.print_msg(f"You finish removing your {self.armor.name}.")
275 |
276 | class Armor(Enchantable):
277 | description = "This is armor. It may protect you from attacks."
278 | stealth_pen = 0
279 | dex_mod_softcap = None #This represents the softcap for dexterity bonus to AC
280 |
281 | def __init__(self, name, symbol, protect):
282 | super().__init__(name, symbol)
283 | self._protect = protect
284 |
285 | @property
286 | def protect(self):
287 | return self._protect + self.enchant
288 |
289 | def use(self, player):
290 | g = player.g
291 | if player.armor and player.armor.name == self.name:
292 | if g.yes_no(f"Take off your {self.name}?"):
293 | player.activity = RemArmor(self)
294 | else:
295 | g.print_msg(f"You begin putting on your {self.name}.")
296 | player.activity = WearArmor(self)
297 | return False #Do not remove armor from inventory
298 |
299 | class LeatherArmor(Armor):
300 |
301 | def __init__(self):
302 | super().__init__("leather armor", "L", 1)
303 |
304 | class HideArmor(Armor):
305 |
306 | def __init__(self):
307 | super().__init__("hide armor", "H", 2)
308 |
309 | class ChainShirt(Armor):
310 | dex_mod_softcap = 4
311 |
312 | def __init__(self):
313 | super().__init__("chain shirt", "C", 3)
314 |
315 | class ScaleMail(Armor):
316 | stealth_pen = 2
317 | dex_mod_softcap = 3
318 |
319 | def __init__(self):
320 | super().__init__("scale mail", "M", 4)
321 |
322 | class HalfPlate(Armor):
323 | stealth_pen = 4
324 | dex_mod_softcap = 2
325 |
326 | def __init__(self):
327 | super().__init__("half-plate", "A", 5)
328 |
329 | class ChainMail(Armor):
330 | stealth_pen = 6
331 | dex_mod_softcap = 1
332 |
333 | def __init__(self):
334 | super().__init__("chainmail", "I", 6)
335 |
336 | class SplintArmor(Armor):
337 | stealth_pen = 8
338 | dex_mod_softcap = 0
339 |
340 | def __init__(self):
341 | super().__init__("splint armor", "S", 7)
342 |
343 | class PlateArmor(Armor):
344 | stealth_pen = 10
345 | dex_mod_softcap = -1
346 |
347 | def __init__(self):
348 | super().__init__("plate armor", "T", 8)
349 |
350 | class Weapon(Enchantable):
351 | description = "This is a weapon that can be used to attack enemies."
352 | crit_mult = 2
353 | crit_chance = 1
354 | dmg_type = "default"
355 |
356 | def __init__(self, name, symbol, dmg, finesse=False, heavy=False, thrown=None):
357 | super().__init__(name, symbol)
358 | self.dmg = Dice(*dmg)
359 | self.finesse = finesse
360 | self.heavy = heavy #Heavy weapons get a -2 penalty on attack rolls
361 | self.thrown = thrown #Either None or a 2-tuple representing short and long range
362 |
363 | def use(self, player):
364 | g = player.g
365 | if self is player.weapon:
366 | if g.yes_no(f"Put away your {self.name}?"):
367 | player.weapon = UNARMED
368 | player.energy -= player.get_speed()
369 | else:
370 | return False
371 | else:
372 | if player.weapon is not UNARMED:
373 | player.energy -= player.get_speed()
374 | g.print_msg(f"You switch to your {self.name}.")
375 | else:
376 | g.print_msg(f"You wield a {self.name}.")
377 | player.weapon = self
378 |
379 | def roll_dmg(self):
380 | return self.dmg.roll()
381 |
382 | def on_hit(self, player, mon):
383 | #TODO: Not yet implemented
384 | pass
385 |
386 | class NullWeapon(Weapon):
387 | description = "You are punching with your fists. You shouldn't see this in-game."
388 | dmg_type = "bludgeon"
389 |
390 | def __init__(self):
391 | super().__init__("unarmed", "f", (1, 2))
392 |
393 | def can_enchant(self):
394 | return False
395 |
396 | class EnchantScroll(Scroll):
397 | description = "Reading this scroll will enchant a weapon or armor of the player's choice."
398 |
399 | def __init__(self):
400 | super().__init__("scroll of enchant")
401 |
402 | def use(self, player):
403 | g = player.g
404 | items = [t for t in player.inventory if t.can_enchant()]
405 | if not items:
406 | g.print_msg("You don't have any items that can be enchanted.")
407 | else:
408 | items.sort(key=lambda t: t.name)
409 | strings = ", ".join(f"{i+1}. {t.name}" for i, t in enumerate(items))
410 | g.print_msg("Enchant which item? (Enter a number)")
411 | g.print_msg(strings)
412 | try:
413 | num = int(g.input())
414 | if num < 1 or num > len(items):
415 | g.print_msg(f"Number must be between 1 and {len(items)}.")
416 | return False #Don't waste the item on an invalid input
417 | except ValueError:
418 | g.print_msg("You didn't enter a number.")
419 | return False
420 | g.print_msg("You read a scroll of enchant. The scroll crumbles to dust.")
421 | item = items[num-1]
422 | g.print_msg(f"You enchant your {item.name}. It gains a +1 bonus.")
423 | item.add_enchant()
424 | return True
425 |
426 | UNARMED = NullWeapon()
427 |
428 | class Club(Weapon):
429 | dmg_type = "bludgeon"
430 |
431 | def __init__(self):
432 | super().__init__("club", "!", (1, 4))
433 |
434 | class Dagger(Weapon):
435 | crit_chance = 2
436 | dmg_type = "pierce"
437 |
438 | def __init__(self):
439 | super().__init__("dagger", "/", (1, 4), finesse=True, thrown=(4, 12))
440 |
441 | class Handaxe(Weapon):
442 | crit_mult = 3
443 | dmg_type = "slash"
444 |
445 | def __init__(self):
446 | super().__init__("handaxe", "h", (1, 6), thrown=(4, 12))
447 |
448 | class Javelin(Weapon):
449 | dmg_type = "pierce" # changed damage type to pierce (NapoleonBonatarte)
450 |
451 | def __init__(self):
452 | super().__init__("javelin", "j", (1, 6), thrown=(6, 24))
453 |
454 | class Mace(Weapon):
455 | dmg_type = "bludgeon"
456 |
457 | def __init__(self):
458 | super().__init__("mace", "T", (1, 6))
459 |
460 | class Shortsword(Weapon):
461 | crit_chance = 2
462 | dmg_type = "slash"
463 |
464 | def __init__(self):
465 | super().__init__("shortsword", "i", (1, 6), finesse=True)
466 |
467 | class Longsword(Weapon):
468 | crit_chance = 2
469 | dmg_type = "slash"
470 |
471 | def __init__(self):
472 | super().__init__("longsword", "I", (1, 9))
473 |
474 | class Greatclub(Weapon):
475 | dmg_type = "bludgeon"
476 |
477 | def __init__(self):
478 | super().__init__("greatclub", "P", (1, 8))
479 |
480 | class Battleaxe(Weapon):
481 | crit_mult = 3
482 | dmg_type = "slash"
483 |
484 | def __init__(self):
485 | super().__init__("battleaxe", "F", (1, 9))
486 |
487 | class Morningstar(Weapon):
488 | dmg_type = "pierce"
489 |
490 | def __init__(self):
491 | super().__init__("morningstar", "k", (1, 8))
492 |
493 | class Glaive(Weapon):
494 | dmg_type = "slash"
495 |
496 | def __init__(self):
497 | super().__init__("glaive", "L", (1, 10), heavy=True)
498 |
499 | class Greataxe(Weapon):
500 | crit_mult = 3
501 | dmg_type = "slash"
502 |
503 | def __init__(self):
504 | super().__init__("greataxe", "G", (1, 12), heavy=True)
505 |
506 | class Wand(Item):
507 | description = "This is a wand."
508 |
509 | def __init__(self, name, charges, efftype="blast"):
510 | super().__init__(name, "Î")
511 | self.charges = charges
512 | self.efftype = efftype
513 |
514 | def wand_effect(self, player, mon):
515 | self.g.print_msg("Nothing special seems to happen.")
516 |
517 | def use(self, player):
518 | g = player.g
519 | monsters = list(player.monsters_in_fov())
520 | g.print_msg(f"This wand has {self.charges} charges remaining.")
521 | target = g.select_monster_target()
522 | if not target:
523 | return
524 | if g.board.line_of_sight((player.x, player.y), (target.x, target.y)):
525 | line = list(g.board.line_between((player.x, player.y), (target.x, target.y)))
526 | else:
527 | line = list(g.board.line_between((target.x, target.y), (player.x, player.y)))
528 | line.reverse()
529 | if self.efftype == "ray":
530 | t = player.distance(target)
531 | def raycast(line, rnd):
532 | line.clear()
533 | dx = target.x - player.x
534 | dy = target.y - player.y
535 | i = 1
536 | x, y = player.x, player.y
537 | hittarget = False
538 | while True:
539 | nx = rnd(player.x + dx * (i/t))
540 | ny = rnd(player.y + dy * (i/t))
541 | i += 1
542 | if (nx, ny) == (x, y):
543 | continue
544 | if (x, y) == (target.x, target.y):
545 | hittarget = True
546 | if g.board.blocks_sight(nx, ny):
547 | return hittarget #The ray should at least hit the target if it doesn't reach anyone else
548 | x, y = nx, ny
549 | line.append((x, y))
550 | rounds = (int, round, math.ceil) #Try different rounding functions, to ensure that the ray passes through at least the target
551 | line = []
552 | for f in rounds:
553 | if raycast(line, f):
554 | break
555 | g.blast.clear()
556 | for x, y in line:
557 | t = g.get_monster(x, y)
558 | if t is not None:
559 | if not target.despawn_summon():
560 | self.wand_effect(player, t)
561 | t.on_alerted()
562 | g.blast.add((x, y))
563 | g.draw_board()
564 | time.sleep(0.001)
565 | time.sleep(0.05)
566 | g.blast.clear()
567 | g.draw_board()
568 | else:
569 | for x, y in line:
570 | g.set_projectile_pos(x, y)
571 | g.draw_board()
572 | time.sleep(0.03)
573 | if (t := g.get_monster(x, y)) is not None:
574 | if t is not target and x_in_y(3, 5): #If a creature is in the way, we may hit it instead of our intended target.
575 | g.print_msg(f"The {t.name} is in the way.")
576 | target = t
577 | break
578 | g.clear_projectile()
579 | if not target.despawn_summon():
580 | self.wand_effect(player, target)
581 | self.charges -= 1
582 | player.did_attack = True
583 | alert = 2 + (self.efftype == "ray") #Ray effects that affect all monsters in a line are much more likely to alert monsters
584 | for m in player.monsters_in_fov():
585 | if x_in_y(alert, 4) or m is target: #Zapping a wand is very likely to alert nearby monsters to your position
586 | m.on_alerted()
587 | return (True if self.charges <= 0 else None)
588 |
589 | class MagicMissile(Wand):
590 | description = "This wand can be used to fire magic missiles at creatures, which will always hit."
591 |
592 | def __init__(self):
593 | super().__init__("wand of magic missiles", random.randint(3, 7))
594 |
595 | def wand_effect(self, player, target):
596 | g = player.g
597 | dam = 0
598 | for _ in range(3):
599 | dam += target.apply_armor(random.randint(2, 5))
600 | msg = f"The magic missiles hit the {target.name} "
601 | if dam <= 0:
602 | msg += "but do no damage."
603 | else:
604 | target.HP -= dam
605 | msg += f"for {dam} damage."
606 | if target.HP > 0:
607 | msg += f" Its HP: {target.HP}/{target.MAX_HP}"
608 | g.print_msg(msg)
609 | if target.HP <= 0:
610 | player.defeated_monster(target)
611 |
612 | class PolymorphWand(Wand):
613 | description = "This wand can be used to polymorph nearby enemies into something weaker."
614 |
615 | def __init__(self):
616 | super().__init__("polymorph wand", random.randint(random.randint(2, 7), 7))
617 |
618 | def wand_effect(self, player, target):
619 | g = player.g
620 | if target.saving_throw(target.WIS, 15):
621 | g.print_msg(f"The {target.name} resists.")
622 | else:
623 | target.polymorph()
624 |
625 | class WandOfFear(Wand):
626 | description = "This wand can be used to make nearby enemies frightened of the player."
627 |
628 | def __init__(self):
629 | super().__init__("wand of fear", random.randint(3, 7))
630 |
631 | def wand_effect(self, player, target):
632 | g = player.g
633 | if target.is_eff_immune("Frightened"):
634 | g.print_msg(f"The {target.name} is unaffected.")
635 | elif target.saving_throw(target.WIS, 15):
636 | g.print_msg(f"The {target.name} resists.")
637 | else:
638 | g.print_msg(f"The {target.name} is frightened!")
639 | target.gain_effect("Frightened", random.randint(30, 60))
640 |
641 | class LightningWand(Wand):
642 | description = "This wand can be used to cast lightning bolts, dealing damage to nearby enemies."
643 |
644 | def __init__(self):
645 | super().__init__("wand of lightning", random.randint(3, 7), efftype="ray")
646 |
647 | def wand_effect(self, player, target):
648 | g = player.g
649 | numdice = 8
650 | if not target.has_effect("Paralyzed") and target.saving_throw(target.DEX, 15):
651 | numdice = 4
652 | g.print_msg(f"The {target.name} partially resists.")
653 | damage = target.apply_armor(dice(numdice, 6))
654 | msg = f"The bolt strikes the {target.name} "
655 | if damage <= 0:
656 | msg += "but does no damage."
657 | else:
658 | msg += f"for {damage} damage."
659 | target.HP -= damage
660 | g.print_msg(msg)
661 | if target.HP <= 0:
662 | player.defeated_monster(target)
663 | else:
664 | target.maybe_split(damage, 6)
665 |
666 | class Ring(Item):
667 | description = "This is a ring that can provide a passive bonus when equipped."
668 | #Passives can be: STR, DEX, protect, stealth, dodge, to_hit
669 | _valid_passives = {"STR", "DEX", "protect", "stealth", "dodge", "to_hit"}
670 | def __init__(self, name, wear_msg, rem_msg, passives={}):
671 | super().__init__(name, "ô")
672 | for key in passives:
673 | if key not in self._valid_passives:
674 | raise ValueError(f"{key!r} is not a valid passive")
675 | self.wear_msg = wear_msg
676 | self.rem_msg = rem_msg
677 | self.passives = passives
678 |
679 | def use(self, player):
680 | g = player.g
681 | worn_rings = player.worn_rings
682 | if self in worn_rings:
683 | if g.yes_no(f"Take off your {self.name}?"):
684 | g.print_msg(f"You take off your {self.name}.")
685 | g.print_msg(self.rem_msg)
686 | worn_rings.remove(self)
687 | player.recalc_passives()
688 | else:
689 | if len(worn_rings) >= 7:
690 | g.print_msg(f"You're already wearing the maximum number of rings.")
691 | return False
692 | else:
693 | g.print_msg(f"You put on a {self.name}.")
694 | g.print_msg(self.wear_msg)
695 | worn_rings.append(self)
696 | player.recalc_passives()
697 |
698 | class ProtectionRing(Ring):
699 | description = "This ring can provide a slight bonus to protection when equipped."
700 |
701 | def __init__(self):
702 | super().__init__("ring of protection", "You feel more protected.", "You feel more vulnerable.",
703 | passives={"protect": 1}
704 | )
705 |
706 | class StrengthRing(Ring):
707 | description = "This ring can provide a bonus to strength when equipped."
708 |
709 | def __init__(self):
710 | super().__init__("ring of strength", "You feel stronger.", "You don't feel as strong anymore.",
711 | passives={"STR": 3}
712 | )
713 |
714 | class DexterityRing(Ring):
715 | description = "This ring can provide a bonus to dexterity when equipped."
716 |
717 | def __init__(self):
718 | super().__init__("ring of dexterity", "You feel like your agility has improved.", "You feel less agile.",
719 | passives={"DEX": 3}
720 | )
--------------------------------------------------------------------------------
/player.py:
--------------------------------------------------------------------------------
1 | import random, time, math
2 | from collections import defaultdict
3 | from utils import *
4 |
5 | from entity import Entity
6 | from items import *
7 | from os import get_terminal_size
8 |
9 | class Player(Entity):
10 |
11 | def __init__(self, g):
12 | super().__init__(g)
13 | self.exp = 0
14 | self.level = 1
15 | self.HP = self.MAX_HP
16 | self.dead = False
17 | self.ticks = 0
18 | self.resting = False
19 | self.weapon = UNARMED
20 | self.inventory = []
21 | self.energy = 30
22 | self.speed = 30
23 |
24 | self.base_str = 10
25 | self.base_dex = 10
26 | self.mod_str = 0
27 | self.mod_dex = 0
28 | self.str_drain = 0
29 | self.dex_drain = 0
30 | self.passives = defaultdict(int)
31 |
32 | self.hp_drain = 0
33 | self.poison = 0
34 | self.fire = 0
35 | self.turns_engulfed = 0 #For the water elemental
36 | self.engulfed_by = None
37 | self.effects = {}
38 | self.armor = None
39 | self.activity = None
40 | self.did_attack = False
41 | self.last_attacked = False
42 | self.moved = False
43 | self.last_moved = False
44 |
45 | self.grappled_by = []
46 | self.worn_rings = []
47 |
48 | def calc_ring_passives(self):
49 | passives = defaultdict(int)
50 | for ring in self.worn_rings:
51 | for stat, val in ring.passives.items():
52 | passives[stat] += val**2
53 | for p in passives:
54 | passives[p] = math.ceil(round(math.sqrt(passives[p]), 3))
55 | return passives
56 |
57 | def recalc_passives(self):
58 | passives = self.calc_ring_passives()
59 | self.passives = passives
60 |
61 | #Todo: Allow effects to modify these values
62 |
63 | @property
64 | def STR(self):
65 | return self.base_str + self.mod_str
66 |
67 | @property
68 | def DEX(self):
69 | return self.base_dex + self.mod_dex
70 |
71 | def add_grapple(self, mon):
72 | if mon.distance(self) > 1: #Can't grapple if we're not close enough'
73 | return False
74 | if mon in self.grappled_by:
75 | return False
76 | self.grappled_by.append(mon)
77 | return True
78 |
79 | def remove_grapple(self, mon):
80 | if mon in self.grappled_by:
81 | self.grappled_by.remove(mon)
82 |
83 | def get_ac_bonus(self, avg=False):
84 | s = calc_mod(self.DEX, avg)
85 | if self.armor:
86 | armor = self.armor
87 | if armor.dex_mod_softcap is not None:
88 | softcap = armor.dex_mod_softcap
89 | if s > softcap: #Reduce any excess above the softcap
90 | diff = s - softcap
91 | if avg:
92 | s = softcap + diff / 4
93 | else:
94 | s = softcap + div_rand(diff, 4)
95 | if self.has_effect("Haste"):
96 | s += 2
97 | s += self.passives["dodge"]
98 | s -= 2 * len(self.grappled_by)
99 | return s
100 |
101 | def get_max_hp(self):
102 | return max(self.MAX_HP - self.hp_drain, 0)
103 |
104 | def get_speed(self):
105 | speed = self.speed
106 | if self.has_effect("Haste"):
107 | speed *= 2
108 | elif self.has_effect("Lethargy"):
109 | speed = speed * 2 // 3
110 | return int(speed)
111 |
112 | def max_exp(self):
113 | return 50 + math.ceil((self.level - 1)**1.2 * 20)
114 |
115 | def gain_exp(self, amount):
116 | self.exp += amount
117 | old_level = self.level
118 | dex_inc = False
119 | while self.exp >= self.max_exp():
120 | self.exp -= self.max_exp()
121 | self.level += 1
122 | avg = (self.base_str+self.base_dex)//2
123 | past_softcap = avg >= 20
124 | if self.level % (4+2*past_softcap) == 0:
125 | if one_in(2):
126 | self.base_str += 1
127 | else:
128 | self.base_dex += 1
129 | dex_inc = True
130 | if self.level % (3+past_softcap) == 0:
131 | self.g.print_msg(f"You leveled up to level {(self.level)}!", "green")
132 | old_level = self.level
133 | while True:
134 | user = self.g.input("Would you like to increase (S)TR or (D)EX?")
135 | user = user.upper()
136 | if user == "S":
137 | self.base_str += 1
138 | break
139 | elif user == "D":
140 | self.base_dex += 1
141 | dex_inc = True
142 | break
143 | else:
144 | self.g.print_msg("Please enter \"S\" or \"D\"")
145 | if dex_inc and self.armor:
146 | softcap = self.armor.dex_mod_softcap
147 | if softcap is not None:
148 | thresh = 10 + softcap * 2
149 | if self.DEX >= thresh:
150 | self.g.print_msg("Note: Any dodge bonus beyond this level of DEX is reduced due to your heavy armor.")
151 | if self.level > old_level:
152 | self.g.print_msg(f"You leveled up to level {(self.level)}!", "green")
153 |
154 | @property
155 | def MAX_HP(self):
156 | return 100 + (self.level - 1)*20
157 |
158 | def interrupt(self, force=False):
159 | if self.resting:
160 | self.g.print_msg("Your rest was interrupted.", "yellow")
161 | self.resting = False
162 | elif self.activity:
163 | if force or not self.g.yes_no(f"Continue {self.activity.name}?"):
164 | self.g.print_msg(f"You stop {self.activity.name}.")
165 | self.activity = None
166 |
167 | def drain(self, amount, silent=False):
168 | if amount <= 0:
169 | return
170 | self.hp_drain += amount
171 | self.HP = min(self.HP, self.get_max_hp())
172 | if not silent:
173 | self.g.print_msg("Your life force is drained!", "red")
174 | self.interrupt()
175 | if self.get_max_hp() <= 0:
176 | self.g.print_msg("You have died!", "red")
177 | self.dead = True
178 |
179 | def do_poison(self, amount):
180 | if amount <= 0:
181 | return
182 | self.poison += amount
183 | if self.has_effect("Rejuvenated"):
184 | self.g.print_msg("The rejunenation blocks the effects of the poison in your system.")
185 | elif self.poison >= self.HP:
186 | self.g.print_msg("You're lethally poisoned!", "red")
187 | else:
188 | self.g.print_msg("You are poisoned!", "yellow")
189 |
190 | def take_damage(self, dam, poison=False, force_interrupt=False):
191 | if dam <= 0:
192 | return
193 | self.HP -= dam
194 | if force_interrupt:
195 | self.interrupt(force=True)
196 | elif not poison: #Poison damage should only interrupt activities if it's likely to be lethal
197 | self.interrupt()
198 | else:
199 | if self.poison >= self.HP:
200 | if self.resting or self.activity:
201 | self.g.print_msg("The amount of poison in your body is lethal!", "red")
202 | self.interrupt()
203 | if self.HP <= 0:
204 | self.HP = 0
205 | self.g.print_msg("You have died!", "red")
206 | self.dead = True
207 | elif self.HP <= self.get_max_hp() // 4:
208 | self.g.print_msg("*** WARNING: Your HP is low! ***", "red")
209 |
210 | def add_item(self, item):
211 | if isinstance(item, Wand):
212 | w = next((t for t in self.inventory if isinstance(t, Wand) and type(t) == type(item)), None)
213 | if w is not None:
214 | w.charges += item.charges
215 | else:
216 | self.inventory.append(item)
217 | else:
218 | self.inventory.append(item)
219 |
220 | def rand_place(self):
221 | self.x = 0
222 | self.y = 0
223 | if not super().place_randomly():
224 | raise RuntimeError("Could not generate a valid starting position for player")
225 |
226 | def teleport(self):
227 | board = self.g.board
228 | oldloc = (self.x, self.y)
229 | for _ in range(500):
230 | x = random.randint(1, board.cols - 2)
231 | y = random.randint(1, board.rows - 2)
232 | if board.is_passable(x, y) and (x, y) != oldloc:
233 | seeslastpos = board.line_of_sight((x, y), oldloc)
234 | if not seeslastpos: #We teleported out of sight
235 | for m in self.monsters_in_fov():
236 | m.track_timer = min(m.track_timer, dice(1, 7)) #Allow them to still close in on where they last saw you, and not immediately realize you're gone
237 | self.g.print_msg("You teleport!")
238 | self.x = x
239 | self.y = y
240 | self.fov = self.calc_fov()
241 | self.grappled_by.clear()
242 | break
243 | else:
244 | self.g.print_msg("You feel yourself begin to teleport, but nothing happens.")
245 |
246 | def grapple_check(self):
247 | if self.grappled_by:
248 | stat = max(self.DEX, self.STR) #Let's use the higher of the two
249 | for m in self.grappled_by[:]:
250 | mod = calc_mod(stat)
251 | if m.has_effect("Confused"):
252 | mod += 4 #Give a bonus escaping a confused monster's grab
253 | if dice(1, 20) + mod >= m.grapple_dc:
254 | if self.STR > self.DEX or (self.STR == self.DEX and one_in(2)):
255 | break_method = "force yourself"
256 | else:
257 | break_method = "wriggle"
258 | self.g.print_msg(f"You {break_method} out of the {m.name}'s grapple.")
259 | self.remove_grapple(m)
260 | m.energy -= m.get_speed() #So they can't immediately re-grapple the player
261 | else:
262 | self.g.print_msg(f"You fail to escape the {m.name}'s grapple.", "yellow")
263 | self.energy -= self.get_speed()
264 | return True
265 | return False
266 |
267 | def move(self, dx, dy):
268 | if self.dead:
269 | self.energy = 0
270 | return False
271 | board = self.g.board
272 | if self.has_effect("Confused") and not one_in(4):
273 | odx, ody = dx, dy
274 | dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]
275 | for _ in range(2):
276 | dx, dy = random.choice(dirs)
277 | if board.is_passable(self.x+dx, self.y+dy):
278 | break
279 | if not board.is_passable(self.x+dx, self.y+dy):
280 | x, y = self.x + dx, self.y + dy
281 | obstacle = ""
282 | if board.blocks_sight(x, y):
283 | obstacle = "wall"
284 | elif (m := self.g.get_monster(x, y)):
285 | obstacle = m.name
286 | if obstacle:
287 | self.g.print_msg(f"You bump into the {obstacle}.")
288 | self.energy -= self.get_speed()#We bumped into something while confused
289 | return
290 | if one_in(3) and (odx, ody) != (dx, dy):
291 | self.g.print_msg("You stumble around.")
292 | adj = []
293 | if (m := self.g.get_monster(self.x-1, self.y)):
294 | adj.append(m)
295 | if (m := self.g.get_monster(self.x+1, self.y)):
296 | adj.append(m)
297 | if (m := self.g.get_monster(self.x, self.y+1)):
298 | adj.append(m)
299 | if (m := self.g.get_monster(self.x, self.y+1)):
300 | adj.append(m)
301 | if (m := self.g.get_monster(self.x + dx, self.y + dy)):
302 | self.moved = True
303 | if m.is_friendly():
304 | if self.grapple_check():
305 | return
306 | self.energy -= 30
307 | self.swap_with(m)
308 | m.energy = min(m.energy - 30, 0)
309 | self.g.print_msg(f"You swap places with the {m.name}.")
310 | else:
311 | self.attack(dx, dy)
312 | return True
313 | board = self.g.board
314 | if not board.is_passable(self.x + dx, self.y + dy):
315 | return False
316 | self.moved = True
317 | if self.grapple_check():
318 | return True
319 | if not super().move(dx, dy):
320 | return False
321 | self.fov = self.calc_fov()
322 | speed = self.get_speed()
323 | board = self.g.board
324 | if dx != 0 or dy != 0:
325 | tile = board.get(self.x, self.y)
326 | if not tile.walked:
327 | tile.walked = True
328 | if tile.items:
329 | strings = list(map(lambda item: item.name, tile.items))
330 | if len(strings) == 1:
331 | self.g.print_msg(f"You see a {strings[0]} here.")
332 | else:
333 | self.g.print_msg(f"At this location you see the following items: {', '.join(strings)}")
334 | for m in adj:
335 | dist = abs(self.x - m.x) + abs(self.y - m.y)
336 | if m.has_effect("Confused") or m.has_effect("Stunned"): #Confused monsters can't make opportunity attacks
337 | continue
338 | if m.is_friendly():
339 | continue
340 | mon_speed = m.get_speed()
341 | fuzz = speed//3
342 | is_faster = mon_speed > speed + random.randint(-fuzz, fuzz)
343 | if m.is_aware and m.sees_player() and dist >= 2 and is_faster and one_in(3):
344 | self.g.print_msg(f"As you move away from {m.name}, it makes an opportunity attack!", "yellow")
345 | m.melee_attack(target=self)
346 | self.energy -= 30
347 | return True
348 |
349 | def gain_effect(self, name, duration):
350 | types = self.g.effect_types
351 | if name in types:
352 | typ = types[name]
353 | if name in self.effects:
354 | self.effects[name].duration += div_rand(duration, 2)
355 | else:
356 | self.effects[name] = (eff := typ(duration))
357 | self.g.print_msg(eff.add_msg)
358 |
359 | def lose_effect(self, name, silent=False):
360 | if name in self.effects:
361 | eff = self.effects[effect]
362 | if silent:
363 | self.g.print_msg(eff.rem_msg)
364 | del self.effects[effect]
365 | eff.on_expire(self)
366 |
367 | def has_effect(self, name):
368 | return name in self.effects
369 |
370 | def sees(self, pos, clairv=False):
371 | clairv = clairv and self.has_effect("Clairvoyance")
372 | if pos in self.fov:
373 | return True
374 | elif clairv:
375 | x, y = pos
376 | dx = self.x - x
377 | dy = self.y - y
378 | dist = math.sqrt(dx**2 + dy**2)
379 | return round(dist) <= 8
380 | else:
381 | return False
382 |
383 | def monsters_in_fov(self, include_friendly=False, clairvoyance=False):
384 | if clairvoyance:
385 | clairvoyance = self.has_effect("Clairvoyance")
386 | for m in self.g.monsters:
387 | if not include_friendly and m.is_friendly():
388 | continue
389 | mx, my = m.x, m.y
390 | dx = self.x - mx
391 | dy = self.y - my
392 | dist = math.sqrt(dx**2 + dy**2)
393 | if (mx, my) in self.fov or (clairvoyance and round(dist) <= 8):
394 | yield m
395 |
396 | def adjust_duration(self, effect, amount):
397 | if effect in self.effects:
398 | eff = self.effects[effect]
399 | eff.duration += amount
400 | if eff.duration <= 0:
401 | del self.effects[effect]
402 | self.g.print_msg(eff.rem_msg)
403 | eff.on_expire(self)
404 |
405 | def stealth_mod(self):
406 | mod = self.passives["stealth"]
407 | if self.last_attacked:
408 | mod -= 5
409 | if self.has_effect("Invisible"):
410 | mod += 5
411 | if self.armor:
412 | if self.armor.stealth_pen > 0:
413 | mod -= self.armor.stealth_pen
414 | return mod
415 |
416 | def knockback_from(self, ox, oy, force):
417 | if force <= 0:
418 | return
419 | dx = self.x - ox
420 | dy = self.y - oy
421 | if dx != 0:
422 | dx //= abs(dx)
423 | if dy != 0:
424 | dy //= abs(dy)
425 | dx *= force
426 | dy *= force
427 | self.knockback(dx, dy)
428 |
429 | def knockback(self, dx, dy):
430 | if dx == 0 and dy == 0:
431 | return
432 | board = self.g.board
433 | newpos = self.x+dx, self.y+dy
434 | oldpos = (self.x, self.y)
435 | dist = 0
436 | self.grappled_by.clear()
437 | for x, y in board.line_between(oldpos, newpos, skipfirst=True):
438 | if not board.is_passable(x, y):
439 | if dist > 0:
440 | if (m := self.g.get_monster(x, y)) is not None:
441 | dam = dice(1, dist*3)
442 | self.g.print_msg(f"You take {dam} damage by the impact!", "red")
443 | self.take_damage(dam)
444 | amount = max(1, binomial(dam, 50))
445 | self.g.print_msg(f"The {m.name} takes {dam} damage from your impact!")
446 | m.take_damage(dam, source=self)
447 | self.energy -= 15
448 | m.energy -= 15
449 | else:
450 | dam = dice(2, dist*3)
451 | self.g.print_msg(f"You take {dam} damage by the impact!", "red")
452 | self.take_damage(dam)
453 | self.energy -= 30
454 | return
455 | if dist == 0:
456 | self.g.print_msg("You're knocked back!", "red")
457 | self.interrupt(force=True)
458 | dist += 1
459 | self.move_to(x, y)
460 | self.g.draw_board()
461 | time.sleep(0.01)
462 |
463 |
464 | def throw_item(self, item):
465 | g = self.g
466 | if not (mons := list(self.monsters_in_fov())):
467 | g.print_msg("You don't see any targets to throw an item.")
468 | return
469 | if item.thrown:
470 | short, long = item.thrown
471 | else:
472 | short, long = 4, 12
473 | def cond(m): #Here, we take the number of tiles of the LOS line
474 | dx = abs(self.x - m.x)
475 | dy = abs(self.y - m.y)
476 | return max(dx, dy) <= long
477 | target = g.select_monster_target(cond, error=f"None of your targets are within range of your {item.name}.")
478 | if not target:
479 | return
480 | g.select = target
481 | dx = abs(self.x - target.x)
482 | dy = abs(self.y - target.y)
483 | num_tiles = max(dx, dy)
484 | pen = 0
485 | foe_adjacent = False
486 | if (m := g.get_monster(self.x-1, self.y)) and m.is_aware and not m.incapacitated():
487 | foe_adjacent = True
488 | elif (m := g.get_monster(self.x+1, self.y)) and m.is_aware and not m.incapacitated():
489 | foe_adjacent = True
490 | elif (m := g.get_monster(self.x, self.y+1)) and m.is_aware and not m.incapacitated():
491 | foe_adjacent = True
492 | elif (m := g.get_monster(self.x, self.y+1)) and m.is_aware and not m.incapacitated():
493 | foe_adjacent = True
494 | if foe_adjacent: #If there's a monster who can see us and is right next to us, it's harder to aim
495 | pen += 3
496 | avg_pen = pen
497 | if num_tiles > short:
498 | scale = 8
499 | g.print_msg(f"Ranged accuracy is reduced beyond {short} tiles.", "yellow")
500 | pen += mult_rand_frac(num_tiles - short, scale, long - short)
501 | avg_pen += scale*(num_tiles-short)/(long-short)
502 | if item.heavy:
503 | pen += 2
504 | g.print_msg(f"This weapon is heavy, so accuracy is reduced.", "yellow")
505 | if not item.thrown:
506 | pen += 2
507 | g.print_msg(f"This weapon isn't designed to be thrown, so accuracy is reduced.", "yellow")
508 | mod = self.attack_mod(throwing=False, avg=False)
509 | avg_mod = self.attack_mod(throwing=False, avg=True)
510 | mod -= pen
511 | avg_mod -= avg_pen
512 | AC = target.get_ac()
513 | avg_AC = target.get_ac(avg=True)
514 | if target.incapacitated():
515 | AC = min(AC, 5)
516 | avg_AC = min(avg_AC, 5)
517 | prob = to_hit_prob(avg_AC, avg_mod)*100
518 | prob_str = display_prob(prob)
519 | self.g.print_msg(f"Throwing {item.name} at {target.name} - {prob_str} to-hit.")
520 | c = g.input("Press enter to throw, or enter \"C\" to cancel")
521 | g.select = None
522 | if c and c[0].lower() == "c":
523 | return
524 | if g.board.line_of_sight((self.x, self.y), (target.x, target.y)):
525 | line = list(g.board.line_between((self.x, self.y), (target.x, target.y)))
526 | else:
527 | line = list(g.board.line_between((target.x, target.y), (self.x, self.y)))
528 | line.reverse()
529 | for x, y in line:
530 | g.set_projectile_pos(x, y)
531 | g.draw_board()
532 | time.sleep(0.03)
533 | g.clear_projectile()
534 | roll = dice(1, 20)
535 | crit = False
536 | if roll == 1:
537 | hits = False
538 | elif roll == 20:
539 | hits = True
540 | else:
541 | hits = roll + mod >= AC
542 | if hits:
543 | if x_in_y(item.crit_chance, 20):
544 | crit = True
545 | dmg = item.dmg
546 | damage = dmg.roll()
547 | damage += calc_mod(self.attack_stat())
548 | damage += item.enchant
549 | if not item.thrown:
550 | damage = random.randint(1, damage)
551 | if crit:
552 | bonus = 0
553 | for _ in range(item.crit_mult - 1):
554 | bonus += dmg.roll()
555 | if not item.thrown:
556 | bonus = random.randint(1, bonus)
557 | damage += bonus
558 | damage = target.apply_armor(damage, 1+crit) #Crits give 50% armor penetration
559 | if damage <= 0:
560 | g.print_msg(f"The {item.name} hits the {target.name} but does no damage.")
561 | else:
562 | msg = f"The {item.name} hits the {target.name} for {damage} damage."
563 | if target.HP > damage: #Only print the HP message if the attack didn't kill them
564 | msg += f" Its HP: {target.HP-damage}/{target.MAX_HP}"
565 | self.g.print_msg(msg)
566 | if crit:
567 | self.g.print_msg("Critical!", "green")
568 | target.take_damage(damage, self)
569 | else:
570 | g.print_msg(f"The {item.name} misses the {target.name}.")
571 | g.spawn_item(item.__class__(), (target.x, target.y))
572 | if item is self.weapon:
573 | self.weapon = UNARMED
574 |
575 | self.inventory.remove(item)
576 | self.did_attack = True
577 | for m in self.monsters_in_fov():
578 | if m is target:
579 | if not m.despawn_summon():
580 | m.on_alerted()
581 | elif one_in(3):
582 | m.on_alerted()
583 | cost = 30
584 | if not item.thrown:
585 | cost *= 2 if item.heavy else 1.5
586 | self.energy -= cost
587 |
588 | def is_unarmed(self):
589 | return self.weapon is UNARMED
590 |
591 | def detectability(self):
592 | d = []
593 | mons = list(filter(lambda m: not m.is_aware, self.monsters_in_fov()))
594 | if not mons:
595 | return None
596 | mod = self.stealth_mod() + calc_mod(self.DEX, avg=True)
597 | total_stealth = 1
598 | for m in mons:
599 | perc = m.passive_perc - 5*m.has_effect("Asleep")
600 | stealth_prob = d20_prob(perc, mod)
601 | stealth_prob *= 1 - 1/30 #Includes the 1/30 auto-fail chance
602 | if not self.last_attacked:
603 | stealth_prob += (1 - stealth_prob)/2.5
604 | total_stealth *= stealth_prob
605 | #total_stealth is the chance of remaining UNdetected
606 | #To get the detectability, invert it
607 | return 1 - total_stealth
608 |
609 | def do_turn(self):
610 | self.last_attacked = self.did_attack
611 | self.last_moved = self.moved
612 | self.moved = False
613 |
614 | self.mod_str = 0
615 | self.mod_dex = 0
616 |
617 | #Passive modifiers go here
618 | self.mod_str += self.passives["STR"] - self.str_drain
619 | self.mod_dex += self.passives["DEX"] - self.dex_drain
620 |
621 | self.ticks += 1
622 | for m in self.grappled_by[:]:
623 | dist = abs(m.x - self.x) + abs(m.y - self.y)
624 | if dist > 1:
625 | self.remove_grapple(m)
626 | can_regen = self.poison <= 0 and self.fire <= 0
627 | if self.poison > 0:
628 | dmg = 1 + math.isqrt(self.poison//2)
629 | if dmg > self.poison:
630 | dmg = self.poison
631 | self.poison -= dmg
632 | if not self.has_effect("Rejuvenated"): #Rejuvenation allows poison to tick down without doing any damage
633 | self.take_damage(dmg, True)
634 | if dmg > 3:
635 | if one_in(2):
636 | self.g.print_msg("You feel very sick.", "red")
637 | elif one_in(3):
638 | self.g.print_msg("You feel sick.", "red")
639 | if self.engulfed_by:
640 | if self.engulfed_by in self.grappled_by:
641 | self.turns_engulfed += 1
642 | if self.fire > 0:
643 | self.fire -= 1
644 | if self.fire <= 0:
645 | self.g.print_msg("The water extinguishes the fire.")
646 | if self.turns_engulfed > 1:
647 | self.g.print_msg("You can't breathe, as you are engulfed by the water!", "red")
648 | amount = 3 + (self.turns_engulfed - 1)**0.7
649 | self.take_damage(div_rand(int(100*amount), 100))
650 | else:
651 | self.turns_engulfed = 0
652 | self.engulfed_by = None
653 | if self.fire > 0:
654 | self.g.print_msg("The fire burns you!", "red")
655 | dmg = dice(1, 10)+2
656 | self.take_damage(dmg, force_interrupt=True) #Always interrupt activities for this
657 | if self.ticks % 2 == 0 and dice(1, 20) + calc_mod(self.DEX) >= 10:
658 | self.fire -= 1
659 | if self.fire <= 0:
660 | self.g.print_msg("You manage to fully extinguish the fire.", "green")
661 | if can_regen and self.HP < self.get_max_hp():
662 | if self.ticks % 6 == 0:
663 | self.HP += 1
664 | if self.has_effect("Rejuvenated"):
665 | if self.hp_drain > 0:
666 | self.hp_drain -= 1
667 | self.HP += random.randint(4, 8)
668 | self.HP = min(self.HP, self.get_max_hp())
669 | if self.ticks % 6 == 0:
670 | self.g.print_msg("You feel extremely rejuvenated.", "green")
671 | elif self.ticks % 6 == 0:
672 | if self.hp_drain > 0 and one_in(4):
673 | self.hp_drain -= 1
674 | if self.hp_drain == 0:
675 | self.g.print_msg("You have fully recovered from drain.", "green")
676 | recover = 3 if self.has_effect("Rejuvenated") else 20
677 | if self.ticks % recover == 0:
678 | if self.str_drain > 0 and one_in(recover):
679 | self.str_drain -= 1
680 | if self.dex_drain > 0 and one_in(recover):
681 | self.dex_drain -= 1
682 | for e in list(self.effects.keys()):
683 | self.adjust_duration(e, -1)
684 | mod = self.stealth_mod()
685 | for m in self.g.monsters:
686 | m.check_timer -= 1
687 | if m.check_timer <= 0 or self.did_attack or one_in(25): #Very occasionally make the check before the timer reaches zero
688 | m.reset_check_timer()
689 | if not m.is_aware or self.did_attack: #If you attack while invisible, maybe alert the nearby monsters to your position
690 | roll = dice(1, 20)
691 | perc = m.passive_perc
692 | if m.has_effect("Asleep"):
693 | perc -= 5
694 | if (m.x, m.y) in self.fov and (one_in(30) or roll + div_rand(self.DEX - 10, 2) + mod < perc):
695 | m.on_alerted()
696 | m.lose_effect("Asleep")
697 | self.did_attack = False
698 |
699 | def attack_stat(self):
700 | stat = self.STR
701 | if self.weapon.finesse:
702 | stat = max(stat, self.DEX)
703 | return stat
704 |
705 | def attack_mod(self, throwing=False, avg=False):
706 | stat = self.attack_stat()
707 | mod = calc_mod(stat, avg=avg)
708 | if not throwing:
709 | if self.weapon is not UNARMED:
710 | if self.weapon.heavy:
711 | mod -= 2
712 | else:
713 | mod += 2
714 | mod += self.weapon.enchant
715 | return mod + self.passives["to_hit"]
716 |
717 | def base_damage_dice(self):
718 | return self.weapon.dmg
719 |
720 | def apply_resist(self, dam):
721 | if self.has_effect("Resistance"):
722 | dam = binomial(dam, 50)
723 | return dam
724 |
725 | def get_protect(self):
726 | protect = self.armor.protect if self.armor else 0
727 | protect += self.passives["protect"]
728 | return protect
729 |
730 | def protect_roll(self):
731 | prot = self.get_protect()
732 | roll1 = random.randint(0, 4*prot)
733 | roll2 = random.randint(0, 2*prot)
734 | return max(roll1, roll2)
735 |
736 | def apply_armor(self, dam):
737 | return max(0, dam - self.protect_roll())
738 |
739 | def attack(self, dx, dy):
740 | x, y = self.x + dx, self.y + dy
741 | if not self.g.monster_at(x, y):
742 | self.g.print_msg("You strike at the air.")
743 | self.energy -= self.get_speed()
744 | return
745 | mon = self.g.get_monster(x, y)
746 | ench_type = self.weapon.ench_type
747 | cost = min(self.get_speed(), 45)
748 | if ench_type == "speed":
749 | cost = div_rand(cost, 2)
750 | self.energy -= cost
751 | roll = dice(1, 20)
752 | adv = False
753 | if not mon.is_aware or self.has_effect("Invisible"):
754 | adv = True
755 | finesse = self.weapon.finesse
756 | unarmed = self.weapon is UNARMED
757 | sneak_attack = adv and dice(1, 20) + calc_mod(self.DEX) + self.passives["stealth"] >= mon.passive_perc
758 | chance = 3
759 | if unarmed:
760 | chance -= 1
761 | elif finesse:
762 | chance += 1
763 | sneak_attack = sneak_attack and x_in_y(chance, 8)
764 | if mon.has_effect("Asleep"):
765 | sneak_attack = True
766 | eff_ac = mon.get_ac()
767 | if mon.has_effect("Paralyzed"):
768 | eff_ac = min(eff_ac, 5)
769 | adv = True
770 | if adv:
771 | roll = max(roll, dice(1, 20))
772 | crit = False
773 | mod = self.attack_mod()
774 | if roll == 1:
775 | hits = False
776 | elif roll == 20:
777 | hits = True
778 | else:
779 | hits = roll + mod >= eff_ac
780 | if sneak_attack:
781 | if one_in(3):
782 | self.g.print_msg(f"The {mon.name} is caught off-guard by your sneak attack!")
783 | else:
784 | self.g.print_msg(f"You catch the {mon.name} completely unaware!")
785 | hits = True
786 | mon.energy -= random.randint(15, 30)
787 | if mon.has_effect("Asleep"):
788 | hits = True
789 | mon.lose_effect("Asleep")
790 | mon.on_alerted()
791 | if not sneak_attack: #If we did a sneak attack, let's continue to be stealthy
792 | self.did_attack = True
793 | if not hits:
794 | self.g.print_msg(f"Your attack misses the {mon.name}.")
795 | else:
796 | if x_in_y(self.weapon.crit_chance, 20):
797 | crit = True
798 | stat = self.attack_stat()
799 | dmgdice = self.base_damage_dice()
800 | dam = dmgdice.roll()
801 | mult = self.weapon.crit_mult
802 | if crit:
803 | for _ in range(mult - 1):
804 | dam += dmgdice.roll()
805 | if sneak_attack:
806 | scale = 6
807 | lev = self.level
808 | if finesse:
809 | lev = mult_rand_frac(lev, 4, 3)
810 | val = random.randint(1, lev)
811 | scale_int = 1 + (val - 1) // scale
812 | scale_mod = (val - 1) % scale
813 | bonus = dice(scale_int, 6) + mult_rand_frac(dice(1, 6), scale_mod, scale)
814 | if unarmed:
815 | bonus = max(1, div_rand(bonus, 3))
816 | softcap = dmgdice.avg()*mult
817 | if bonus > softcap: #Adds a soft cap to sneak attack damage
818 | diff = bonus - softcap
819 | bonus = softcap + div_rand(diff, 3)
820 | dam += bonus
821 | dam += div_rand(stat - 10, 2)
822 | dam += self.weapon.enchant
823 | dam = max(dam, 1)
824 | dam = mon.apply_armor(dam, 1+crit)
825 | if ench_type == "armor piercing":
826 | dam2 = mon.apply_armor(dam, 1+crit)
827 | if dam < dam2:
828 | dam = dam2
829 | min_dam = dice(1, 6) if sneak_attack else 0 #Sneak attacks are guaranteed to deal at least 1d6 damage
830 | dam = max(dam, min_dam)
831 | dmgtype = self.weapon.dmg_type
832 | if dam > 0:
833 | msg = f"You hit the {mon.name} for {dam} damage."
834 | if mon.HP > dam:
835 | msg += f" Its HP: {mon.HP-dam}/{mon.MAX_HP}"
836 | self.g.print_msg(msg)
837 | if crit:
838 | self.g.print_msg("Critical!", "green")
839 | else:
840 | self.g.print_msg(f"You hit the {mon.name} but do no damage.")
841 | if dam > 0 and ench_type == "life stealing" and one_in(7):
842 | base = dam
843 | fuzz = (base+1//2)
844 | base += random.randint(0, fuzz) - random.randint(0, fuzz)
845 | stolen = min(dice(4, 4), base)
846 |
847 | if stolen > 0:
848 | self.g.print_msg(f"You steal an additional {stolen} HP.", "green")
849 | dam += stolen
850 | mon.take_damage(dam, self)
851 |
852 | self.adjust_duration("Invisible", -random.randint(0, 6))
853 | if not sneak_attack:
854 | for m in self.monsters_in_fov():
855 | d = m.distance(self, False)
856 | if one_in(6) or one_in(d):
857 | m.on_alerted()
858 |
859 | def defeated_monster(self, mon):
860 | self.g.print_msg(f"The {mon.name} dies!", "green")
861 | self.g.remove_monster(mon)
862 | num = len(list(filter(lambda m: not m.is_friendly(), self.g.monsters)))
863 | self.remove_grapple(mon)
864 | d = mon.diff
865 | if d > 4:
866 | d = 4 + (mon.diff - 4) / 2
867 | v1 = min(d, 6*math.log2(1+d/6))
868 | val = (v1 - 1)**0.85
869 | gain = math.ceil(12 * 2**val) - 6
870 | self.gain_exp(gain)
871 | if mon.weapon:
872 | if isinstance(mon.weapon, list):
873 | for w in mon.weapon:
874 | if one_in(4):
875 | weapon = w()
876 | self.g.print_msg(f"The {mon.name} drops its {weapon.name}!", "green")
877 | self.g.spawn_item(weapon, (mon.x, mon.y))
878 | elif one_in(4):
879 | weapon = mon.weapon()
880 | self.g.print_msg(f"The {mon.name} drops its {weapon.name}!", "green")
881 | self.g.spawn_item(weapon, (mon.x, mon.y))
882 | if num == 0:
883 | if self.g.level == 1:
884 | self.g.print_msg("Level complete! Move onto the stairs marked with a \">\", then press SPACE to go down to the next level.")
885 | board = self.g.board
886 | los_tries = 100
887 | while True:
888 | sx = random.randint(1, board.cols - 2)
889 | sy = random.randint(1, board.rows - 2)
890 | if not board.is_passable(sx, sy):
891 | continue
892 | if los_tries > 0 and board.line_of_sight((self.x, self.y), (sx, sy)):
893 | los_tries -= 1
894 | continue
895 | if abs(self.x - sx) + abs(self.y - sy) <= 4:
896 | continue
897 | tile = board.get(sx, sy)
898 | if tile.items:
899 | continue
900 | tile.symbol = ">"
901 | tile.stair = True
902 | break
903 |
904 | def inventory_menu(self):
905 | from gameobj import GameTextMenu
906 | menu = GameTextMenu(self.g)
907 | max_lines = get_terminal_size().lines
908 | scroll = 0
909 | items = self.inventory[:]
910 | d = {}
911 | for item in items:
912 | name = item.name
913 | if isinstance(item, Wand):
914 | name += f" - {item.charges} charges"
915 | elif isinstance(item, Ring) and item in self.worn_rings:
916 | name += " (worn)"
917 | elif isinstance(item, Weapon) and item is self.weapon:
918 | name += " (wielded)"
919 | if name not in d:
920 | d[name] = [0, item]
921 | d[name][0] += 1
922 | strings = []
923 | choices = []
924 | for i, name in enumerate(sorted(d.keys())):
925 | n = name
926 | num, item = d[name]
927 | if num > 1:
928 | n += f" ({num})"
929 | strings.append(n)
930 | choices.append(item)
931 | shown_return_msg = False
932 | chars = "1234567890abcdefghijklmnop"
933 | while True:
934 | menu.clear_msg()
935 | menu.add_text("Select which item?")
936 | menu.add_text("Use the w and s keys to scroll, press Enter to cancel")
937 | menu.add_line()
938 | num_display = min(len(chars), max_lines - 4)
939 | scroll_limit = max(0, len(strings) - num_display)
940 | n = min(len(strings), num_display)
941 | padsize = min(30, get_terminal_size().columns)
942 | for i in range(n):
943 | string = strings[i+scroll].ljust(padsize)
944 | if i == 0 and scroll > 0:
945 | string += " (↑)"
946 | if i == n - 1 and scroll < scroll_limit:
947 | string += " (↓)"
948 | menu.add_text(f"{chars[i]} - {string}")
949 | menu.add_line()
950 | menu.display()
951 | choice = menu.getch()
952 | char = chr(choice)
953 | if char == "w":
954 | if scroll > 0:
955 | scroll -= 1
956 | elif char == "s":
957 | scroll += 1
958 | if scroll > scroll_limit:
959 | scroll = scroll_limit
960 | elif choice == 10: #Enter:
961 | break
962 | elif char in chars:
963 | ind = chars.index(char)
964 | if ind < num_display:
965 | item = choices[ind+scroll]
966 | menu.clear_msg()
967 | menu.add_text(item.name)
968 | menu.add_line()
969 | menu.add_text(item.description)
970 | if isinstance(item, Weapon):
971 | dmg = item.dmg
972 | X, Y = dmg.num, dmg.sides
973 | menu.add_text(f"This weapon deals {X}d{Y} base damage.")
974 | if item.heavy:
975 | menu.add_text("This weapon is heavy, so attacks are a bit less accurate.")
976 | if item.finesse:
977 | menu.add_text("This weapon is designed in a way that allows it to adapt to your character's style. Attack and damage rolls use the higher of your STR or DEX.")
978 | if item.crit_chance > 1:
979 | menu.add_text(f"Base critical chance on a hit with this weapon is {item.crit_chance}x higher.")
980 | if item.crit_mult > 2:
981 | menu.add_text(f"This weapon deals {item.crit_mult}x damage on a critical hit.")
982 | elif isinstance(item, Armor):
983 | if item.stealth_pen > 0:
984 | menu.add_text(f"This armor tends to make some noise when moving. -{item.stealth_pen} to stealth checks.")
985 | menu.add_line()
986 | can_throw = isinstance(item, Weapon)
987 | use_disp = "use item"
988 | if isinstance(item, Weapon):
989 | if item is self.weapon:
990 | use_disp = "unwield"
991 | else:
992 | use_disp = "wield"
993 | menu.add_text(f"u - {use_disp}") #TODO: Combine the "Throw" menu into this menu and remove the "t" keybind
994 | if can_throw:
995 | menu.add_text("t - throw")
996 | menu.add_text("Enter - return")
997 | menu.display()
998 | while True:
999 | c = menu.getch()
1000 | if c == 10:
1001 | break
1002 | elif chr(c) == "u":
1003 | menu.close()
1004 | result = item.use(self)
1005 | if result is not False: #False to not use time up a turn or the item
1006 | if result is not None: #None uses a turn without removing the item
1007 | self.inventory.remove(item)
1008 | self.energy -= self.get_speed()
1009 | return
1010 | elif chr(c) == "t" and can_throw:
1011 | menu.close()
1012 | self.throw_item(item)
1013 | return
1014 | menu.close()
1015 |
--------------------------------------------------------------------------------
/monster.py:
--------------------------------------------------------------------------------
1 | import random, time
2 | from utils import *
3 | from entity import Entity
4 | from items import *
5 |
6 | class Attack:
7 |
8 | def __init__(self, dmg, to_hit, msg="The {0} attacks {1}"):
9 | self.dmg = dmg
10 | self.to_hit = to_hit
11 | self.msg = msg
12 |
13 | def dmg_bonus(self, mon, player):
14 | return 0
15 |
16 | def can_use(self, mon, player):
17 | return True
18 |
19 | def on_hit(self, player, mon, dmg):
20 | pass
21 |
22 | symbols = {}
23 | dup_warnings = []
24 |
25 | class Monster(Entity):
26 | min_level = 1
27 | speed = 30
28 | diff = 1
29 | AC = 10
30 | to_hit = 0
31 | passive_perc = 11
32 | DEX = 10
33 | WIS = 10
34 | grapple_dc = 10
35 | armor = 0
36 | attacks = [Attack((1, 3), 0)]
37 | spells = []
38 | beast = True
39 | symbol = "?"
40 | weapon = None
41 | eff_immunities = set()
42 |
43 | #Monster traits
44 | rubbery = False
45 |
46 | def __init__(self, g, name="monster", HP=10, ranged=None, ranged_dam=(2, 3)):
47 | super().__init__(g)
48 | if ranged is None:
49 | ranged = one_in(5)
50 | if not isinstance(HP, int):
51 | raise ValueError(f"HP must be an integer, got {repr(HP)} instead")
52 | self.HP = HP
53 | self.MAX_HP = HP
54 | self.name = name
55 | self.ranged = ranged
56 | self.last_seen = None
57 | self.dir = None
58 | self.ranged_dam = ranged_dam
59 | self.track_timer = 0
60 | self.is_aware = False
61 | self.check_timer = 1
62 | self.effects = {}
63 | self.summon_timer = None
64 | self.energy = -random.randrange(self.speed)
65 | self.target = None
66 |
67 | def is_friendly(self):
68 | if self.has_effect("Charmed"):
69 | return True
70 | return self.summon_timer is not None
71 |
72 | def despawn(self):
73 | self.HP = 0
74 | self.energy = -self.get_speed()
75 | self.g.remove_monster(self)
76 |
77 | def __init_subclass__(cls):
78 | if cls.symbol in symbols:
79 | other = symbols[cls.symbol]
80 | dup_warnings.append(f"{cls.__name__} has same symbol as {other.__name__}")
81 | else:
82 | symbols[cls.symbol] = cls
83 |
84 | def get_speed(self):
85 | speed = self.speed
86 | #When effects modify speed, the effects will go here
87 | return speed
88 |
89 | def reset_check_timer(self):
90 | self.check_timer = random.randint(1, 4)
91 |
92 | def move(self, dx, dy):
93 | board = self.g.board
94 | if super().move(dx, dy):
95 | self.energy -= 30
96 | return True
97 | return False
98 |
99 | def is_eff_immune(self, eff):
100 | return eff in self.eff_immunities
101 |
102 | def get_ac(self, avg=False):
103 | return 10 + calc_mod(self.DEX, avg)
104 |
105 | def choose_polymorph_type(self):
106 | #Note: A bit of a hack using object polymorphing
107 | types = Monster.__subclasses__()
108 | candidates = list(filter(lambda typ: typ.diff <= self.diff and typ.beast and typ != self.__class__, types))
109 | assert len(candidates) > 0
110 | tries = 100
111 | while tries > 0:
112 | tries -= 1
113 | maxdiff = max(1, self.diff - one_in(2))
114 | newdiff = 1
115 | for _ in range(random.randint(2, 3)):
116 | newdiff = random.randint(newdiff, maxdiff)
117 | choices = list(filter(lambda typ: newdiff == typ.diff, candidates))
118 | if not choices:
119 | continue
120 | chosen = random.choice(choices)
121 | if one_in(6):
122 | return chosen
123 | inst = chosen(self.g)
124 | if inst.MAX_HP < self.MAX_HP:
125 | if chosen.armor <= self.armor or one_in(2):
126 | return chosen
127 | return random.choice(candidates)
128 |
129 | def polymorph(self):
130 | oldname = self.name
131 | typ = self.choose_polymorph_type()
132 | self.__class__ = typ
133 | inst = typ(self.g)
134 | self.ranged = False
135 | self._symbol = inst.symbol
136 | self.HP = inst.HP
137 | self.MAX_HP = inst.MAX_HP
138 | self.name = inst.name
139 | a_an = "an" if self.name[0] in "aeiou" else "a"
140 | self.g.print_msg_if_sees((self.x, self.y), f"The {oldname} polymorphs into a {self.name}!")
141 |
142 | def has_effect(self, name):
143 | return name in self.effects
144 |
145 | def lose_effect(self, name):
146 | if name in self.effects:
147 | del self.effects[name]
148 |
149 | def incapacitated(self):
150 | incap_effs = ["Asleep", "Stunned", "Paralyzed"]
151 | for e in incap_effs:
152 | if self.has_effect(e):
153 | return True
154 | return False
155 |
156 | def gain_effect(self, name, duration):
157 | if name not in self.effects:
158 | self.effects[name] = 0
159 | self.effects[name] += duration
160 | if self.incapacitated():
161 | player = self.g.player
162 | player.remove_grapple(self)
163 |
164 | def lose_effect(self, name):
165 | if name in self.effects:
166 | del self.effects[name]
167 |
168 | def despawn_summon(self):
169 | if self.summon_timer is None:
170 | return False
171 | self.despawn()
172 | self.g.print_msg_if_sees((self.x, self.y), "Your summoned ally disappears!")
173 | return True
174 |
175 | def take_damage(self, dam, source=None):
176 | self.HP -= dam
177 | if source is self.g.player and self.despawn_summon():
178 | return
179 | if self.HP <= 0:
180 | self.despawn()
181 | if source is not None:
182 | if source is self.g.player or source.is_friendly():
183 | self.g.player.defeated_monster(self)
184 | else:
185 | self.despawn_summon()
186 |
187 | def do_turn(self):
188 | self.energy += self.get_speed()
189 | while self.energy > 0:
190 | old = self.energy
191 | self.actions()
192 | if self.energy == old:
193 | self.energy = min(self.energy, 0)
194 | self.tick_effects()
195 |
196 | def tick_effects(self):
197 | if self.summon_timer is not None and self.summon_timer > 0:
198 | self.summon_timer -= 1
199 | if self.summon_timer == 0:
200 | self.despawn()
201 | self.g.print_msg_if_sees((self.x, self.y), "Your summoned ally disappears!")
202 | return
203 | if self.track_timer > 0:
204 | self.track_timer -= 1
205 | for e in list(self.effects.keys()):
206 | self.effects[e] -= 1
207 | if self.effects[e] <= 0:
208 | del self.effects[e]
209 | if e == "Confused":
210 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} is no longer confused.")
211 | elif e == "Stunned":
212 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} is no longer stunned.")
213 | elif e == "Frightened":
214 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} regains courage.")
215 | elif e == "Charmed":
216 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} becomes hostile again!", "yellow")
217 | self.energy -= self.get_speed()
218 | self.target = self.g.player
219 |
220 | def should_use_ranged(self):
221 | board = self.g.board
222 | player = self.g.player
223 | if not self.has_line_of_fire():
224 | return False
225 | return x_in_y(2, 5)
226 |
227 | def modify_damage(self, target, damage, crit=False):
228 | player = self.g.player
229 | if target is player:
230 | protect = target.armor.protect if target.armor else 0
231 | protect += target.passives["protect"]
232 | else:
233 | protect = target.armor
234 | if protect > 0:
235 | if target is player:
236 | dam_red = player.protect_roll()
237 | if crit: #Monster crits against player don't increase damage, but ignores a portion of armor
238 | dam_red = random.randint(0, dam_red)
239 | damage -= dam_red
240 | else:
241 | damage -= random.randint(0, protect*2)
242 | if damage <= 0:
243 | return 0
244 | if target is player and player.has_effect("Resistance"):
245 | damage = binomial(damage, 50)
246 | return max(damage, 0)
247 |
248 | def melee_attack(self, target=None, attack=None, force=False):
249 | if attack is None:
250 | attacks = list(filter(lambda a: isinstance(a, list) or a.can_use(self, self.g.player), self.attacks))
251 | if not attacks:
252 | return
253 | attack = random.choice(attacks)
254 | if isinstance(attack, list):
255 | c = list(filter(lambda a: a.can_use(self, self.g.player), attack))
256 | attack = random.choice(c)
257 | player = self.g.player
258 | if target is None:
259 | target = player
260 | roll = dice(1, 20)
261 | disadv = 0
262 | disadv += target.has_effect("Invisible")
263 | disadv += self.has_effect("Frightened") and self.sees_player()
264 | for _ in range(disadv):
265 | roll = min(roll, dice(1, 20))
266 | if target is player:
267 | ac_mod = player.get_ac_bonus()
268 | AC = 10 + ac_mod
269 | else:
270 | AC = target.get_ac()
271 | mon = target
272 | mon.target = self
273 | bonus = attack.to_hit
274 | total = roll + bonus
275 | if roll == 1:
276 | hits = False
277 | elif roll == 20:
278 | hits = True
279 | else:
280 | hits = total >= AC
281 |
282 | if not hits:
283 | if target is not player or roll == 1 or total < AC - ac_mod:
284 | the_target = "you" if target is player else f"the {target.name}"
285 | self.g.print_msg_if_sees((target.x, target.y), f"The {self.name}'s attack misses {the_target}.")
286 | else:
287 | self.g.print_msg(f"You evade the {self.name}'s attack.")
288 | else:
289 | base = dice(*attack.dmg)
290 | if target is player:
291 | base += attack.dmg_bonus(self, target)
292 | crit = roll == 20 and dice(1, 20) + bonus >= AC
293 | damage = self.modify_damage(target, base, crit)
294 | the_target = "you" if target is player else f"the {target.name}"
295 | if damage:
296 | self.g.print_msg_if_sees((target.x, target.y), attack.msg.format(self.name, the_target) + f" for {damage} damage!", "red" if target is player else "white")
297 | if target is player:
298 | target.take_damage(damage)
299 | attack.on_hit(player, self, damage)
300 | else:
301 | target.take_damage(damage, source=self)
302 | else:
303 | self.g.print_msg_if_sees((target.x, target.y), attack.msg.format(self.name, the_target) + " but does no damage.")
304 |
305 | def do_melee_attack(self, target=None):
306 | player = self.g.player
307 | if target is not None:
308 | target = player
309 | for att in self.attacks:
310 | if isinstance(att, list):
311 | attacks = list(filter(lambda a: a.can_use(self, self.g.player), att))
312 | if not attacks:
313 | continue
314 | att = random.choice(attacks)
315 | if att.can_use(self, player):
316 | self.melee_attack(player, att)
317 |
318 | def saving_throw(self, stat, DC):
319 | return dice(1, 20) + calc_mod(stat) >= DC
320 |
321 | def do_ranged_attack(self, target=None):
322 | if not self.ranged:
323 | return
324 | player = self.g.player
325 | board = self.g.board
326 | if target is None:
327 | target = player
328 | the_target = "you" if target is player else f"the {target.name}"
329 | self.g.print_msg(f"The {self.name} makes a ranged attack at {the_target}.")
330 | for point in board.line_between((self.x, self.y), (target.x, target.y), skipfirst=True, skiplast=True):
331 | self.g.set_projectile_pos(*point)
332 | self.g.draw_board()
333 | time.sleep(0.06)
334 | self.g.clear_projectile()
335 | roll = dice(1, 20)
336 | if (target is player and player.has_effect("Invisible")) or self.has_effect("Frightened"): #The player is harder to hit when invisible
337 | roll = min(roll, dice(1, 20))
338 | bonus = self.to_hit
339 | if target is player:
340 | dodge_mod = player.get_ac_bonus()
341 | AC = 10 + dodge_mod
342 | else:
343 | AC = target.AC
344 | total = roll + self.to_hit
345 | if roll == 1:
346 | hits = False
347 | elif roll == 20:
348 | hits = True
349 | else:
350 | hits = total >= AC
351 | if not hits:
352 | if target is player and roll > 1 and total >= AC - dodge_mod:
353 | self.g.print_msg("You dodge the projectile.")
354 | else:
355 | self.g.print_msg(f"The projectile misses {the_target}.")
356 | else:
357 | damage = self.modify_damage(target, dice(*self.ranged_dam))
358 | if damage:
359 | the_target_is = "You are" if target is player else "The {target.name} is"
360 | self.g.print_msg(f"{the_target_is} hit for {damage} damage!", "red" if target is player else "white")
361 | player.take_damage(damage)
362 | else:
363 | self.g.print_msg(f"The projectile hits {the_target} but does no damage.")
364 | self.energy -= self.get_speed()
365 |
366 | def sees_player(self):
367 | player = self.g.player
368 | if player.has_effect("Invisible"):
369 | return False
370 | return (self.x, self.y) in player.fov
371 |
372 | def can_guess_invis(self):
373 | #Can we correctly guess the player's exact position when invisible?
374 | player = self.g.player
375 | xdist = player.x - self.x
376 | ydist = player.y - self.y
377 | dist = abs(xdist) + abs(ydist)
378 | if dist <= 1 and one_in(4): #If we are right next to the player, we are more likely to notice
379 | return True
380 | if not one_in(6): #Only make the check every 6 turns on average
381 | return False
382 | pen = max(dist - 2, 0) #Distance penalty; it's harder to guess the position of an invisible player who's far away
383 | if not player.last_moved:
384 | pen += 5 #If the player doesn't move, it's harder to know where they are
385 | return dice(1, 20) + div_rand(self.WIS - 10, 2) - pen >= dice(1, 20) + div_rand(player.DEX - 10, 2)
386 |
387 | def guess_rand_invis(self):
388 | board = self.g.board
389 | tries = 100
390 | while tries > 0:
391 | dx = random.randint(-2, 2)
392 | dy = random.randint(-2, 2)
393 | if (dx, dy) == (0, 0):
394 | continue
395 | xp = self.x + dx
396 | yp = self.y + dy
397 | if (xp < 0 or xp >= board.cols) or (yp < 0 or yp >= board.cols):
398 | continue
399 | if board.blocks_sight(xp, yp) or not board.line_of_sight((self.x, self.y), (xp, yp)):
400 | tries -= 1
401 | else:
402 | self.last_seen = (xp, yp)
403 | break
404 |
405 | def reset_track_timer(self):
406 | self.track_timer = random.randint(25, 65)
407 |
408 | def check_split(self, chance):
409 | if self.HP < random.randint(10, 20):
410 | return False #No splitting if we don't have enough HP
411 | if "jelly" not in self.name.lower():
412 | return False
413 | denom = random.randint(self.HP, self.MAX_HP)
414 | return x_in_y(chance, denom)
415 |
416 | def maybe_split(self, dam, mult):
417 | if dam <= 0:
418 | return False
419 | if not self.check_split(dam*mult):
420 | return
421 | self.HP += binomial(dam, 50)
422 | if self.HP > self.MAX_HP:
423 | self.HP = self.MAX_HP
424 | x, y = self.x, self.y
425 | neighbors = [(x+1, y), (x-1, y), (x, y+1), (x, y-1), (x+1, y+1), (x+1, y-1), (x-1, y+1), (x-1, y-1)]
426 | random.shuffle(neighbors)
427 | nx, ny = 0, 0
428 | g = self.g
429 | board = self.g.board
430 | for p in neighbors:
431 | nx, ny = p
432 | if board.is_passable(nx, ny):
433 | break
434 | else:
435 | return
436 | cx, cy = self.x, self.y
437 | HP = random.randint(self.HP, self.MAX_HP)
438 | hp1 = div_rand(HP, 2)
439 | hp2 = HP - hp1
440 | m1 = self.__class__(g)
441 | m2 = self.__class__(g)
442 | m1.HP = m1.MAX_HP = hp1
443 | m2.HP = m2.MAX_HP = hp2
444 | self.g.print_msg(f"The {self.name} splits into two!", "yellow")
445 | self.despawn()
446 | m1.place_at(x, y)
447 | m2.place_at(nx, ny)
448 | g.monsters.append(m1)
449 | g.monsters.append(m2)
450 |
451 | def on_alerted(self, target=None):
452 | player = self.g.player
453 | self.is_aware = True
454 | if target is not None and target is not player:
455 | self.target = None
456 | self.last_seen = (player.x, player.y)
457 | self.reset_track_timer()
458 |
459 | def stop_tracking(self):
460 | self.last_seen = None
461 | self.track_timer = 0
462 | self.is_aware = False
463 | self.dir = None
464 | self.target = None
465 |
466 | def apply_armor(self, dam, armor_div=1):
467 | prot = random.randint(0, 2*self.armor)
468 | prot = div_rand(prot, armor_div)
469 | return max(0, dam - prot)
470 |
471 | def has_line_of_fire(self):
472 | player = self.g.player
473 | return self.g.board.is_clear_path((self.x, self.y), (player.x, player.y))
474 |
475 | def sees_target(self):
476 | if self.target is self.g.player:
477 | return self.sees_player()
478 | if not self.target:
479 | return False
480 | target = self.target
481 | if self.g.board.line_of_sight((self.x, self.y), (target.x, target.y)):
482 | return True
483 | return self.g.board.line_of_sight((target.x, target.y), (self.x, self.y))
484 |
485 | def try_use_spell(self, target):
486 | candidates = self.spells[:]
487 | random.shuffle(candidates)
488 | for spell in candidates:
489 | if self.maybe_use_spell(spell, target):
490 | self.energy -= mult_rand_frac(self.get_speed(), max(0, spell.time_cost), 100)
491 | return True
492 | return False
493 |
494 | def actions(self):
495 | if self.has_effect("Asleep") or self.has_effect("Stunned") or self.has_effect("Paralyzed"):
496 | self.energy = 0
497 | return
498 | player = self.g.player
499 | if self.target is not None:
500 | if self.target is not player and self.target.HP <= 0:
501 | self.target = None
502 | if self.target is None:
503 | self.target = player
504 | if self.is_friendly():
505 | self.is_aware = True
506 | mon_typ = self.__class__.__name__
507 | if mon_typ == "Troll" and self.HP < self.MAX_HP:
508 | regen = 2 + one_in(3)
509 | self.HP = min(self.MAX_HP, self.HP + regen)
510 | if x_in_y(3, 5) and one_in(self.distance(player)):
511 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} slowly regenerates.")
512 | board = self.g.board
513 |
514 | target = self.target
515 | confused = self.has_effect("Confused") and not one_in(4)
516 | guessplayer = False
517 | if self.is_aware and player.has_effect("Invisible"):
518 | guessplayer = self.can_guess_invis() #Even if the player is invisible, the monster may still be able to guess their position
519 | if confused:
520 | dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]
521 | if not self.move(*random.choice(dirs)): #Only try twice
522 | if not self.move(*(d := random.choice(dirs))):
523 | x, y = self.x + d[0], self.y + d[1]
524 | obstacle = ""
525 | if board.blocks_sight(x, y):
526 | obstacle = "wall"
527 | elif (m := self.g.get_monster(x, y)):
528 | obstacle = m.name
529 | if obstacle:
530 | self.g.print_msg_if_sees((self.x, self.y), f"The {self.name} bumps into the {obstacle}.")
531 | self.energy -= div_rand(self.get_speed(), 2) #We bumped into something while confused
532 | self.energy = min(self.energy, 0)
533 | elif not self.is_friendly() and self.has_effect("Frightened"):
534 | if self.sees_player():
535 | dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]
536 | random.shuffle(dirs)
537 | dist = self.distance(player)
538 | if dist <= 1 and one_in(4): #If we are already next to the player when frightened, there's a small chance we try to attack before running away
539 | self.energy -= self.get_speed()
540 | self.do_melee_attack()
541 | else:
542 | for dx, dy in dirs:
543 | newx, newy = self.x + dx, self.y + dy
544 | newdist = abs(newx - player.x) + abs(newy - player.y)
545 | if newdist >= dist: #Don't move closer to the player
546 | self.move(dx, dy)
547 | break
548 | else:
549 | if x_in_y(2, 5): #If we are frightened and nowhere to run, try attacking
550 | if dist <= 1:
551 | self.energy -= self.get_speed()
552 | self.do_melee_attack()
553 | elif self.ranged and target is player and self.should_use_ranged():
554 | self.do_ranged_attack()
555 | elif one_in(2) and dice(1, 20) + calc_mod(self.WIS) >= 15:
556 | self.lose_effect("Frightened")
557 | elif self.is_friendly():
558 | can_see = (self.x, self.y) in player.fov
559 | if can_see and (mons := list(player.monsters_in_fov())):
560 | dist = 999
561 | closest = None
562 | for m in mons:
563 | if not board.is_clear_path((self.x, self.y), (m.x, m.y)):
564 | if not board.is_clear_path((m.x, m.y), (self.x, self.y)):
565 | continue
566 | if (d := self.distance(m)) < dist:
567 | dist = d
568 | closest = m
569 | if dist <= 1:
570 | self.melee_attack(m)
571 | else:
572 | self.path_towards(m.x, m.y)
573 | if self.distance(player) > 4 or not can_see:
574 | self.path_towards(player.x, player.y)
575 | elif one_in(6):
576 | dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]
577 | random.shuffle(dirs)
578 | for d in dirs:
579 | if self.move(*d):
580 | self.dir = d
581 | break
582 | elif (self.is_aware or (target is not player and self.sees_target())) and (self.sees_player() or guessplayer):
583 | xdist = target.x - self.x
584 | ydist = target.y - self.y
585 | self.last_seen = (target.x, target.y)
586 | self.reset_track_timer()
587 | if self.distance(target) <= 1:
588 | used_spell = False
589 | if self.spells and one_in(6) and target is player:
590 | used_spell = self.try_use_spell(target)
591 | if not used_spell or self.energy > 0: #If we still have enough energy points to do so, make a melee attack
592 | self.energy -= self.get_speed()
593 | self.do_melee_attack(target)
594 | elif self.ranged and target is player and self.should_use_ranged():
595 | self.do_ranged_attack()
596 | else:
597 | dx = 1 if xdist > 0 else (-1 if xdist < 0 else 0)
598 | dy = 1 if ydist > 0 else (-1 if ydist < 0 else 0)
599 | axdist = abs(xdist)
600 | aydist = abs(ydist)
601 | old = self.energy
602 | used_spell = False
603 | if self.spells and one_in(5) and target is player:
604 | used_spell = self.try_use_spell(target)
605 | if not used_spell:
606 | oldx, oldy = self.x, self.y
607 | self.path_towards(target.x, target.y)
608 | moved = (self.x, self.y) != (oldx, oldy)
609 | if not moved and self.distance(target) <= 4 and one_in(5):
610 | could_route_around = self.g.monster_at(self.x+dx, self.y) or self.g.monster_at(self.x, self.y+dy)
611 | if could_route_around:
612 | self.path_towards(*self.last_seen, maxlen=self.distance(target)+3)
613 | else:
614 | if self.target is not player: #We lost sight of a target; go back to targeting the player
615 | self.target = player
616 | target = self.target
617 | if target.has_effect("Invisible") and (self.x, self.y) == self.last_seen:
618 | self.guess_rand_invis()
619 | if self.target is player and self.last_seen:
620 | if self.track_timer > 0:
621 | if player.has_effect("Invisible"):
622 | check = dice(1, 20) + calc_mod(player.DEX) < 10 + calc_mod(self.WIS)
623 | else:
624 | check = True
625 | self.path_towards(*self.last_seen)
626 | if (self.x, self.y) == self.last_seen and check:
627 | sees_you = self.sees_player()
628 | #If we reach the target position and still don't see the player, roll a stealth check to continue tracking the player
629 | if sees_you or dice(1, 20) + calc_mod(player.DEX) + player.passives["stealth"] < 14 + calc_mod(self.WIS):
630 | self.last_seen = (player.x, player.y)
631 | else:
632 | self.stop_tracking()
633 | else:
634 | self.stop_tracking()
635 | elif not one_in(5):
636 | choose_new = self.dir is None or (one_in(3) or not self.move(*self.dir))
637 | if choose_new:
638 | if self.dir is None:
639 | dirs = [(-1, 0), (1, 0), (0, 1), (0, -1)]
640 | random.shuffle(dirs)
641 | for d in dirs:
642 | if self.move(*d):
643 | self.dir = d
644 | break
645 | else:
646 | if self.dir in [(-1, 0), (1, 0)]:
647 | dirs = [(0, 1), (0, -1)]
648 | else:
649 | dirs = [(-1, 0), (1, 0)]
650 | random.shuffle(dirs)
651 | for d in dirs:
652 | if self.move(*d):
653 | self.dir = d
654 | break
655 | else:
656 | if not self.move(*self.dir):
657 | d = (-self.dir[0], -self.dir[1])
658 | self.move(*d)
659 | self.dir = d
660 |
661 | def maybe_use_spell(self, spell, target):
662 | if self.distance(target, False) > spell.range:
663 | return False
664 |
665 | g = self.g
666 | player = g.player
667 | board = g.board
668 |
669 | if not spell.should_use(self, target):
670 | return False
671 |
672 | if spell.efftype is None:
673 | if spell.msg:
674 | g.print_msg(spell.msg.format(self.name))
675 | spell.on_hit_effect(self, target)
676 | return True
677 | if g.board.line_of_sight((self.x, self.y), (target.x, target.y)):
678 | line = list(g.board.line_between((self.x, self.y), (target.x, target.y)))
679 | elif g.board.line_of_sight((target.x, target.y), (self.x, self.y)):
680 | line = list(g.board.line_between((target.x, target.y), (self.x, self.y)))
681 | line.reverse()
682 | else:
683 | return False
684 |
685 | if spell.efftype == "blast":
686 | for x, y in line:
687 | if g.monster_at(x, y):
688 | return False
689 | if spell.msg:
690 | g.print_msg(spell.msg.format(self.name))
691 | for x, y in line:
692 | g.set_projectile_pos(x, y)
693 | g.draw_board()
694 | time.sleep(0.03)
695 | g.clear_projectile()
696 | spell.on_hit_effect(self, target)
697 | elif spell.efftype == "cone":
698 | x, y = self.x, self.y
699 | px, py = target.x, target.y
700 | dx = px - x
701 | dy = py - y
702 | if round(math.sqrt(dx**2 + dy**2)) > spell.range:
703 | return False
704 | angle = math.degrees(math.atan2(dy, dx))
705 | area = list(board.get_in_cone((x, y), spell.range, angle))
706 | num = 0
707 | for cx, cy in area:
708 | if g.monster_at(cx, cy):
709 | num += 1
710 | if num > 0 and x_in_y(num, num+2):
711 | return False
712 | if spell.msg:
713 | g.print_msg(spell.msg.format(self.name))
714 | for cx, cy in area:
715 | g.blast.add((cx, cy))
716 | if (m := g.get_monster(cx, cy)):
717 | spell.on_hit_effect(m)
718 | elif (player.x, player.y) == (cx, cy):
719 | spell.on_hit_effect(self, player)
720 | g.draw_board()
721 | time.sleep(0.2)
722 | g.blast.clear()
723 | g.draw_board()
724 | return True
725 |
726 | class SpellAttack:
727 |
728 | def __init__(self, efftype, range, msg="", time_cost=100):
729 | self.efftype = efftype #Can be "cone", "blast", "ray", or None
730 | self.range = range
731 | self.msg = msg
732 | self.time_cost = time_cost #Percentage of a turn this uses up
733 |
734 | def should_use(self, mon, target):
735 | return True
736 |
737 | def on_hit_effect(self, mon, target):
738 | pass
739 |
740 |
741 | #Balance:
742 | #2x HP and damage from DnD
743 | #(A lot of these are based on DnD monsters)
744 | class Bat(Monster):
745 | min_level = 1
746 | diff = 1
747 | DEX = 15
748 | WIS = 12
749 | symbol = "w"
750 | attacks = [
751 | Attack((1, 3), 0, "The {0} bites {1}")
752 | ]
753 |
754 | def __init__(self, g):
755 | #name, HP, ranged, ranged_dam
756 | #ranged == None means there is a chance of it using a ranged attack
757 | super().__init__(g, "bat", 3, False)
758 |
759 | class Lizard(Monster):
760 | min_level = 1
761 | diff = 1
762 | speed = 20
763 | passive_perc = 9
764 | DEX = 12
765 | WIS = 8
766 | symbol = "r"
767 | attacks = [
768 | Attack((1, 3), 0, "The {0} bites {1}")
769 | ]
770 |
771 | def __init__(self, g):
772 | super().__init__(g, "lizard", 4, False)
773 |
774 | class Kobold(Monster):
775 | diff = 2
776 | min_level = 3
777 | DEX = 15
778 | WIS = 7
779 | to_hit = 4
780 | passive_perc = 8
781 | beast = False
782 | symbol = "K"
783 | weapon = Dagger
784 | attacks = [
785 | Attack((2, 4), 4, "The {0} hits {1} with its dagger")
786 | ]
787 |
788 | def __init__(self, g):
789 | super().__init__(g, "kobold", 10, None, (2, 4))
790 |
791 | class ClawGrapple(Attack):
792 |
793 | def __init__(self, dmg, to_hit):
794 | super().__init__(dmg, to_hit, "The {0} claws {1}")
795 |
796 | def on_hit(self, player, mon, dmg):
797 | if not one_in(3) and player.add_grapple(mon):
798 | player.g.print_msg(f"The {mon.name} grapples you with its claw!", "red")
799 |
800 | class GiantRat(Monster):
801 | diff = 2
802 | min_level = 5
803 | DEX = 15
804 | to_hit = 4
805 | passive_perc = 10
806 | symbol = "R"
807 | attacks = [
808 | Attack((2, 4), 4, "The {0} bites {1}")
809 | ]
810 |
811 | def __init__(self, g):
812 | super().__init__(g, "giant rat", 14, False)
813 |
814 | class CrabClaw(ClawGrapple):
815 |
816 | def __init__(self):
817 | super().__init__((2, 6), 3)
818 |
819 | class GiantCrab(Monster):
820 | diff = 3
821 | min_level = 4
822 | DEX = 15
823 | WIS = 9
824 | to_hit = 3
825 | armor = 2
826 | passive_perc = 9
827 | symbol = "C"
828 | attacks = [
829 | CrabClaw()
830 | ]
831 |
832 | def __init__(self, g):
833 | super().__init__(g, "giant crab", 20, False)
834 |
835 |
836 | class PoisonBite(Attack):
837 |
838 | def __init__(self):
839 | super().__init__((2, 4), 6, "The {0} bites {1}")
840 |
841 | def on_hit(self, player, mon, dmg):
842 | g = player.g
843 | poison = dice(4, 6) + dice(1, 3)
844 | if dmg < poison:
845 | poison = random.randint(dmg, poison)
846 | player.do_poison(poison)
847 |
848 | class GiantPoisonousSnake(Monster):
849 | diff = 3
850 | min_level = 8
851 | DEX = 18
852 | WIS = 10
853 | to_hit = 6
854 | passive_perc = 10
855 | symbol = "S"
856 | attacks = [
857 | PoisonBite()
858 | ]
859 |
860 | def __init__(self, g):
861 | super().__init__(g, "giant poisonous snake", 22, False)
862 |
863 | class Skeleton(Monster):
864 | diff = 3
865 | min_level = 7
866 | DEX = 14
867 | WIS = 8
868 | to_hit = 4
869 | armor = 1
870 | passive_perc = 9
871 | beast = False
872 | symbol = "F"
873 | weapon = Shortsword
874 | attacks = [
875 | Attack((2, 6), 4, "The {0} hits you with its shortsword")
876 | ]
877 |
878 | def __init__(self, g):
879 | super().__init__(g, "skeleton", 26, None, (2, 6))
880 |
881 | class GiantBat(Monster):
882 | diff = 3
883 | speed = 60
884 | min_level = 8
885 | DEX = 16
886 | WIS = 12
887 | to_hit = 4
888 | symbol = "W"
889 | attacks = [
890 | Attack((2, 6), 4, "The {0} bites {1}")
891 | ]
892 |
893 | def __init__(self, g):
894 | super().__init__(g, "giant bat", 26, False)
895 |
896 | class SnakeConstrict(Attack):
897 |
898 | def __init__(self):
899 | super().__init__((2, 8), 4, "The {0} constricts {1}")
900 |
901 | def dmg_bonus(self, mon, player):
902 | if mon in player.grappled_by:
903 | return dice(1, 8)
904 | return 0
905 |
906 | def on_hit(self, player, mon, dmg):
907 | player.add_grapple(mon)
908 |
909 | class SnakeBite(Attack):
910 |
911 | def __init__(self):
912 | super().__init__((2, 6), 4, "The {0} bites {1}")
913 |
914 | def can_use(self, mon, player):
915 | return mon not in player.grappled_by or one_in(3) #If constricting, prefer to use that instead
916 |
917 | class ConstrictorSnake(Monster):
918 | diff = 3
919 | speed = 30
920 | min_level = 8
921 | DEX = 14
922 | WIS = 10
923 | to_hit = 4
924 | symbol = "s"
925 | grapple_dc = 14
926 | attacks = [
927 | [SnakeBite(), SnakeConstrict()]
928 | ]
929 |
930 | def __init__(self, g):
931 | super().__init__(g, "constrictor snake", 26, False)
932 |
933 | class GiantLizard(Monster):
934 | diff = 3
935 | min_level = 9
936 | DEX = 11
937 | to_hit = 4
938 | passive_perc = 10
939 | symbol = "L"
940 | attacks = [
941 | Attack((2, 8), 4, "The {0} bites {1}")
942 | ]
943 |
944 | def __init__(self, g):
945 | super().__init__(g, "giant lizard", 38, False)
946 |
947 | class GiantGoat(Monster):
948 | diff = 4
949 | speed = 40
950 | min_level = 12
951 | DEX = 11
952 | WIS = 12
953 | to_hit = 5
954 | symbol = "G"
955 | attacks = [
956 | Attack((4, 4), 4, "The {0} rams {1}")
957 | ]
958 |
959 | def __init__(self, g):
960 | super().__init__(g, "giant goat", 38, False)
961 |
962 | class Orc(Monster):
963 | diff = 4
964 | speed = 30
965 | min_level = 12
966 | DEX = 12
967 | WIS = 11
968 | to_hit = 5
969 | armor = 2
970 | passive_perc = 10
971 | beast = False
972 | symbol = "O"
973 | weapon = Greataxe
974 | attacks = [
975 | Attack((2, 12), 3, "The {0} hits {1} with its greataxe")
976 | ]
977 |
978 | def __init__(self, g):
979 | super().__init__(g, "orc", 30, None, (2, 6))
980 |
981 | class ShadowStrDrain(Attack):
982 |
983 | def __init__(self):
984 | super().__init__((4, 6), 4)
985 |
986 | def on_hit(self, player, mon, dmg):
987 | g = player.g
988 | if not one_in(3) and player.STR > dice(1, 9):
989 | player.str_drain += 1
990 | g.print_msg("You feel weaker.", "red")
991 |
992 | class Shadow(Monster):
993 | diff = 4
994 | speed = 40
995 | min_level = 12
996 | DEX = 14
997 | WIS = 10
998 | to_hit = 4
999 | passive_perc = 10
1000 | symbol = "a"
1001 | attacks = [
1002 | ShadowStrDrain()
1003 | ]
1004 |
1005 | def __init__(self, g):
1006 | super().__init__(g, "shadow", 32, False)
1007 |
1008 |
1009 | class BlackBear(Monster):
1010 | diff = 4
1011 | speed = 40
1012 | min_level = 13
1013 | DEX = 10
1014 | WIS = 12
1015 | to_hit = 3
1016 | armor = 1
1017 | passive_perc = 13
1018 | symbol = "B"
1019 | attacks = [
1020 | Attack((2, 6), 3, "The {0} bites {1}"),
1021 | Attack((4, 4), 3, "The {0} claws {1}")
1022 | ]
1023 |
1024 | def __init__(self, g):
1025 | super().__init__(g, "black bear", 38, False)
1026 |
1027 | class BrownBear(Monster):
1028 | diff = 5
1029 | speed = 40
1030 | min_level = 15
1031 | DEX = 10
1032 | WIS = 12
1033 | to_hit = 3
1034 | armor = 1
1035 | passive_perc = 13
1036 | symbol = "&"
1037 | attacks = [
1038 | Attack((2, 8), 3, "The {0} bites {1}"),
1039 | Attack((4, 6), 3, "The {0} claws {1}")
1040 | ]
1041 |
1042 | def __init__(self, g):
1043 | super().__init__(g, "brown bear", 68, False)
1044 |
1045 | class SpecterDrain(Attack):
1046 |
1047 | def __init__(self):
1048 | super().__init__((4, 6), 4)
1049 |
1050 | def on_hit(self, player, mon, dmg):
1051 | player.drain(random.randint(1, dmg))
1052 |
1053 | class Specter(Monster):
1054 | diff = 5
1055 | speed = 50
1056 | min_level = 18
1057 | DEX = 14
1058 | WIS = 10
1059 | to_hit = 4
1060 | passive_perc = 10
1061 | eff_immunities = {"Charmed", "Asleep"}
1062 | symbol = "t"
1063 | attacks = [
1064 | SpecterDrain()
1065 | ]
1066 |
1067 | def __init__(self, g):
1068 | super().__init__(g, "specter", 44, False)
1069 |
1070 | class GiantEagle(Monster):
1071 | diff = 5
1072 | speed = 45
1073 | DEX = 17
1074 | WIS = 12
1075 | min_level = 16
1076 | to_hit = 5
1077 | passive_perc = 14
1078 | symbol = "E"
1079 | attacks = [
1080 | Attack((2, 6), 5, "The {0} attacks {1} with its beak"),
1081 | Attack((4, 6), 5, "The {0} attacks {1} with its talons")
1082 | ]
1083 |
1084 | def __init__(self, g):
1085 | super().__init__(g, "giant eagle", 52, False)
1086 |
1087 | class JellyAcidAttack(Attack):
1088 |
1089 | def __init__(self):
1090 | super().__init__((4, 6), 6, "The {0} attacks {1}")
1091 |
1092 | def on_hit(self, player, mon, dmg):
1093 | g = player.g
1094 | g.print_msg("The acid burns!", "red")
1095 | player.take_damage(player.apply_resist(dice(1, 12)))
1096 |
1097 | class OchreJelly(Monster):
1098 | diff = 6
1099 | speed = 10
1100 | DEX = 6
1101 | WIS = 6
1102 | min_level = 18
1103 | to_hit = 6
1104 | passive_perc = 8
1105 | beast = False
1106 | symbol = "H"
1107 | eff_immunities = {"Charmed", "Frightened"}
1108 | attacks = [
1109 | JellyAcidAttack()
1110 | ]
1111 |
1112 | def __init__(self, g):
1113 | super().__init__(g, "ochre jelly", 90, False)
1114 |
1115 | class Ogre(Monster):
1116 | diff = 6
1117 | DEX = 8
1118 | WIS = 7
1119 | min_level = 20
1120 | to_hit = 6
1121 | armor = 2
1122 | passive_perc = 8
1123 | beast = False
1124 | symbol = "J"
1125 | weapon = Club
1126 | attacks = [
1127 | Attack((2, 6), 6, "The {0} hits {1} with its club"),
1128 | ]
1129 |
1130 | def __init__(self, g):
1131 | super().__init__(g, "ogre", 118, False)
1132 |
1133 | class PolarBear(Monster):
1134 | diff = 6
1135 | speed = 40
1136 | min_level = 18
1137 | DEX = 10
1138 | WIS = 13
1139 | to_hit = 7
1140 | armor = 2
1141 | passive_perc = 13
1142 | symbol = "P"
1143 | attacks = [
1144 | Attack((2, 8), 7, "The {0} bites {1}"),
1145 | Attack((4, 6), 7, "The {0} claws {1}")
1146 | ]
1147 |
1148 | def __init__(self, g):
1149 | super().__init__(g, "polar bear", 84, False)
1150 |
1151 | class NothicRotGaze(SpellAttack):
1152 |
1153 | def __init__(self):
1154 | super().__init__(None, 6, "The {0} gazes at you!", time_cost=40)
1155 |
1156 | def should_use(self, mon, target):
1157 | return one_in(2)
1158 |
1159 | def on_hit_effect(self, mon, target):
1160 | if isinstance(target, Monster):
1161 | return
1162 | g = target.g
1163 | g.print_msg("You feel your flesh rotting.", "red")
1164 | dam = target.apply_resist(dice(4, 6))
1165 | target.take_damage(dam)
1166 | target.drain(dam, silent=True)
1167 |
1168 | class Nothic(Monster):
1169 | diff = 6
1170 | speed = 30
1171 | min_level = 18
1172 | DEX = 16
1173 | WIS = 10
1174 | to_hit = 4
1175 | armor = 2
1176 | passive_perc = 12
1177 | symbol = "N"
1178 | attacks = [
1179 | Attack((2, 6), 4, "The {0} claws {1}"),
1180 | Attack((2, 6), 4, "The {0} claws {1}")
1181 | ]
1182 | spells = [
1183 | NothicRotGaze()
1184 | ]
1185 |
1186 | def __init__(self, g):
1187 | super().__init__(g, "nothic", 90, False)
1188 |
1189 | class Rhinoceros(Monster):
1190 | diff = 6
1191 | speed = 40
1192 | min_level = 19
1193 | DEX = 8
1194 | WIS = 12
1195 | to_hit = 7
1196 | armor = 2
1197 | passive_perc = 13
1198 | symbol = "Y"
1199 | attacks = [
1200 | Attack((2, 8), 7, "The {0} gores {1}")
1201 | ]
1202 |
1203 | def __init__(self, g):
1204 | super().__init__(g, "rhinoceros", 90, False)
1205 |
1206 | class WightLifeDrain(Attack):
1207 |
1208 | def __init__(self):
1209 | super().__init__((2, 6), 4, "The {0} uses life drain")
1210 |
1211 | def on_hit(self, player, mon, dmg):
1212 | player.drain(dmg)
1213 |
1214 | class Wight(Monster):
1215 | diff = 7
1216 | speed = 30
1217 | min_level = 21
1218 | DEX = 14
1219 | WIS = 13
1220 | to_hit = 4
1221 | armor = 2
1222 | passive_perc = 13
1223 | symbol = "T"
1224 | weapon = Longsword
1225 | attacks = [
1226 | Attack((2, 8), 7, "The {0} hits {1} with its longsword"),
1227 | [
1228 | Attack((2, 8), 7, "The {0} hits {1} with its longsword"),
1229 | WightLifeDrain()
1230 | ]
1231 | ]
1232 |
1233 | def __init__(self, g):
1234 | super().__init__(g, "wight", 90, False)
1235 |
1236 | class Sasquatch(Monster):
1237 | diff = 7
1238 | speed = 40
1239 | min_level = 22
1240 | DEX = 10
1241 | WIS = 16
1242 | to_hit = 6
1243 | armor = 2
1244 | passive_perc = 17
1245 | beast = False
1246 | symbol = "Q"
1247 | attacks = [
1248 | Attack((2, 8), 6, "The {0} punches {1} with its fist"),
1249 | Attack((2, 8), 6, "The {0} punches {1} with its fist")
1250 | ]
1251 |
1252 | def __init__(self, g):
1253 | super().__init__(g, "sasquatch", 118, False)
1254 |
1255 | class ScorpionClaw(ClawGrapple):
1256 |
1257 | def __init__(self):
1258 | super().__init__((2, 8), 4)
1259 |
1260 | class ScorpionSting(Attack):
1261 |
1262 | def __init__(self):
1263 | super().__init__((2, 10), 4, "The {0} stings {1}")
1264 |
1265 | def on_hit(self, player, mon, dmg):
1266 | g = player.g
1267 | poison = dice(4, 10)
1268 | if dmg < poison:
1269 | poison = random.randint(dmg, poison)
1270 | player.do_poison(poison)
1271 |
1272 | class GiantScorpion(Monster):
1273 | diff = 7
1274 | speed = 40
1275 | min_level = 21
1276 | DEX = 13
1277 | WIS = 9
1278 | to_hit = 4
1279 | armor = 4
1280 | passive_perc = 9
1281 | grapple_dc = 12
1282 | symbol = "D"
1283 | attacks = [
1284 | ScorpionClaw(),
1285 | ScorpionClaw(),
1286 | ScorpionSting()
1287 | ]
1288 |
1289 | def __init__(self, g):
1290 | super().__init__(g, "giant scorpion", 98, False)
1291 |
1292 | class AdhesiveSlimeAttack(Attack):
1293 |
1294 | def __init__(self):
1295 | super().__init__((5, 8), 6, "The {0} attacks {1}")
1296 |
1297 | def on_hit(self, player, mon, dmg):
1298 | g = player.g
1299 | if not one_in(7) and player.add_grapple(mon):
1300 | g.print_msg(f"The {mon.name}'s pseudopod adheres to you, holding you in place!", "red")
1301 |
1302 | class GiantGreenSlime(Monster):
1303 | diff = 8
1304 | speed = 30
1305 | min_level = 24
1306 | DEX = 14
1307 | WIS = 8
1308 | to_hit = 4
1309 | passive_perc = 9
1310 | grapple_dc = 19 #It's so sticky that the escape DC is set quite high
1311 | symbol = "M"
1312 | attacks = [
1313 | AdhesiveSlimeAttack(),
1314 | ]
1315 |
1316 | def __init__(self, g):
1317 | super().__init__(g, "giant green slime", 168, False)
1318 |
1319 | class Ettin(Monster):
1320 | diff = 8
1321 | speed = 40
1322 | min_level = 26
1323 | DEX = 8
1324 | WIS = 10
1325 | to_hit = 7
1326 | passive_perc = 14
1327 | armor = 4
1328 | symbol = "Ň"
1329 | weapon = [Battleaxe, Morningstar]
1330 | attacks = [
1331 | Attack((4, 9), 7, "The {0} attacks {1} with a battleaxe"),
1332 | Attack((4, 9), 7, "The {0} attacks {1} with a morningstar"),
1333 | ]
1334 |
1335 | def __init__(self, g):
1336 | super().__init__(g, "ettin", 170, False)
1337 |
1338 | class Troll(Monster):
1339 | diff = 9
1340 | speed = 40
1341 | min_level = 28
1342 | DEX = 13
1343 | WIS = 9
1344 | to_hit = 7
1345 | passive_perc = 11
1346 | armor = 4
1347 | symbol = "ő"
1348 | attacks = [
1349 | Attack((2, 6), 7, "The {0} bites {1}"),
1350 | Attack((4, 6), 7, "The {0} claws {1}"),
1351 | Attack((4, 6), 7, "The {0} claws {1}"),
1352 | ]
1353 |
1354 | def __init__(self, g):
1355 | super().__init__(g, "troll", 168, False)
1356 |
1357 | class FireElementalAttack(Attack):
1358 |
1359 | def __init__(self):
1360 | super().__init__((4, 6), 6, "The {0} touches {1} with its fire")
1361 |
1362 | def on_hit(self, player, mon, dmg):
1363 | g = player.g
1364 | if player.fire <= 0 or one_in(3):
1365 | player.fire += 1
1366 | g.print_msg("You're set on fire!", "red")
1367 |
1368 | class FireElemental(Monster):
1369 | diff = 9
1370 | speed = 30
1371 | min_level = 30
1372 | DEX = 17
1373 | WIS = 10
1374 | to_hit = 6
1375 | passive_perc = 10
1376 | symbol = "Ã"
1377 | attacks = [
1378 | FireElementalAttack()
1379 | ]
1380 | eff_immunities = {"Asleep", "Paralyzed"}
1381 |
1382 | def __init__(self, g):
1383 | super().__init__(g, "fire elemental", 204, False)
1384 |
1385 | class ElementalEngulf(SpellAttack):
1386 |
1387 | def __init__(self):
1388 | super().__init__(None, 1)
1389 |
1390 | def on_hit_effect(self, mon, target):
1391 | if isinstance(target, Monster):
1392 | return
1393 | if mon in target.grappled_by:
1394 | return
1395 | if target.engulfed_by:
1396 | return
1397 | g = target.g
1398 | if not one_in(15) and dice(1,20) + calc_mod(target.STR) >= 15: # changed undefined roll to dice(1,20)
1399 | g.print_msg(f"The {mon.name} attempts to engulf you, but you resist!", "yellow")
1400 | elif target.add_grapple(mon):
1401 | g.print_msg(f"The {mon.name} engulfs you! You can't breathe!", "red")
1402 | target.engulfed_by = mon
1403 | target.turns_engulfed = 0
1404 |
1405 | class AirBlast(SpellAttack):
1406 |
1407 | def __init__(self):
1408 | super().__init__(None, 1, "The {0} sends a huge blast of air at you!")
1409 |
1410 | def should_use(self, mon, target):
1411 | return one_in(2)
1412 |
1413 | def on_hit_effect(self, mon, target):
1414 | if isinstance(target, Monster):
1415 | return
1416 | g = target.g
1417 | saved = dice(1, 20) + calc_mod(target.STR) >= 13 and not one_in(target.STR+1)
1418 | num = 6
1419 | if saved:
1420 | num = 3
1421 | base = dice(num, 8)
1422 | dam = target.apply_armor(base)
1423 | if dam > 0:
1424 | g.print_msg("You are hit by the blast!", "red")
1425 | target.take_damage(dam)
1426 | if not saved:
1427 | target.knockback_from(mon.x, mon.y, mult_rand_frac(4, dam, base))
1428 | else:
1429 | g.print_msg("You are hit by the blast but take no damage.")
1430 |
1431 | class WaterElementalAttack(Monster):
1432 | diff = 9
1433 | speed = 30
1434 | min_level = 30
1435 | DEX = 14
1436 | WIS = 10
1437 | to_hit = 7
1438 | passive_perc = 10
1439 | symbol = "~"
1440 | attacks = [
1441 | Attack((4, 8), 7, "The {0} slams into {1}"),
1442 | ]
1443 | spells = [ElementalEngulf()]
1444 | eff_immunities = {"Asleep", "Paralyzed"}
1445 |
1446 | def __init__(self, g):
1447 | super().__init__(g, "water elemental", 228, False)
1448 |
1449 | class AirElemental(Monster):
1450 | diff = 9
1451 | speed = 90
1452 | min_level = 30
1453 | DEX = 20
1454 | WIS = 10
1455 | to_hit = 8
1456 | passive_perc = 10
1457 | symbol = "%"
1458 | attacks = [
1459 | Attack((4, 8), 8, "The {0} slams into {1}"),
1460 | ]
1461 | spells = [AirBlast()]
1462 | eff_immunities = {"Asleep", "Paralyzed"}
1463 |
1464 | def __init__(self, g):
1465 | super().__init__(g, "air elemental", 180, False)
1466 |
1467 | class EarthElemental(Monster):
1468 | diff = 9
1469 | speed = 30
1470 | min_level = 30
1471 | DEX = 8
1472 | WIS = 10
1473 | to_hit = 8
1474 | passive_perc = 10
1475 | armor = 9
1476 | symbol = "Ê"
1477 | attacks = [
1478 | Attack((4, 8), 8, "The {0} slams into {1}"),
1479 | ]
1480 | eff_immunities = {"Asleep", "Paralyzed"}
1481 |
1482 | def __init__(self, g):
1483 | super().__init__(g, "earth elemental", 252, False)
1484 |
--------------------------------------------------------------------------------