From 07007d7fde056fdf4b848027b61ceb025c00380b Mon Sep 17 00:00:00 2001 From: "grandsonneo@gmail.com" Date: Fri, 22 Dec 2023 21:47:25 -0500 Subject: [PATCH] Updates/adding armor --- activity.py | 20 ++++++++ armor.json | 31 ++++++++++++ const.py | 1 + game_inst.py | 131 +++++++++++++++++++++++++++++++++++--------------- items.py | 33 ++++++++++--- json_obj.py | 49 ++++++++----------- monster.py | 48 +++++++++++++----- monsters.json | 2 +- player.py | 70 +++++++++++++++++++++++++-- utils.py | 31 +++++++++++- 10 files changed, 324 insertions(+), 92 deletions(-) create mode 100644 activity.py create mode 100644 armor.json diff --git a/activity.py b/activity.py new file mode 100644 index 0000000..c57581f --- /dev/null +++ b/activity.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + +class Activity: + + def __init__(self, name, duration): + self.name = name + self.duration = duration + + @abstractmethod + def on_finished(self, player): + pass + +class EquipArmorActivity(Activity): + + def __init__(self, armor, duration): + super().__init__(f"putting on your {armor.name}", duration) + self.armor = armor + + def on_finished(self, player): + player.equip_armor(self.armor) \ No newline at end of file diff --git a/armor.json b/armor.json new file mode 100644 index 0000000..766b8eb --- /dev/null +++ b/armor.json @@ -0,0 +1,31 @@ +[ + { + "id": "leather", + "name": "leather armor", + "symbol": "l", + "protection": 1 + }, + { + "id": "hide", + "name": "hide armor", + "symbol": "h", + "protection": 2, + "encumbrance": 1 + }, + { + "id": "scale_mail", + "name": "scale mail", + "symbol": "M", + "protection": 4, + "encumbrance": 7, + "stealth_pen": 2 + }, + { + "id": "half_plate", + "name": "half-plate armor", + "symbol": "H", + "protection": 5, + "encumbrance": 6, + "stealth_pen": 2 + } +] \ No newline at end of file diff --git a/const.py b/const.py index 3d0672c..3304a4a 100644 --- a/const.py +++ b/const.py @@ -14,6 +14,7 @@ COLOR_BLUE = 12 COLOR_CYAN = 14 COLOR_BLUE1 = 21 +COLOR_DODGER_BLUE2 = 27 COLOR_DEEP_PINK2 = 167 MSG_TYPES = { diff --git a/game_inst.py b/game_inst.py index 5009f0d..23d90ca 100644 --- a/game_inst.py +++ b/game_inst.py @@ -14,6 +14,13 @@ import curses, textwrap, math +def _check_type(typ, types, type_name): + if typ not in types: + valid_types = sorted(types.keys()) + error = ValueError(f"invalid {type_name} type {typ!r}") + error.add_note(f"Valid {type_name} types are: {', '.join(valid_types)}") + raise error + class Game: def __init__(self): @@ -26,6 +33,7 @@ def __init__(self): self.mon_types = {} self.eff_types = {} self.weap_types = {} + self.armor_types = {} self.level = 1 self.subtick_timer = 0 self.tick = 0 @@ -45,26 +53,17 @@ def clear_mon_select(self): self.select_mon = None def check_mon_type(self, typ): - if typ not in self.mon_types: - valid_types = sorted(self.mon_types.keys()) - error = ValueError(f"invalid monster type {typ!r}") - error.add_note(f"Valid monster types are: {', '.join(valid_types)}") - raise error + _check_type(typ, self.mon_types, "monster") def check_effect_type(self, name): - if name not in self.eff_types: - valid_types = sorted(self.eff_types.keys()) - error = ValueError(f"invalid effect name {name!r}") - error.add_note(f"Valid effect names are: {', '.join(valid_types)}") - raise error + _check_type(name, self.eff_types, "effect") def check_weapon_type(self, typ): - if typ not in self.weap_types: - valid_types = sorted(self.weap_types.keys()) - error = ValueError(f"invalid weapon type {typ!r}") - error.add_note(f"Valid weapon types are: {', '.join(valid_types)}") - raise error + _check_type(typ, self.weap_types, "weapon") + def check_armor_type(self, typ): + _check_type(typ, self.armor_types, "armor") + def load_monsters(self): self.mon_types = load_monster_types() @@ -72,7 +71,10 @@ def load_effects(self): self.eff_types = load_effect_types() def load_weapons(self): - self.weap_types = load_weapon_types() + self.weap_types = load_weapon_types() + + def load_armors(self): + self.armor_types = load_armor_types() def get_mon_type(self, typ): self.check_mon_type(typ) @@ -82,14 +84,19 @@ def get_weapon_type(self, typ): self.check_weapon_type(typ) return self.weap_types[typ] - def get_all_monster_types(self): - for typ in self.mon_types.values(): - yield typ + def get_armor_type(self, typ): + self.check_armor_type(typ) + return self.armor_types[typ] def get_effect_type(self, name): self.check_effect_type(name) return self.eff_types[name] + def get_all_monster_types(self): + for typ in self.mon_types.values(): + yield typ + + def add_message(self, text, typ="neutral"): self.msg_log.add_message(text, typ) @@ -102,14 +109,17 @@ def init_colors(self): curses.init_pair(i + 1, i + 1, -1) def init_game(self): - self.screen = curses.initscr() + screen = curses.initscr() + self.screen = screen self.init_colors() curses.noecho() curses.curs_set(False) Entity.g = self - self.load_json_data() + screen.addstr("Loading roguelike game, please wait...") + screen.refresh() + self.load_json_data() self.init_player() self.generate_level() self.draw_board() @@ -118,6 +128,7 @@ def load_json_data(self): self.load_monsters() self.load_effects() self.load_weapons() + self.load_armors() def next_level(self): player = self.get_player() @@ -160,6 +171,11 @@ def choose_mon_spawn_pos(self): def create_weapon(self, id): typ = self.get_weapon_type(id) return Weapon.from_type(typ) + + def create_armor(self, id): + typ = self.get_armor_type(id) + return Armor.from_type(typ) + def place_items(self): board = self.get_board() @@ -188,19 +204,36 @@ def place_items(self): ] for _ in range(rng(1, 4)): - if x_in_y(3, 8): + if one_in(2): pos = board.random_passable() name = random_weighted(weapons) board.place_item_at(pos, self.create_weapon(name)) + + armors = [ + ["leather", 100], + ["hide", 50], + ["scale_mail", 30] + ] + + for _ in range(rng(2, 4)): + if one_in(2): + pos = board.random_passable() + name = random_weighted(armors) + board.place_item_at(pos, self.create_armor(name)) + def place_monsters(self): eligible_types = {} highest = 0 - levels = [] + + levels = WeightedList() for typ in self.get_all_monster_types(): if self.level >= typ.level: + w = 3 + if typ.level > 1: + w = min(w, self.level - typ.level + 1) if typ.level not in eligible_types: - levels.append(typ.level) + levels.add(typ.level, w) eligible_types[typ.level] = [] eligible_types[typ.level].append(typ) @@ -211,13 +244,13 @@ def place_monsters(self): packs = 0 while num_monsters > 0: - typ = random.choice(eligible_types[random.choice(levels)]) + typ = random.choice(eligible_types[levels.pick()]) min_level = typ.level - pack_spawn_chance = self.level - min_level + 1 - if "PACK_TRAVEL" in typ.flags and x_in_y(pack_spawn_chance, pack_spawn_chance + 3) and one_in(6 + packs * 3): + pack_spawn_chance = self.level - min_level + if "PACK_TRAVEL" in typ.flags and x_in_y(pack_spawn_chance, pack_spawn_chance + 4) and one_in(6 + packs * 3): pack_num = rng(2, 4) if self.spawn_pack(typ.id, pack_num): - num_monsters -= rng(1, pack_num) + num_monsters -= pack_num packs += 1 else: num_monsters -= 1 @@ -420,9 +453,10 @@ def place_stairs(self): def getch(self, wait=True): screen = self.screen - if wait != self.delay: - self.delay = wait - screen.nodelay(not wait) + + screen.nodelay(not wait) + if wait: + curses.flushinp() code = screen.getch() return code @@ -463,7 +497,7 @@ def draw_walls(self, offset_y): elif seen and tile.items: item = tile.items[-1] symbol = item.symbol - color = curses.color_pair(item.display_color()) + color = item.display_color() elif tile.stair: symbol = STAIR_SYMBOL else: @@ -517,16 +551,21 @@ def draw_stats(self): ev_str = f"+{ev}" if ev >= 0 else str(ev) - stealth_str = f"+{stealth}" if stealth >= 0 else str(ev) + stealth_str = f"+{stealth}" if stealth >= 0 else str(stealth) wield_str = player.weapon.name dmg_str = str(player.weapon.damage) + + armor_str = player.armor.name if player.armor else "" + prot_str = f"Protection: {player.get_armor()}" strings2 = [ f"Stealth: {stealth_str}", f"Evasion: {ev_str}", - "", + " ", f"Wield: {wield_str}", - f"Damage: {dmg_str}" + f"Damage: {dmg_str}", + armor_str, + prot_str ] for i, string in enumerate(strings2): @@ -624,7 +663,7 @@ def input_text(self, msg=""): result = screen.getstr() curses.curs_set(False) curses.noecho() - return result.decode() + return result.decode().rstrip() def input_int(self, msg=""): while True: @@ -634,7 +673,15 @@ def input_int(self, msg=""): except ValueError: self.add_message("Integers only, please.", "input") - def process_input(self): + def confirm(self, question): + while True: + txt = self.input_text(question + " (Y/N)") + if txt in ["Y", "N"]: + return txt == "Y" + else: + self.add_message("Please enter an uppercase Y or N.", "input") + + def process_input(self): self.maybe_refresh() player = self.get_player() @@ -651,7 +698,13 @@ def process_input(self): if player.has_status("Paralyzed"): player.use_energy(100) return True + + player.handle_activities() + if player.activity: + player.use_energy(100) + return True + self.draw_board() code = self.getch() char = chr(code) if code == -1: @@ -669,10 +722,10 @@ def process_input(self): elif char == ">": return player.descend() elif char == "r": - if player.HP < player.MAX_HP: + if player.can_rest(): self.add_message("You begin resting.") player.is_resting = True - return True + return True elif char == "v": return self.view_monsters() elif char == "p": diff --git a/items.py b/items.py index bac2a95..8a331f9 100644 --- a/items.py +++ b/items.py @@ -1,5 +1,6 @@ from utils import * from const import * +from activity import * from json_obj import WeaponType import curses @@ -24,7 +25,7 @@ def __init__(self): self.symbol = "p" def display_color(self): - return COLOR_DEEP_PINK2 + return curses.color_pair(COLOR_DEEP_PINK2) class HealingPotion(Potion): @@ -71,7 +72,7 @@ def __init__(self): self.name = "shrinking potion" def display_color(self): - return COLOR_CYAN + return curses.color_pair(COLOR_CYAN) def use(self, player): player.add_msg("You drink the shrinking potion.") @@ -95,7 +96,7 @@ def __init__(self): self.name = "speed potion" def display_color(self): - return COLOR_BLUE + return curses.color_pair(COLOR_BLUE) def use(self, player): player.add_msg("You drink the speed potion.") @@ -115,7 +116,7 @@ def __init__(self): self.name = "invisibility potion" def display_color(self): - return COLOR_BLUE + return curses.color_pair(COLOR_BLUE) def use(self, player): player.add_msg("You drink the invisibility potion.") @@ -137,13 +138,13 @@ def __init__(self): self.damage = Dice(0, 0, 1) self.dmg_type = "bludgeon" self.finesse = False - self.heavy = False + self.heavy = False def roll_damage(self): return self.damage.roll() def display_color(self): - return COLOR_SILVER + return curses.color_pair(COLOR_DODGER_BLUE2) | curses.A_REVERSE @classmethod def from_type(cls, typ): @@ -180,8 +181,28 @@ class Armor(Item): def __init__(self): super().__init__() + self.type = None self.name = "armor" self.protection = 1 self.stealth_pen = 0 self.encumbrance = 0 + + def display_color(self): + return curses.color_pair(COLOR_BLUE) | curses.A_REVERSE + + @classmethod + def from_type(cls, typ): + obj = cls() + obj.type = typ + obj.name = typ.name + obj.symbol = typ.symbol + obj.protection = typ.protection + obj.stealth_pen = typ.stealth_pen + obj.encumbrance = typ.encumbrance + + return obj + + def use(self, player): + dur = triangular_roll(20, 40) + player.queue_activity(EquipArmorActivity(self, dur)) \ No newline at end of file diff --git a/json_obj.py b/json_obj.py index 80b878b..f35253b 100644 --- a/json_obj.py +++ b/json_obj.py @@ -217,42 +217,31 @@ def load(cls, d): return obj +def load_types(filename, field_name, typ_obj): + types = {} + f = open(filename, "r") + data = json.load(f) + for obj in data: + unique = obj[field_name] + if unique in types: + raise ValueError(f"duplicate {field_name} value {unique!r} in {filename}") + typ = typ_obj.load(obj) + types[unique] = typ + return types + def load_monster_types(): - mon_types = {} - f = open("monsters.json", "r") - data = json.load(f) - for mon in data: - mon_id = mon["id"] - if mon_id in mon_types: - raise ValueError(f"duplicate monster id {mon_id!r}") - typ = MonsterType.load(mon) - mon_types[mon_id] = typ - return mon_types + return load_types("monsters.json", "id", MonsterType) def load_weapon_types(): - weap_types = {} - f = open("weapons.json", "r") - data = json.load(f) - for weap in data: - weap_id = weap["id"] - if weap_id in weap_types: - raise ValueError(f"duplicate weapon id {weap_id!r}") - typ = WeaponType.load(weap) - weap_types[weap_id] = typ - return weap_types + return load_types("weapons.json", "id", WeaponType) def load_effect_types(): - eff_types = {} - f = open("effects.json", "r") - data = json.load(f) - for effect in data: - name = effect["name"] - if name in eff_types: - raise ValueError(f"duplicate effect name {name!r}") - typ = EffectType.load(effect) - eff_types[name] = typ - return eff_types + return load_types("effects.json", "name", EffectType) + +def load_armor_types(): + return load_types("armor.json", "id", ArmorType) + diff --git a/monster.py b/monster.py index 1dff6ce..15ef365 100644 --- a/monster.py +++ b/monster.py @@ -355,8 +355,11 @@ def set_pack_target_pos(self): x = 0 y = 0 num = 0 + player = g.get_player() + + #TODO: When friendly monsters are possible (through scrolls), allow it to consider anyone it's targeting, not just the player + target_u = self.sees(player) and self.state == "AWARE" - return False #Each member of the pack tries to move toward the average of its nearby members for mon in g.monsters_in_radius(self.pos, 5): if self is mon: @@ -377,7 +380,9 @@ def set_pack_target_pos(self): x /= num y /= num target = Point(round(x), round(y)) - if self.target_pos != target: + dist_to_ent = self.distance(player) + targ_dist_to_ent = target.distance(player.pos) + if not target_u or targ_dist_to_ent < dist_to_ent: self.set_target(target) return True return False @@ -412,7 +417,7 @@ def alerted(self): mon.target_entity(player) self.set_state("AWARE") - self.target_entity(player) + self.target_entity(player) def move_dir(self, dx, dy): if super().move_dir(dx, dy): @@ -587,31 +592,51 @@ def determine_invis(self, c): def perception_roll(self): return self.roll_wisdom() + self.get_skill("perception") + + def on_hit(self, ent): + g = self.g + if self.has_flag("PACK_TRAVEL"): + for mon in g.monsters_in_radius(self.pos, rng(8, 16)): + if self is mon: + continue + if not self.is_ally(mon): + continue + + if not one_in(3): + self.set_state("AWARE") + + def move(self): g = self.g board = g.get_board() - player = g.get_player() + player = g.get_player() + + reached_target = self.target_pos == self.pos + is_targeting_u = self.has_target() and self.target_pos == player.pos + + if reached_target: + self.clear_target() match self.state: case "IDLE": self.idle() case "AWARE": if self.sees(player): - self.target_entity(player) + if not (self.has_flag("PACK_TRAVEL") and self.set_pack_target_pos()): + self.target_entity(player) + if self.id == "bat" and one_in(5): self.set_rand_target() elif self.sees_pos(player.pos): - #Target is in LOS, but invisible - reached_target = self.target_pos == self.pos + #Target is in LOS, but invisible perceived_invis = self.determine_invis(player) - if self.target_pos != player.pos and reached_target: + if not is_targeting_u and reached_target: if perceived_invis: self.target_entity(player) else: - self.random_guess_invis() - + self.random_guess_invis() else: self.set_state("TRACKING") self.target_entity(player) @@ -629,7 +654,8 @@ def move(self): #Once we reach the target, make a perception check contested by the player's stealth check to determine the new location if self.perception_roll() >= player.stealth_roll(): self.set_target(player.pos) - self.patience += rng(0, 3) + elif self.has_flag("PACK_TRAVEL") and self.set_pack_target_pos(): + self.patience += rng(0, 1) else: #If we fail, idle around for a few turns instead self.pursue_check = rng(1, 4) diff --git a/monsters.json b/monsters.json index f7e1698..3ccf87f 100644 --- a/monsters.json +++ b/monsters.json @@ -166,7 +166,7 @@ "speed": 100, "base_damage": "1d4", "attack_msg": " bites ", - "poison": { "potency": 15, "max_damage": 21 }, + "poison": { "potency": 13, "max_damage": 21 }, "blindsight_range": 2, "flags": [ "SEES" ], "reach": 2 diff --git a/player.py b/player.py index 9bf7566..e8ceb94 100644 --- a/player.py +++ b/player.py @@ -2,6 +2,8 @@ from utils import * from const import * from items import UNARMED +from collections import deque +import math class Player(Entity): @@ -24,8 +26,17 @@ def __init__(self): self.debug_wizard = False self.weapon = UNARMED self.armor = None + self.activity_queue = deque() + self.activity = None self.inventory = [] + def encumb_ev_mult(self): + enc = self.get_encumbrance() + return math.exp(-enc * enc / 70) + + def get_encumbrance(self): + return self.armor.encumbrance if self.armor else 0 + def is_unarmed(self): return self.weapon is UNARMED @@ -40,6 +51,9 @@ def calc_evasion(self): bonus *= 0.7 elif self.has_status("Reduced"): bonus *= 1.3 + + bonus *= self.encumb_ev_mult() + return bonus + 5 def add_to_inventory(self, item): @@ -48,9 +62,31 @@ def add_to_inventory(self, item): def remove_from_inventory(self, item): if item in self.inventory: self.inventory.remove(item) + + def handle_activities(self): + activity = self.activity + if activity: + activity.duration -= 1 + if activity.duration <= 0: + self.add_msg(f"You finish {activity.name}.") + activity.on_finished(self) + self.activity = None + + if self.activity_queue and not activity: + new_act = self.activity_queue.popleft() + self.add_msg(f"You begin {new_act.name}.") + self.activity = new_act + + def queue_activity(self, activity): + self.activity_queue.append(activity) - def interrupt(self): - self.is_resting = False + def interrupt(self): + if self.is_resting: + self.is_resting = False + + if self.activity: #TODO: Add confirmation before cancelling + self.activity = None + self.activity_queue.clear() def use_energy(self, amount): super().use_energy(amount) @@ -59,6 +95,12 @@ def use_energy(self, amount): def is_player(self): return True + def equip_armor(self, armor): + self.armor = armor + + def unequip_armor(self): + self.armor = None + def xp_to_next_level(self): amount = 100 * self.xp_level ** 1.5 return round(amount/10)*10 @@ -112,6 +154,22 @@ def calc_fov(self): board = g.get_board() self.fov = board.get_fov(self.pos) + def can_rest(self): + if self.HP >= self.MAX_HP: + return False + num_visible = 0 + for mon in self.visible_monsters(): + num_visible += 1 + + if num_visible > 0: + if num_visible == 1: + self.add_msg("There's a monster nearby!", "warning") + else: + self.add_msg("There are monsters nearby!", "warning") + return False + + return True + def sees_pos(self, other): return other in self.fov @@ -331,6 +389,7 @@ def attack_pos(self, pos): att_roll = self.roll_to_hit(mon) if att_roll >= 0: + mon.on_hit(self) stat = self.STR if finesse: stat = max(stat, self.DEX) @@ -351,7 +410,8 @@ def attack_pos(self, pos): ] self.add_msg(random.choice(msg)) max_bonus = 3 + mult_rand_frac(eff_level, 3, 2) - damage += dice(1, 2 + eff_level * 2) + damage = mult_rand_frac(damage, 6 + rng(0, eff_level - 1), 6) + damage += rng(0, max_bonus) mon.use_energy(triangular_roll(0, 100)) @@ -498,6 +558,10 @@ def stealth_mod(self): stealth += 3 elif self.has_status("Enlarged"): stealth -= 3 + + armor = self.armor + if armor: + stealth -= armor.stealth_pen return stealth diff --git a/utils.py b/utils.py index 35a2aab..874d9de 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,6 @@ import random, math - from random import randint as rng +from itertools import accumulate def gen_stats(): points = 27 @@ -22,12 +22,13 @@ def gen_stats(): stats[ind] += 1 points -= cost - if stats[ind] >= 15 or random.randint(1, 2) == 1: + if (stats[ind] >= 15 or (stats[ind] >= 13 and one_in(2))) or x_in_y(3, 5): ind = -1 return stats def triangular_roll(a, b): + #Returns a rand integer between a and b inclusive, biased towards the average result range = b - a r1 = range//2 r2 = (range+1)//2 @@ -99,6 +100,32 @@ def apply_armor(damage, armor): prot = rng(0, armor) + rng(0, armor) return max(damage - prot, 0) +class WeightedList: + + def __init__(self): + self.choices = [] + self.weights = [] + self.cumulative_weights = None + + def add(self, value, weight): + if weight > 0: + self.choices.append(value) + self.weights.append(weight) + self.cumulative_weights = None + + def clear(self): + self.choices.clear() + self.weights.clear() + self.cumulative_weights = None + + def pick(self): + if len(self.choices) == 0: + raise IndexError("cannot pick from an empty weighted list") + if not self.cumulative_weights: + self.cumulative_weights = list(accumulate(self.weights)) + return random.choices(self.choices, cum_weights=self.cumulative_weights)[0] + + class Dice: def __init__(self, num, sides, mod):