diff --git a/myning/chapters/healer.py b/myning/chapters/healer.py index 2953da2..01b6725 100644 --- a/myning/chapters/healer.py +++ b/myning/chapters/healer.py @@ -96,7 +96,7 @@ def compose(self): with self.content_container: self.content_container.border_title = "Healer" yield self.content - with Container() as c: + with Container(id="progress_container") as c: c.border_title = "Estimated Time Remaining" yield self.progress yield Footer() diff --git a/myning/chapters/mine/actions.py b/myning/chapters/mine/actions.py deleted file mode 100644 index aee5012..0000000 --- a/myning/chapters/mine/actions.py +++ /dev/null @@ -1,406 +0,0 @@ -import math -import random -from abc import ABC, abstractmethod -from dataclasses import dataclass -from functools import lru_cache - -from rich.console import RenderableType -from rich.table import Table -from textual.widget import Widget - -from myning.chapters.mine.mining_minigame import MiningMinigame, MiningScore -from myning.objects.army import Army -from myning.objects.character import Character -from myning.objects.graveyard import Graveyard -from myning.objects.inventory import Inventory -from myning.objects.item import Item -from myning.objects.player import Player -from myning.objects.settings import Settings -from myning.objects.stats import IntegerStatKeys, Stats -from myning.objects.trip import Trip -from myning.utilities.file_manager import FileManager -from myning.utilities.formatter import Formatter -from myning.utilities.generators import ( - generate_character, - generate_enemy_army, - generate_equipment, - generate_mineral, - generate_reward, -) -from myning.utilities.species_rarity import get_recruit_species -from myning.utilities.string_generation import generate_death_action -from myning.utilities.tab_title import TabTitle -from myning.utilities.ui import Icons - -player = Player() -settings = Settings() -stats = Stats() -trip = Trip() -graveyard = Graveyard() -inventory = Inventory() - - -class Action(ABC): - def __init__(self, duration=5): - self.duration = duration - - def tick(self): - self.duration -= 1 - - @property - @abstractmethod - def content(self) -> RenderableType | Widget: - pass - - @property - def next(self) -> "Action | None": - return None - - -class MineralAction(Action): - def __init__(self): - duration = random.randint(5, trip.seconds_left // 60 + 30) - self.game = MiningMinigame(duration) - super().__init__(duration) - - @property - def content(self): - if settings.mini_games_disabled: - return "\n".join( - [ - f"Mining... ({self.duration} seconds left)\n", - "💎 " * (5 - (self.duration - 1) % 5), - ] - ) - return self.game - - @property - @lru_cache(maxsize=1) - def next(self): - if not trip.mine: - return None - if settings.mini_games_disabled or self.duration == 0: - return ItemsAction( - [generate_mineral(trip.mine.max_item_level, trip.mine.resource)], - "You found a mineral!", - ) - minerals = [] - match self.game.score: - case MiningScore.GREEN: - minerals.extend( - generate_mineral(trip.mine.max_item_level, trip.mine.resource) for _ in range(2) - ) - return ItemsAction( - minerals, - "[bold green1]Fantastic![/]\n\n" - "You've struck a rich mineral layer while mining and find twice the amount of " - "minerals!\n" - f"Your progress has been advanced by [bold]{self.duration}[/] seconds.\n\n" - "Keep up the good work, miner!", - ) - case MiningScore.YELLOW: - minerals.append(generate_mineral(trip.mine.max_item_level, trip.mine.resource)) - return ItemsAction( - minerals, - "[bold yellow1]Alright![/]\n\n" - "You succesfully mine a mineral, and your progress has been advanced by " - f"[bold]{self.duration}[/] seconds.", - ) - case MiningScore.ORANGE: - return ItemsAction( - [], - "[bold orange1]Drat![/]\n\n" - "You've encountered an unexpected pocket of mineral-free rock while mining.\n\n" - "Try a little harder for better prospects!", - ) - case MiningScore.RED: - return ItemsAction( - [], - "[bold red1]Ouch![/]\n\n" - "You've struck a rocky vein while mining, and take some damage as a result.\n" - "Your progress has been delayed by [bold]10[/] seconds.\n\n" - "Be more careful with your swings!", - ) - - -@dataclass -class RoundLog: - attacker: Character - defender: Character - is_friendly: bool - damage: int - dodged: bool - critted: bool - - -class CombatAction(Action): - # pylint: disable=redefined-builtin - def __init__(self, *, enemies: Army | None = None, round=1): - if enemies: - self.enemies = enemies - else: - assert trip.mine - if trip.mine.enemies[1] < 0: - trip.mine.enemies[1] = len(player.army) + trip.mine.enemies[1] - self.enemies = generate_enemy_army( - trip.mine.character_levels, - trip.mine.enemies, - trip.mine.max_enemy_items, - trip.mine.max_enemy_item_level, - trip.mine.enemy_item_scale, - ) - self.round = round - self.round_logs: list[RoundLog] = [] - self.damage_done = 0 - self.damage_taken = 0 - TabTitle.change_tab_subactivity( - f"⚔️ Battling ({player.species.icon}{len(player.army.living_members)} " - f"v 👽{len(self.enemies.living_members)})" - ) - duration = random.randint(5, 9) - super().__init__(duration) - - @property - def content(self): - player_army = Army(player.army.living_members) - enemy_army = Army(self.enemies.living_members) - content_table = Table.grid() - content_table.add_row("[orange1]Oh no! You're under attack[/]\n") - content_table.add_row(f"[bold]Round {self.round}[/]") - content_table.add_row(f"Fighting... ({self.duration} seconds left)\n") - content_table.add_row("⚔️ " * (5 - (self.duration - 1) % 5)) - content_table.add_row("\n[bold]Your Army[/]") - content_table.add_row( - player_army.compact_view if settings.compact_mode else player_army.battle_view - ) - content_table.add_row("\n[bold]Enemy Army[/]") - content_table.add_row( - enemy_army.compact_view if settings.compact_mode else enemy_army.battle_view - ) - return content_table - - def fight(self): - battle_order = _get_battle_order(player.army.living_members, self.enemies.living_members) - # bonus = _mini_game_bonus(static_menu) - for attacker in battle_order: - if player.army.defeated or self.enemies.defeated: - break - - is_friendly = attacker in player.army - defender = random.choice( - self.enemies.living_members if is_friendly else player.army.living_members - ) - damage, dodged, crit = _calculate_damage(attacker, defender) - - if damage > 0: - if not dodged: - defender.subtract_health(damage) - self.round_logs.append( - RoundLog( - attacker=attacker, - defender=defender, - is_friendly=is_friendly, - damage=damage, - dodged=dodged, - critted=crit, - ) - ) - if is_friendly: - self.damage_done += damage - FileManager.save(attacker) - else: - self.damage_taken += damage - FileManager.save(defender) - - if defender.health <= 0: - battle_order.remove(defender) - if is_friendly: - stats.increment_int_stat(IntegerStatKeys.FALLEN_SOLDIERS) - FileManager.save(stats) - - @property - @lru_cache(maxsize=1) - def next(self): - self.fight() - if player.army.defeated: - return None - if self.enemies.defeated: - trip.add_battle(len(self.enemies), True) - FileManager.save(trip) - return VictoryAction(len(self.enemies)) - next_combat_action = CombatAction(enemies=self.enemies, round=self.round + 1) - return RoundAction( - self.damage_done, - self.damage_taken, - self.round_logs, - next_combat_action, - ) - - -class RoundAction(Action): - def __init__( - self, damage_done: int, health_lost: int, round_logs: list[RoundLog], next_action: Action - ): - self.damage_done = damage_done - self.health_lost = health_lost - self.next_action = next_action - damage_width = max(len(str(self.damage_done)), len(str(self.health_lost))) - self.summary = ( - "[bold]Round Summary[/]\n\n" - f"[bold green1]{self.damage_done:{damage_width}}[/] damage inflicted.\n" - f"[bold red1]{self.health_lost:{damage_width}}[/] damage sustained.\n" - ) - self.log_table = Table.grid(padding=(0, 1, 0, 0)) - if round_logs: - for log in round_logs: - self.log_table.add_row( - str(log.attacker.icon), - f"[{'green1' if log.is_friendly else 'red1'}]{log.attacker.name}[/]", - Icons.CRIT if log.critted else "", - "[bold dark_cyan](0)[/]" - if log.dodged - else f"[{'bold orange1' if log.critted else 'normal'}]{log.damage}[/]", - Icons.DODGE if log.dodged else "", - str(log.defender.icon), - f"[{'red1' if log.is_friendly else 'green1'}]{log.defender.name}[/]", - Icons.DEATH if log.defender.health <= 0 else "", - ) - self.log_table.columns[3].justify = "right" - super().__init__(5) - - @property - def content(self): - table = Table.grid(expand=True) - table.add_row(self.summary) - table.add_row(f"Returning to battle in {self.duration}...\n") - table.add_row(self.log_table) - return table - - @property - def next(self): - return self.next_action - - -class VictoryAction(Action): - def __init__(self, enemy_count: int): - TabTitle.change_tab_subactivity("") - assert trip.mine - rewards = generate_reward(trip.mine.max_item_level, enemy_count) - trip.add_items(*rewards) - FileManager.multi_save(trip, *rewards) - self.rewards = rewards - super().__init__(len(rewards) + 1) - - @property - def content(self): - lines = ["[green1]You won the battle![/]\n"] + [ - item.battle_new_str for item in self.rewards[: len(self.rewards) - self.duration + 1] - ] - return "\n".join(lines) - - @property - def next(self): - return self if self.duration > 1 else None - - -class ItemsAction(Action): - def __init__(self, items: list[Item], message: str): - self.items = items - trip.add_items(*items) - FileManager.multi_save(*items, trip) - self.message = message + "\n\n" + "\n".join(item.battle_new_str for item in self.items) - super().__init__(5) - - @property - def content(self): - return self.message - - -class EquipmentAction(ItemsAction): - def __init__(self): - assert trip.mine - equipment = generate_equipment(trip.mine.max_item_level) - super().__init__([equipment], "You've found a piece of equipment!") - - -class RecruitAction(Action): - def __init__(self): - assert trip.mine - levels = trip.mine.character_levels - levels = [max(1, math.ceil(level * 0.75)) for level in levels] - species = get_recruit_species(trip.mine.companion_rarity) - ally = generate_character(levels, species=species) - trip.add_ally(ally) - FileManager.multi_save(trip, ally) - self.message = "\n\n".join( - [ - "[green1]You have recruited an ally![/]", - f"{ally.icon} [bold]{ally.name}[/] ({Icons.LEVEL} {Formatter.level(ally.level)})", - f"{ally.introduction} I'd like to join your army.", - ] - ) - super().__init__(5) - - @property - def content(self): - return self.message - - -class LoseAllyAction(Action): - def __init__(self): - ally = random.choice(player.allies) - reason = generate_death_action() - if ally.is_ghost: - self.message = ( - f"[dodger_blue1]{ally.icon} {ally.name} was almost {reason}.[/]\n\n" - "Luckily, they're a ghost." - ) - else: - self.message = ( - f"[red1]Oh no! {ally.icon} {ally.name} has died![/]\n\nCause of death: {reason}." - ) - player.remove_ally(ally) - for item in ally.equipment.all_items: - inventory.add_item(item) - ally.equipment.clear() - - trip.remove_ally(ally) - graveyard.add_fallen_ally(ally) - FileManager.multi_save(trip, player, graveyard, inventory) - super().__init__(5) - - @property - def content(self): - return self.message - - -def _get_battle_order(allies: list[Character], enemies: list[Character]): - combined = allies + enemies - random.shuffle(combined) - return combined - - -def _calculate_damage(attacker: Character, defender: Character, bonus=1): - dodge_chance = int(defender.stats["dodge_chance"]) - dodge = random.choices( - [True, False], - weights=[dodge_chance, 100 - dodge_chance], - )[0] - - critical_chance = int(attacker.stats["critical_chance"]) - crit = random.choices( - [True, False], - weights=[critical_chance, 100 - critical_chance], - )[0] - - damage = attacker.stats["damage"] - damage = random.randint(0, damage) - damage = int(bonus * damage) - if crit: - damage *= 2 - - armor = defender.stats["armor"] - blocked = random.randint(0, max(armor, 0)) - damage -= blocked - damage = max(damage, 0) - return damage, dodge, crit diff --git a/myning/chapters/mine/actions/__init__.py b/myning/chapters/mine/actions/__init__.py new file mode 100644 index 0000000..82613ec --- /dev/null +++ b/myning/chapters/mine/actions/__init__.py @@ -0,0 +1,187 @@ +import math +import random +from abc import ABC, abstractmethod +from functools import lru_cache + +from rich.console import RenderableType +from textual.widget import Widget + +from myning.chapters.mine.mining_minigame import MiningMinigame, MiningScore +from myning.objects.graveyard import Graveyard +from myning.objects.inventory import Inventory +from myning.objects.item import Item +from myning.objects.player import Player +from myning.objects.settings import Settings +from myning.objects.stats import Stats +from myning.objects.trip import Trip +from myning.utilities.file_manager import FileManager +from myning.utilities.formatter import Formatter +from myning.utilities.generators import generate_character, generate_equipment, generate_mineral +from myning.utilities.species_rarity import get_recruit_species +from myning.utilities.string_generation import generate_death_action +from myning.utilities.ui import Icons + +player = Player() +settings = Settings() +stats = Stats() +trip = Trip() +graveyard = Graveyard() +inventory = Inventory() + + +class Action(ABC): + def __init__(self, duration=5): + self.duration = duration + + def tick(self): + self.duration -= 1 + + @property + @abstractmethod + def content(self) -> RenderableType | Widget: + pass + + @property + def next(self) -> "Action | None": + return None + + +class MineralAction(Action): + def __init__(self): + duration = random.randint(5, trip.seconds_left // 60 + 30) + self.game = MiningMinigame(duration) + super().__init__(duration) + + @property + def content(self): + if settings.mini_games_disabled: + return "\n".join( + [ + f"Mining... ({self.duration} seconds left)\n", + "💎 " * (5 - (self.duration - 1) % 5), + ] + ) + return self.game + + @property + @lru_cache(maxsize=1) + def next(self): + if not trip.mine: + return None + if settings.mini_games_disabled or self.duration == 0: + return ItemsAction( + [generate_mineral(trip.mine.max_item_level, trip.mine.resource)], + "You found a mineral!", + ) + minerals = [] + match self.game.score: + case MiningScore.GREEN: + minerals.extend( + generate_mineral(trip.mine.max_item_level, trip.mine.resource) for _ in range(2) + ) + return ItemsAction( + minerals, + "[bold green1]Fantastic![/]\n\n" + "You've struck a rich mineral layer while mining and find twice the amount of " + "minerals!\n" + f"Your progress has been advanced by [bold]{self.duration}[/] seconds.\n\n" + "Keep up the good work, miner!", + ) + case MiningScore.YELLOW: + minerals.append(generate_mineral(trip.mine.max_item_level, trip.mine.resource)) + return ItemsAction( + minerals, + "[bold yellow1]Alright![/]\n\n" + "You succesfully mine a mineral, and your progress has been advanced by " + f"[bold]{self.duration}[/] seconds.", + ) + case MiningScore.ORANGE: + return ItemsAction( + [], + "[bold orange1]Drat![/]\n\n" + "You've encountered an unexpected pocket of mineral-free rock while mining.\n\n" + "Try a little harder for better prospects!", + ) + case MiningScore.RED: + return ItemsAction( + [], + "[bold red1]Ouch![/]\n\n" + "You've struck a rocky vein while mining, and take some damage as a result.\n" + "Your progress has been delayed by [bold]10[/] seconds.\n\n" + "Be more careful with your swings!", + ) + + +class ItemsAction(Action): + def __init__(self, items: list[Item], message: str): + self.items = items + trip.add_items(*items) + FileManager.multi_save(*items, trip) + self.message = message + "\n\n" + "\n".join(item.battle_new_str for item in self.items) + super().__init__(5) + + @property + def content(self): + return self.message + + +class EquipmentAction(ItemsAction): + def __init__(self): + assert trip.mine + equipment = generate_equipment(trip.mine.max_item_level) + super().__init__([equipment], "You've found a piece of equipment!") + + +class RecruitAction(Action): + def __init__(self): + assert trip.mine + levels = trip.mine.character_levels + levels = [max(1, math.ceil(level * 0.75)) for level in levels] + species = get_recruit_species(trip.mine.companion_rarity) + ally = generate_character(levels, species=species) + trip.add_ally(ally) + FileManager.multi_save(trip, ally) + self.message = "\n\n".join( + [ + "[green1]You have recruited an ally![/]", + f"{ally.icon} [bold]{ally.name}[/] ({Icons.LEVEL} {Formatter.level(ally.level)})", + f"{ally.introduction} I'd like to join your army.", + ] + ) + super().__init__(5) + + @property + def content(self): + return self.message + + +class LoseAllyAction(Action): + def __init__(self): + ally = random.choice(player.allies) + reason = generate_death_action() + if ally.is_ghost: + self.message = ( + f"[dodger_blue1]{ally.icon} {ally.name} was almost {reason}.[/]\n\n" + "Luckily, they're a ghost." + ) + else: + self.message = ( + f"[red1]Oh no! {ally.icon} {ally.name} has died![/]\n\nCause of death: {reason}." + ) + player.remove_ally(ally) + for item in ally.equipment.all_items: + inventory.add_item(item) + ally.equipment.clear() + + trip.remove_ally(ally) + graveyard.add_fallen_ally(ally) + FileManager.multi_save(trip, player, graveyard, inventory) + super().__init__(5) + + @property + def content(self): + return self.message + + +# pylint: disable=wrong-import-position +from .combat import * diff --git a/myning/chapters/mine/actions/combat.py b/myning/chapters/mine/actions/combat.py new file mode 100644 index 0000000..89ebab3 --- /dev/null +++ b/myning/chapters/mine/actions/combat.py @@ -0,0 +1,254 @@ +import random +from dataclasses import dataclass +from functools import lru_cache + +from rich.table import Table + +from myning.chapters.mine.actions import Action +from myning.chapters.mine.combat_minigame import CombatMinigame +from myning.objects.army import Army +from myning.objects.character import Character +from myning.objects.player import Player +from myning.objects.settings import Settings +from myning.objects.stats import IntegerStatKeys, Stats +from myning.objects.trip import Trip +from myning.utilities.file_manager import FileManager +from myning.utilities.formatter import Formatter +from myning.utilities.generators import generate_enemy_army, generate_reward +from myning.utilities.tab_title import TabTitle +from myning.utilities.ui import Icons + +player = Player() +settings = Settings() +stats = Stats() +trip = Trip() + + +class CombatAction(Action): + # pylint: disable=redefined-builtin + def __init__(self, *, enemies: Army | None = None, round=1): + if enemies: + self.enemies = enemies + else: + assert trip.mine + if trip.mine.enemies[1] < 0: + trip.mine.enemies[1] = len(player.army) + trip.mine.enemies[1] + self.enemies = generate_enemy_army( + trip.mine.character_levels, + trip.mine.enemies, + trip.mine.max_enemy_items, + trip.mine.max_enemy_item_level, + trip.mine.enemy_item_scale, + ) + self.round = round + self.round_logs: list[RoundLog] = [] + self.damage_done = 0 + self.damage_taken = 0 + TabTitle.change_tab_subactivity( + f"⚔️ Battling ({player.species.icon}{len(player.army.living_members)} " + f"v 👽{len(self.enemies.living_members)})" + ) + self.duration = 9 + self.game = CombatMinigame(self) + super().__init__(self.duration) + + @property + def content(self): + if not settings.mini_games_disabled: + return self.game + allies = Army(player.army.living_members) + enemies = Army(self.enemies.living_members) + table = Table.grid() + table.add_row("[orange1]Oh no! You're under attack[/]\n") + table.add_row(f"[bold]Round {self.round}[/]") + table.add_row(f"Fighting... ({self.duration} seconds left)\n") + table.add_row("⚔️ " * (5 - (self.duration - 1) % 5)) + table.add_row("\n[bold]Your Army[/]") + table.add_row(allies.compact_view if settings.compact_mode else allies.battle_view) + table.add_row("\n[bold]Enemy Army[/]") + table.add_row(enemies.compact_view if settings.compact_mode else enemies.battle_view) + return table + + def fight(self, bonus: float): + battle_order = _get_battle_order(player.army.living_members, self.enemies.living_members) + for attacker in battle_order: + if player.army.defeated or self.enemies.defeated: + break + + is_friendly = attacker in player.army + defender = random.choice( + self.enemies.living_members if is_friendly else player.army.living_members + ) + if is_friendly: + damage, dodged, crit = _calculate_damage(attacker, defender, bonus) + else: + damage, dodged, crit = _calculate_damage(attacker, defender) + + if damage > 0: + if not dodged: + defender.subtract_health(damage) + self.round_logs.append( + RoundLog( + attacker=attacker, + defender=defender, + is_friendly=is_friendly, + damage=damage, + dodged=dodged, + critted=crit, + ) + ) + if is_friendly: + self.damage_done += damage + FileManager.save(attacker) + else: + self.damage_taken += damage + FileManager.save(defender) + + if defender.health <= 0: + battle_order.remove(defender) + if is_friendly: + stats.increment_int_stat(IntegerStatKeys.FALLEN_SOLDIERS) + FileManager.save(stats) + + @property + @lru_cache(maxsize=1) + def next(self): + self.fight(self.game.bonus) + self.game.remove() + if player.army.defeated: + return None + if self.enemies.defeated: + trip.add_battle(len(self.enemies), True) + FileManager.save(trip) + return VictoryAction(len(self.enemies)) + next_combat_action = CombatAction(enemies=self.enemies, round=self.round + 1) + return RoundAction( + self.game.bonus, + self.damage_done, + self.damage_taken, + self.round_logs, + next_combat_action, + ) + + +@dataclass +class RoundLog: + attacker: Character + defender: Character + is_friendly: bool + damage: int + dodged: bool + critted: bool + + +class RoundAction(Action): + def __init__( + self, + bonus: float, + damage_done: int, + health_lost: int, + round_logs: list[RoundLog], + next_action: Action, + ): + damage_width = max(len(str(damage_done)), len(str(health_lost))) + self.summary = "[bold]Round Summary[/]\n\n" + if not settings.mini_games_disabled: + self.summary += f"{_bonus_str(bonus)}\n\n" + self.summary += ( + f"[bold green1]{damage_done:{damage_width}}[/] damage inflicted.\n" + f"[bold red1]{health_lost:{damage_width}}[/] damage sustained.\n" + ) + self.log_table = Table.grid(padding=(0, 1, 0, 0)) + if round_logs: + for log in round_logs: + self.log_table.add_row( + str(log.attacker.icon), + f"[{'green1' if log.is_friendly else 'red1'}]{log.attacker.name}[/]", + Icons.CRIT if log.critted else "", + "[bold dark_cyan](0)[/]" + if log.dodged + else f"[{'bold orange1' if log.critted else 'normal'}]{log.damage}[/]", + Icons.DODGE if log.dodged else "", + str(log.defender.icon), + f"[{'red1' if log.is_friendly else 'green1'}]{log.defender.name}[/]", + Icons.DEATH if log.defender.health <= 0 else "", + ) + self.log_table.columns[3].justify = "right" + self.next_action = next_action + super().__init__(5) + + @property + def content(self): + table = Table.grid(expand=True) + table.add_row(self.summary) + table.add_row(f"Returning to battle in {self.duration}...\n") + table.add_row(self.log_table) + return table + + @property + def next(self): + return self.next_action + + +class VictoryAction(Action): + def __init__(self, enemy_count: int): + TabTitle.change_tab_subactivity("") + assert trip.mine + rewards = generate_reward(trip.mine.max_item_level, enemy_count) + trip.add_items(*rewards) + FileManager.multi_save(trip, *rewards) + self.rewards = rewards + super().__init__(len(rewards) + 1) + + @property + def content(self): + lines = ["[green1]You won the battle![/]\n"] + [ + item.battle_new_str for item in self.rewards[: len(self.rewards) - self.duration + 1] + ] + return "\n".join(lines) + + @property + def next(self): + return self if self.duration > 1 else None + + +def _get_battle_order(allies: list[Character], enemies: list[Character]): + combined = allies + enemies + random.shuffle(combined) + return combined + + +def _calculate_damage(attacker: Character, defender: Character, bonus: float = 1): + dodge_chance = int(defender.stats["dodge_chance"]) + dodge = random.choices( + [True, False], + weights=[dodge_chance, 100 - dodge_chance], + )[0] + + critical_chance = int(attacker.stats["critical_chance"]) + crit = random.choices( + [True, False], + weights=[critical_chance, 100 - critical_chance], + )[0] + + damage = attacker.stats["damage"] + damage = random.randint(0, damage) + damage = int(bonus * damage) + if crit: + damage *= 2 + + armor = defender.stats["armor"] + blocked = random.randint(0, max(armor, 0)) + damage -= blocked + damage = max(damage, 0) + return damage, dodge, crit + + +def _bonus_str(bonus: float): + return ( + "[bold]No[/] damage bonus" + if bonus == 1 + else f"[bold green1]{Formatter.percentage(bonus)}[/] damage bonus" + if bonus > 1 + else f"[bold red1]{Formatter.percentage(bonus)}[/] damage penalty" + ) diff --git a/myning/chapters/mine/combat_minigame.py b/myning/chapters/mine/combat_minigame.py new file mode 100644 index 0000000..a5980c9 --- /dev/null +++ b/myning/chapters/mine/combat_minigame.py @@ -0,0 +1,175 @@ +import random +from typing import TYPE_CHECKING + +from rich.table import Table +from textual import events +from textual.containers import Container, Vertical +from textual.reactive import reactive +from textual.widgets import ProgressBar, Static + +from myning.objects.army import Army +from myning.objects.player import Player +from myning.objects.settings import Settings + +if TYPE_CHECKING: + from myning.chapters.mine.actions.combat import CombatAction + +TICKS_PER_SECOND = 12 +BONUS_THRESHOLD = 2 / 3 +KEYS = ["a", "s", "d", "f"] + +player = Player() +settings = Settings() + + +class CombatMinigame(Container): + def __init__(self, action: "CombatAction") -> None: + super().__init__() + self.action = action + self.minigame = Minigame(self.action.duration) + + def compose(self): + with Container(): + yield CombatDisplay(self.action) + with Vertical(): + yield self.minigame + yield ProgressBar(show_eta=False) + + def toggle_paused(self): + self.display = self.minigame.paused + self.minigame.paused = not self.minigame.paused + self.minigame.focus() + + @property + def bonus(self): + if settings.mini_games_disabled or not self.minigame.total: + return 1 + score = self.minigame.total_correct / self.minigame.total + if score < BONUS_THRESHOLD: + bonus = score - BONUS_THRESHOLD + else: + bonus = (score - BONUS_THRESHOLD) * 3 + bonus *= self.minigame.total / self.minigame.potential + return 1 + bonus + + +class CombatDisplay(Static): + def __init__(self, action: "CombatAction"): + super().__init__() + self.action = action + + def on_mount(self): + self.tick() + self.set_interval(1, self.tick) + + def tick(self): + allies = Army(player.army.living_members) + enemies = Army(self.action.enemies.living_members) + table = Table.grid() + table.add_row("[orange1]Oh no! You're under attack[/]\n") + table.add_row(f"[bold]Round {self.action.round}[/]") + table.add_row(f"Fighting... ({self.action.duration} seconds left)\n") + table.add_row("⚔️ " * (5 - (self.action.duration - 1) % 5)) + table.add_row("\n[bold]Your Army[/]") + table.add_row(allies.compact_view if settings.compact_mode else allies.battle_view) + table.add_row("\n[bold]Enemy Army[/]") + table.add_row(enemies.compact_view if settings.compact_mode else enemies.battle_view) + self.update(table) + + +class Minigame(Static): + can_focus = True + active_key = reactive(None) + visible_lines = reactive([]) + color = reactive("dodger_blue1") + + def __init__(self, duration: int): + super().__init__() + self.paused = False + + self.width = random.randint(3, 4) + self.lines = [""] * 12 + self.solutions: list[int | None] = [None] * 12 + for _ in range(duration * TICKS_PER_SECOND): + position = random.randint(0, self.width - 1) + length = random.randint(4, 6) + shafts = ["|"] * (length - 2) + lines = [f" {' ' * position * 5}{char}" for char in ["▼", *shafts, "v"]] + self.lines.extend(lines) + self.solutions.extend([position] * length) + # Gap + self.lines.extend([""] * 2) + self.solutions.extend([None] * 2) + self.lines.reverse() + self.solutions.reverse() + + self.potential = 0 + self.total = 0 + self.total_correct = 0 + + def render(self): + table = Table.grid() + for line in self.visible_lines: + table.add_row(line) + table.add_row(f"[{self.color}]{'═' * self.width * 5}[/]") + keys = "".join( + f"[bold on dodger_blue1] {key.upper()} [/]" + if key == self.active_key + else f"[bold] {key.upper()} [/]" + for key in KEYS[: self.width] + ) + table.add_row(keys) + return table + + def on_mount(self): + self.focus() + self.tick() + self.set_interval(1 / TICKS_PER_SECOND, self.tick) + + def on_key(self, key: events.Key): + if key.name in KEYS: + self.active_key = key.name + + def watch_color(self): + color = { + "dodger_blue1": "dodgerblue", + "green1": "lime", + "red1": "red", + }[self.color] + self.styles.border = ("double", color) + + def tick(self): + if self.paused: + return + self.lines.pop() + self.solutions.pop() + self.visible_lines = self.lines[-15:] + + if self.solution is not None: + self.potential += 1 + if self.active_key: + self.total += 1 + if self.correct: + self.total_correct += 1 + self.color = "green1" + else: + self.color = "red1" + + if query := self.app.query("CombatMinigame ProgressBar"): + progress = query.first(ProgressBar) + if self.total: + progress.total = self.total + progress.progress = self.total_correct + + @property + def solution(self): + return self.solutions[-1] + + @property + def correct(self): + if not self.active_key: + return False + try: + return KEYS.index(self.active_key) == self.solution + except ValueError: + return False diff --git a/myning/chapters/mine/screen.py b/myning/chapters/mine/screen.py index 13c1e76..f37dcb9 100644 --- a/myning/chapters/mine/screen.py +++ b/myning/chapters/mine/screen.py @@ -2,6 +2,7 @@ import time from typing import Type +from rich.table import Table from textual.containers import Container, Horizontal, ScrollableContainer, Vertical from textual.screen import Screen from textual.widget import Widget @@ -73,10 +74,10 @@ def compose(self): yield self.content with self.sidebar: yield self.army - with Container() as c: + with Container(id="summary_container") as c: c.border_title = "Trip Summary" yield self.summary - with Container() as c: + with Container(id="progress_container") as c: c.border_title = "Trip Progress" yield self.time yield self.progress @@ -94,7 +95,7 @@ def action_compact(self): def action_skip(self): if self.abandoning: self.abandoning = False - if isinstance(self.action, MineralAction): + if isinstance(self.action, (MineralAction, CombatAction)): self.action.game.toggle_paused() self.update_screen() elif self.should_exit: @@ -124,6 +125,15 @@ def action_skip(self): member.health -= 1 FileManager.multi_save(*player.army) self.skip(color) + elif isinstance(self.action, CombatAction): + if settings.mini_games_disabled: + # pylint: disable=protected-access + table: Table = self.content._renderable # type: ignore + if "disabled" not in str(table.columns[0]._cells[-1]): + table.add_row( + "\nMinigames have been disabled; you can enable them in the settings." + ) + self.content.update(table) elif isinstance(self.action, ItemsAction): if self.check_skip(TICK_LENGTH): trip.seconds_passed(TICK_LENGTH) @@ -152,11 +162,12 @@ def tick(self): self.exit() return - self.update_screen() self.action.tick() if self.action.duration <= 0: self.action = self.next_action + self.update_screen() + def update_screen(self): if not trip.mine: return @@ -196,7 +207,7 @@ def reset_border(self): self.content_container.styles.border = ("round", "dodgerblue") def confirm_abandon(self): - if isinstance(self.action, MineralAction): + if isinstance(self.action, (MineralAction, CombatAction)): self.action.game.toggle_paused() self.content.update( "Are you sure you want to abandon your trip?\n\n" diff --git a/myning/tui/app.css b/myning/tui/app.css index 296e413..d2876af 100644 --- a/myning/tui/app.css +++ b/myning/tui/app.css @@ -78,9 +78,14 @@ SideBar > DataTable { HealScreen Container, MineScreen Container { - border: round dodgerblue; height: auto; layout: horizontal; +} + +HealScreen #progress_container, +MineScreen #progress_container, +MineScreen #summary_container { + border: round dodgerblue; padding: 0 0 0 1; } @@ -109,6 +114,21 @@ MineScreen Container > Static { margin: 0 1 0 0; } +CombatMinigame Container Vertical { + height: auto; + margin: 4 0 0 8; +} + +CombatMinigame Minigame { + border: double dodgerblue; + width: auto; + height: auto; +} + +CombatMinigame ProgressBar { + width: 22; +} + /* HelpScreen */ HelpScreen { diff --git a/myning/tui/chapter/question.py b/myning/tui/chapter/question.py index a39f96b..fe7434b 100644 --- a/myning/tui/chapter/question.py +++ b/myning/tui/chapter/question.py @@ -1,14 +1,14 @@ from rich.console import RenderableType from rich.table import Table -from textual.reactive import Reactive +from textual.reactive import reactive from textual.widgets import Static from myning.utilities.ui import Colors class Question(Static): - message: Reactive[RenderableType] = Reactive("", layout=True) # type:ignore - subtitle: Reactive[RenderableType] = Reactive("", layout=True) # type: ignore + message: reactive[RenderableType] = reactive("", layout=True) # type:ignore + subtitle: reactive[RenderableType] = reactive("", layout=True) # type: ignore def render(self): table = Table.grid() diff --git a/tests/chapters/test_mine.py b/tests/chapters/test_mine.py index fe13d78..f8fe1fc 100644 --- a/tests/chapters/test_mine.py +++ b/tests/chapters/test_mine.py @@ -110,9 +110,10 @@ async def test_victory(app: MyningApp, pilot: Pilot, chapter: ChapterWidget): # increase player stats player.level = 30 - # skip until the trip is over + # wait for trip to be over + trip.seconds_left = 10 while app.query("MineScreen"): - await pilot.press("enter") + await pilot.pause(1) assert "Your mining trip" in chapter.question.message @@ -125,9 +126,10 @@ async def test_defeat(app: MyningApp, pilot: Pilot, chapter: ChapterWidget): # pick and start mine await pilot.press("enter", "enter", "enter") - # skip until the trip is over + # wait for trip to be over + trip.seconds_left = 10 while app.query("MineScreen"): - await pilot.press("enter") + await pilot.pause(1) # chasm is impossible for a level one player assert "You lost the battle" in chapter.question.message