diff --git a/board.py b/board.py index a225f05..5c97892 100644 --- a/board.py +++ b/board.py @@ -1,13 +1,14 @@ from utils import * import random from collections import defaultdict +from pathfinding import find_path class Tile: def __init__(self): self.wall = False self.revealed = False - self.stair = False + self.stair = 0 self.items = [] def is_passable(self): @@ -263,4 +264,15 @@ def get_fov(self, pos): seen.add(p) fov.add(p) - return fov \ No newline at end of file + return fov + + def get_path(self, start, end, cost_func=None): + def passable_func(p): + return p == start or self.passable(p) + + if cost_func is None: + cost_func = lambda p: 1 + + return find_path(self, start, end, passable_func, cost_func) + + \ No newline at end of file diff --git a/const.py b/const.py index a8178c3..3d0672c 100644 --- a/const.py +++ b/const.py @@ -25,4 +25,10 @@ "input": COLOR_CYAN } +from enum import Enum + +class ItemUseResult: + NOT_USED = 0 #Item was not used + USED = 1 #Item was used, but should not be consumed + CONSUMED = 2 #Item was used, and should be consumed diff --git a/entity.py b/entity.py index 5f7a1dc..9a29076 100644 --- a/entity.py +++ b/entity.py @@ -204,20 +204,34 @@ def make_noise(self, volume): def roll_wisdom(self): return gauss_roll(stat_mod(self.WIS)) - def hit_with_acid(self, strength, corr): + def acid_resist(self): + #Resistance to acid + # -1 is vulnerable, +1 is resistant, +2 is immune + return 0 + + def hit_with_acid(self, strength): + res = self.acid_resist() + if res >= 2: + return + + if res < 0: + strength *= 2 + elif res == 1: + strength = div_rand(strength, 2) + + armor = self.get_armor() + strength -= rng(0, armor) + if strength <= 0: return + dam = rng(1, strength) typ = "bad" if self.is_player() else "neutral" - - self.add_msg_u_or_mons("The acid burns you!", f"{self.get_name(True)} is burned by acid!", typ) + + severity = " terribly" if res < 0 else "" + self.add_msg_u_or_mons("The acid burns you{severity}!", f"{self.get_name(True)} is burned{severity} by the acid!", typ) self.take_damage(dam) - if self.is_player(): - if x_in_y(dam * corr, 2000): - #TODO: Corrode armor - pass - def stealth_mod(self): return stat_mod(self.DEX) diff --git a/game_inst.py b/game_inst.py index 5e3a500..5009f0d 100644 --- a/game_inst.py +++ b/game_inst.py @@ -32,6 +32,7 @@ def __init__(self): self.select_mon = None self.noise_events = [] self.revealed = set() + self.delay = False def add_noise_event(self, pos, loudness, src=None): if loudness > 0: @@ -107,8 +108,9 @@ def init_game(self): curses.noecho() curses.curs_set(False) Entity.g = self + self.load_json_data() - self.load_json_data() + self.init_player() self.generate_level() self.draw_board() @@ -138,7 +140,7 @@ def generate_level(self): board.clear_los_cache() board.clear_collision_cache() board.procgen_level() - self.place_player() + self.init_player() self.place_monsters() self.place_items() @@ -164,7 +166,7 @@ def place_items(self): potions = [ [InvisibilityPotion, 25], - [HealingPotion, 120], + [HealingPotion, 130], [EnlargementPotion, 20], [ShrinkingPotion, 20], [SpeedPotion, 30] @@ -180,11 +182,13 @@ def place_items(self): ["club", 100], ["dagger", 60], ["greatclub", 25], - ["handaxe", 60] + ["handaxe", 60], + ["battleaxe", 30], + ["greatsword", 20] ] for _ in range(rng(1, 4)): - if x_in_y(2, 5): + if x_in_y(3, 8): pos = board.random_passable() name = random_weighted(weapons) board.place_item_at(pos, self.create_weapon(name)) @@ -260,6 +264,12 @@ def deinit_window(self): import os os.system("cls" if os.name == "nt" else "clear") self.window_init = False + + def init_player(self): + player = self.get_player() + self.place_player() + + player.recalc_max_hp() def place_player(self): board = self.get_board() @@ -399,14 +409,20 @@ def remove_dead(self): def place_stairs(self): board = self.get_board() - pos = board.random_passable() + + for _ in range(5): + pos = board.random_passable() + if not self.items_at(pos): + break tile = board.get_tile(pos) - tile.stair = True + tile.stair = 1 def getch(self, wait=True): screen = self.screen - screen.nodelay(not wait) + if wait != self.delay: + self.delay = wait + screen.nodelay(not wait) code = screen.getch() return code @@ -451,7 +467,10 @@ def draw_walls(self, offset_y): elif tile.stair: symbol = STAIR_SYMBOL else: - symbol = " " + symbol = "." if seen else " " + if seen: + color = curses.color_pair(COLOR_GRAY) + self.draw_symbol(pos.y + offset_y, pos.x, symbol, color) def draw_stats(self): @@ -681,8 +700,17 @@ def select_monster_menu(self, monsters, check_fov=True): self.add_message("View info of which monster? (Use the a and d keys to select, then press Enter)") - cursor = (len(monsters)-1)//2 + cursor = 0 mon = None + + min_dist = 999 + nearest = None + for i, m in enumerate(monsters): + dist = player.distance(m) + if dist < min_dist: + min_dist = dist + nearest = m + cursor = i while True: self.set_mon_select(monsters[cursor]) self.draw_board() @@ -787,13 +815,12 @@ def select_use_item(self): can_scroll = max_scroll > 0 - string = "Use which item? Enter a number from 1 - 9, then press Enter. Press 0 to cancel. " + string = "Use which item? Enter a number from 1 - 9, then press Enter. Press 0 to cancel. Press Shift+D to view description." if can_scroll: string += " (W and S keys to scroll)" select = 0 - - + while True: screen.erase() screen.addstr(0, 0, string) @@ -815,13 +842,17 @@ def select_use_item(self): scroll += 1 scroll = clamp(scroll, 0, max_scroll) + item = player.inventory[scroll + select] + if char == "0": return None + elif char == "D": + self.add_message(item.name + " - " + item.description, "info") + return None elif char in "123456789": select = int(char) - 1 if key == 10: - item = player.inventory[scroll + select] return item class PopupInfo: diff --git a/items.py b/items.py index ebe9327..bac2a95 100644 --- a/items.py +++ b/items.py @@ -9,13 +9,14 @@ class Item: def __init__(self): self.name = "item" self.symbol = "?" + self.damage_taken = 0 def display_color(self): return 0 def use(self, player): - return False - + return ItemUseResult.NOT_USED + class Potion(Item): def __init__(self): @@ -26,8 +27,8 @@ def display_color(self): return COLOR_DEEP_PINK2 -class HealingPotion(Potion): - description = "A potion with a glimmering red liquid." +class HealingPotion(Potion): + description = "A potion with a glimmering red liquid, that restores HP when consumed." def __init__(self): super().__init__() @@ -37,11 +38,12 @@ def use(self, player): player.add_msg("You drink the healing potion.") player.add_msg("You begin to feel more restored.", "good") player.heal(dice(4, 4) + 2) + player.poison = max(0, player.poison - rng(0, 6)) player.use_energy(100) - return True + return ItemUseResult.CONSUMED class EnlargementPotion(Potion): - description = "A potion with a light red liquid." + description = "A potion that will cause whoever consumes it to grow in size, increasing their HP and damage, but reducing stealth and evasion for the duration." def __init__(self): super().__init__() @@ -59,10 +61,10 @@ def use(self, player): player.add_status("Enlarged", dur) player.use_energy(100) player.recalc_max_hp() - return True + return ItemUseResult.CONSUMED class ShrinkingPotion(Potion): - description = "" + description = "A potion that will cause whoever consumes it to shrink in size, making them more stealthy and evasive, but reducing max HP and damage for the duration." def __init__(self): super().__init__() @@ -83,10 +85,10 @@ def use(self, player): player.add_status("Reduced", dur) player.use_energy(100) player.recalc_max_hp() - return True + return ItemUseResult.CONSUMED class SpeedPotion(Potion): - description = "A potion with a blue liquid that appears to have a slight glow." + description = "A potion with a blue liquid that appears to have a slight glow. When consed, it grants a temporary speed boost." def __init__(self): super().__init__() @@ -103,10 +105,10 @@ def use(self, player): dur = rng(20, 60) player.use_energy(100) player.add_status("Hasted", dur) - return True + return ItemUseResult.CONSUMED class InvisibilityPotion(Potion): - description = "A potion with a rather transparent liquid." + description = "A potion with a rather transparent liquid. Anyone who drinks it will become temporarily invisible, but attacking while invisible will reduce its duration." def __init__(self): super().__init__() @@ -123,18 +125,19 @@ def use(self, player): dur = rng(60, 100) player.use_energy(100) player.add_status("Invisible", dur) - return True + return ItemUseResult.CONSUMED -#TODO: Melee and ranged weapons/JSON for them - -class Weapon: +class Weapon(Item): + description = "A weapon that can be used in combat." def __init__(self): super().__init__() + self.type = None self.name = "weapon" self.damage = Dice(0, 0, 1) self.dmg_type = "bludgeon" self.finesse = False + self.heavy = False def roll_damage(self): return self.damage.roll() @@ -145,7 +148,9 @@ def display_color(self): @classmethod def from_type(cls, typ): obj = cls() + obj.type = typ obj.name = typ.name + obj.heavy = typ.heavy obj.symbol = typ.symbol obj.damage = typ.base_damage obj.dmg_type = typ.damage_type @@ -156,7 +161,7 @@ def use(self, player): player.add_msg(f"You wield a {self.name}.") player.use_energy(100) player.weapon = self - return True + return ItemUseResult.USED class NullWeapon(Weapon): @@ -169,4 +174,14 @@ def roll_damage(self): return 1 + one_in(3) UNARMED = NullWeapon() - \ No newline at end of file + +class Armor(Item): + description = "Armor that may protect its wearer from damage." + + def __init__(self): + super().__init__() + self.name = "armor" + self.protection = 1 + self.stealth_pen = 0 + self.encumbrance = 0 + \ No newline at end of file diff --git a/json_obj.py b/json_obj.py index 908c213..80b878b 100644 --- a/json_obj.py +++ b/json_obj.py @@ -164,6 +164,7 @@ def load(cls, d): obj.load_optional(d, "blindsight_range", 0, int) obj.load_optional(d, "poison", False, (bool, dict)) + obj.load_optional(d, "acid_strength", 0, int) obj.load_optional(d, "weapon", None) if obj.poison != False: @@ -202,6 +203,21 @@ def load(cls, d): return obj +class ArmorType(JSONObject): + + @classmethod + def load(cls, d): + obj = cls() + obj.load_required(d, "id", str) + obj.load_required(d, "name", str) + obj.load_required(d, "symbol", str), + obj.load_required(d, "protection", int) + obj.load_optional(d, "encumbrance", 0, int) + obj.load_optional(d, "stealth_pen", 0, int) + + return obj + + def load_monster_types(): mon_types = {} f = open("monsters.json", "r") diff --git a/monster.py b/monster.py index 583db3a..1dff6ce 100644 --- a/monster.py +++ b/monster.py @@ -3,7 +3,6 @@ from utils import * from const import * from items import Weapon -from pathfinding import find_path from collections import deque import random, curses from json_obj import * @@ -90,9 +89,6 @@ def calc_path_to(self, pos): g = self.g board = g.get_board() - def passable_func(p): - return p == pos or board.passable(p) - def cost_func(p): cost = 1 if (c := g.monster_at(p)) and not self.will_attack(c): @@ -100,7 +96,7 @@ def cost_func(p): return cost - path = find_path(board, self.pos, pos, passable_func, cost_func) + path = board.get_path(self.pos, pos, cost_func) self.path.clear() if not path: @@ -479,7 +475,7 @@ def calc_evasion(self): def get_armor(self): return self.type.armor - def get_hit_msg(self, c): + def get_hit_msg(self, c, damage): g = self.g player = g.get_player() @@ -496,7 +492,13 @@ def get_hit_msg(self, c): if msg.startswith(monster_name): msg = msg.capitalize() + if damage <= 0: + msg += " but deals no damage" + return msg + "." + + def get_acid_strength(self): + return self.type.acid_strength def attack_pos(self, pos): g = self.g @@ -509,46 +511,59 @@ def attack_pos(self, pos): u_see_attacker = player.sees(self) u_see_defender = player.sees(c) print_msg = u_see_attacker or u_see_defender - + if att_roll >= 0: damage = self.base_damage_roll() stat = self.DEX if self.type.use_dex_melee else self.STR damage += div_rand(stat - 10, 2) - damage = max(damage, 1) - msg_type = "bad" if c.is_player() else "neutral" - + damage = max(damage, 1) + damage = apply_armor(damage, c.get_armor()) + + msg_type = "bad" if (c.is_player() and damage > 0) else "neutral" if print_msg: - self.add_msg_if_u_see(self, self.get_hit_msg(c), msg_type) + self.add_msg_if_u_see(self, self.get_hit_msg(c, damage), msg_type) c.take_damage(damage) - poison_typ = self.type.poison - if poison_typ: - amount = poison_typ.max_damage - eff_con = random.gauss(c.CON, 2.5) - - eff_potency = poison_typ.potency - round((eff_con - 10)/1.3) - eff_potency = clamp(eff_potency, 0, 20) - if eff_potency > 0: - amount = mult_rand_frac(amount, eff_potency, 20) - typ = "bad" if c.is_player() else "neutral" - dmg = rng(0, amount) - if dmg > 0: - c.add_msg_u_or_mons("You are poisoned!", f"{self.get_name(True)} is poisoned!", typ) - c.poison += dmg - if poison_typ.slowing and x_in_y(dmg, dmg+3): - paralyzed = False - if self.has_status("Slowed") and one_in(4): - c.add_status("Paralyzed", rng(1, 5)) - paralyzed = True - - c.add_status("Slowed", rng(dmg, dmg*4), paralyzed) - - elif print_msg: - self.add_msg_if_u_see(self, f"{self.get_name(True)}'s attack misses {c.get_name()}.") + + if damage > 0: + if self.type.poison: + self.inflict_poison_to(c) + acid = self.get_acid_strength() + if acid > 0: + c.hit_with_acid(acid) + elif u_see_attacker: + defender = c.get_name() if u_see_defender else "something" + self.add_msg_if_u_see(self, f"{self.get_name(True)}'s attack misses {defender}.") self.use_energy(100) return True + def acid_resist(self): + if self.has_flag("ACID_RESISTANT"): + return 1 + return 0 + + def inflict_poison_to(self, c): + poison_typ = self.type.poison + amount = poison_typ.max_damage + eff_con = random.gauss(c.CON, 2.5) + + eff_potency = poison_typ.potency - round((eff_con - 10)/1.3) + eff_potency = clamp(eff_potency, 0, 20) + if eff_potency > 0: + amount = mult_rand_frac(amount, eff_potency, 20) + typ = "bad" if c.is_player() else "neutral" + dmg = rng(0, amount) + if dmg > 0: + c.add_msg_u_or_mons("You are poisoned!", f"{self.get_name(True)} is poisoned!", typ) + c.poison += dmg + if poison_typ.slowing and x_in_y(dmg, dmg+3): + paralyzed = False + if self.has_status("Slowed") and one_in(5): + c.add_status("Paralyzed", rng(1, 5)) + paralyzed = True + c.add_status("Slowed", rng(dmg, dmg*4), paralyzed) + def random_guess_invis(self): g = self.g board = g.get_board() diff --git a/monsters.json b/monsters.json index a3720d2..f7e1698 100644 --- a/monsters.json +++ b/monsters.json @@ -191,5 +191,26 @@ "base_damage": "1d6", "attack_msg": " hits with a scimitar", "flags": [ "SEES" ] + }, + { + "id": "gray_ooze", + "name": "gray ooze", + "symbol": "8", + "STR": 12, + "DEX": 6, + "CON": 16, + "INT": 1, + "WIS": 6, + "CHA": 2, + "HP": 22, + "to_hit": 3, + "level": 11, + "diff": 4, + "speed": 35, + "base_damage": "1d6", + "blindsight_range": 12, + "acid_strength": 14, + "attack_msg": " hits with its pseudopod", + "flags": [ "ACID_RESISTANT" ] } ] \ No newline at end of file diff --git a/player.py b/player.py index 9335721..9bf7566 100644 --- a/player.py +++ b/player.py @@ -23,6 +23,7 @@ def __init__(self): self.is_resting = False self.debug_wizard = False self.weapon = UNARMED + self.armor = None self.inventory = [] def is_unarmed(self): @@ -44,6 +45,10 @@ def calc_evasion(self): def add_to_inventory(self, item): self.inventory.append(item) + def remove_from_inventory(self, item): + if item in self.inventory: + self.inventory.remove(item) + def interrupt(self): self.is_resting = False @@ -60,6 +65,7 @@ def xp_to_next_level(self): def inc_random_stat(self): rand = rng(1, 6) + match rand: case 1: self.STR += 1 @@ -140,7 +146,10 @@ def get_name(self, capitalize=False): def calc_to_hit_bonus(self, mon): level_bonus = (self.xp_level - 1) / 3 stat = (self.STR + self.DEX) / 2 - if self.weapon.finesse: + + finesse = self.weapon.finesse + heavy = self.weapon.heavy + if finesse: stat = max(self.STR, self.DEX) * 1.1 stat_bonus = stat_mod(stat) @@ -157,7 +166,10 @@ def calc_to_hit_bonus(self, mon): mod -= 5 if self.has_status("Reduced"): - mod += 1.5 + if heavy: + mod -= 3 + else: + mod += 1.5 if self.is_unarmed(): mod += 1 @@ -171,10 +183,11 @@ def regen_rate(self): def recalc_max_hp(self): base_hp = 10 - mult = base_hp * 0.75 + mult = base_hp * 0.7 level_mod = mult * (self.xp_level - 1) level_mod *= self.CON / 10 - level_mod = max(level_mod, 2 * (self.xp_level - 1)) + level_mod += (self.CON - 10) / 2 + level_mod = max(level_mod, (self.xp_level - 1)) val = base_hp + level_mod if self.has_status("Enlarged"): val *= 1.5 @@ -220,9 +233,9 @@ def use_item(self): if not item: return False used = item.use(self) - if used: - self.inventory.remove(item) - return used + if used == ItemUseResult.CONSUMED: + self.remove_from_inventory(item) + return used != ItemUseResult.NOT_USED def on_move(self, oldpos): g = self.g @@ -240,14 +253,14 @@ def on_move(self, oldpos): if not mon.is_aware(): continue reach = mon.reach_dist() - if not mon.can_reach_attack(self.pos): + if reach > 1 and not mon.has_clear_path_to(self.pos): continue if old_dist <= reach and self.distance(mon) > reach and mon.sees(self): player_roll = triangular_roll(0, self.get_speed()) monster_roll = triangular_roll(0, mon.get_speed()) if monster_roll >= player_roll and one_in(2): - if player.sees(self): + if self.sees(mon): self.add_msg(f"{mon.get_name(True)} makes an attack as you move away!", "warning") oldenergy = mon.energy mon.attack_pos(self.pos) @@ -257,7 +270,7 @@ def descend(self): g = self.g board = g.get_board() tile = board.get_tile(self.pos) - if not tile.stair: + if tile.stair <= 0: self.add_msg("You can't go down there.") return False @@ -317,8 +330,11 @@ def attack_pos(self, pos): att_roll = self.roll_to_hit(mon) - if att_roll >= 0: - damage = self.base_damage_roll() + div_rand(self.STR - 10, 2) + if att_roll >= 0: + stat = self.STR + if finesse: + stat = max(stat, self.DEX) + damage = self.base_damage_roll() + div_rand(stat - 10, 2) crit = False if att_roll >= 5: crit = one_in(10) @@ -369,10 +385,14 @@ def attack_pos(self, pos): self.add_msg(f"Your attack misses {mon.get_name()}.") attack_cost = 100 + if self.weapon.heavy: + attack_cost = 120 + if self.has_status("Hasted"): - attack_cost = 75 + attack_cost = mult_rand_frac(attack_cost, 3, 4) + self.use_energy(attack_cost) - self.adjust_duration("Invisible", -rng(0, 10)) + self.adjust_duration("Invisible", -rng(0, 12)) mon.alerted() self.maybe_alert_monsters(15) @@ -380,7 +400,7 @@ def attack_pos(self, pos): def on_defeat_monster(self, mon): g = self.g - xp_gain = 15 * mon.get_diff_level()**1.5 + xp_gain = 15 * mon.get_diff_level()**1.75 xp_gain = round(xp_gain/5)*5 self.gain_xp(xp_gain) @@ -482,4 +502,12 @@ def stealth_mod(self): return stealth def stealth_roll(self): - return gauss_roll(self.stealth_mod()) \ No newline at end of file + return gauss_roll(self.stealth_mod()) + + def get_armor(self): + if self.armor: + armor = self.armor + return armor.protection + return 0 + + \ No newline at end of file diff --git a/weapons.json b/weapons.json index e17ddfd..84bba26 100644 --- a/weapons.json +++ b/weapons.json @@ -27,5 +27,20 @@ "symbol": "h", "base_damage": "1d6", "damage_type": "slash" - } + }, + { + "id": "battleaxe", + "name": "battleaxe", + "symbol": "T", + "base_damage": "1d8", + "damage_type": "slash" + }, + { + "id": "greatsword", + "name": "greatsword", + "symbol": "j", + "base_damage": "2d6", + "damage_type": "slash", + "heavy": true + } ] \ No newline at end of file