├── .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 | ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/fungamer2-2/VeraDungeon-Rogue) 4 | ![GitHub last commit](https://img.shields.io/github/last-commit/fungamer2-2/VeraDungeon-Rogue) 5 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/fungamer2-2/VeraDungeon-Rogue) 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 | ![image](./images/roguelike_image.png) 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 | --------------------------------------------------------------------------------