NotQuiteParadise2

Source code for nqp.core.systems

from __future__ import annotations

import logging
import math
import random
from typing import TYPE_CHECKING

import pygame
import snecs

from nqp.core import queries
from nqp.core.constants import (
    CRIT_MOD,
    DamageType,
    EntityFacing,
    Flags,
    HealingSource,
    PUSH_FORCE,
    TILE_SIZE,
    WEIGHT_SCALE,
)
from nqp.core.utility import angle_to, distance_to, get_direction
from nqp.world_elements.entity_components import (
    AI,
    Allegiance,
    DamageReceived,
    HealReceived,
    IsDead,
    IsReadyToAttack,
    Position,
    RangedAttack,
    Stats,
)

if TYPE_CHECKING:
    from typing import List

    from nqp.core.game import Game

__all__ = ["draw_entities", "apply_damage", "process_death"]


[docs]def draw_entities(surface: pygame.Surface, shift: pygame.Vector2 = (0, 0)): """ Draw all entities """ draw_list = list() # organize entities for layered rendering for entity, (aesthetic, position) in queries.aesthetic_position: flip = aesthetic.facing == EntityFacing.LEFT animation = aesthetic.animation frame = pygame.transform.flip(animation.surface, flip, False) # animation frame offset b/c entity's position is where their feet are x = position.x + shift.x - animation.width // 2 y = position.y + shift.y - animation.height draw_list.append((position.y, x, y, len(draw_list), frame)) # sort so entities higher on the screen are drawn first (painters alg) draw_list.sort() for operation in draw_list: _, x, y, _, frame = operation surface.blit(frame, (x, y))
[docs]def apply_damage(game: Game): """ Consume damage components and apply their value to the Entity, applying any mitigations. Dodge may negate damage. """ for entity, (damage, aesthetic, stats) in queries.damage_aesthetic_stats: damage_dealt = damage.amount # get defence if damage.type == DamageType.MAGICAL: defence = stats.magic_defence elif damage.type == DamageType.MUNDANE: defence = stats.mundane_defence else: logging.warning(f"Damage type ({damage.type.name}) not recognised. Defaulted to mundane defence.") defence = stats.mundane_defence # crit ignores defence if not damage.is_crit: # mitigate damage by defence, accounting for penetration damage_dealt = max((defence.value - damage.penetration) - damage_dealt, 0) # calc dodge dodge_successful = False if game.rng.roll() <= stats.dodge.value: dodge_successful = True # apply hit effects if no dodge if dodge_successful: # reduce defence for being hit defence.base_value = max(defence.value - 1, 0) # apply damage stats.health.base_value -= damage_dealt # check if dead if stats.health.value <= 0: snecs.add_component(entity, IsDead()) else: # apply flash aesthetic.animation.flash((255, 255, 255)) # create blood spray on crit if damage.is_crit: position = snecs.entity_component(entity, Position) game.world.model.particles.create_blood_spray() # remove damage flag snecs.remove_component(entity, DamageReceived)
[docs]def process_death(game: Game): """ Update Entity's sprites and intentions. """ for entity, (dead, aesthetic, position) in queries.dead_aesthetic_position: # update to dead sprite aesthetic.animation.set_current_frame_set_name("death") aesthetic.animation.delete_on_finish = False aesthetic.animation.loop = False game.world.model.particles.create_blood_spray(position.pos)
[docs]def process_movement(delta_time: float, game: Game): """ Update an Entity's position towards their target. """ for entity, (position, stats, ai, aesthetic) in queries.position_stats_ai_aesthetic_not_dead: # skip if we have nowhere to go if ai.behaviour.current_path is None: continue if len(ai.behaviour.current_path) == 0: continue if ai.behaviour.is_active == False: continue if ai.behaviour.state not in ["path", "path_fast"]: continue if ai.behaviour.state == "path_fast": stats.move_speed.override(100) elif ai.behaviour.state == "path": stats.move_speed.reset() move_result = _walk_path(delta_time, position, stats, ai, game) # update facing if move_result[0][0] < move_result[1][0]: facing = EntityFacing.LEFT else: facing = EntityFacing.RIGHT aesthetic.facing = facing # update sprite frame set aesthetic.animation.set_current_frame_set_name("move")
def _walk_path( delta_time: float, position: Position, stats: Stats, ai: AI, game: Game ) -> List[pygame.Vector2, pygame.Vector2]: """ Have entity walk along their path. Returns [start_pos. new_pos]. """ next_point = ai.behaviour.current_path[0] angle = angle_to(position.pos, next_point) move_distance = stats.move_speed.value * delta_time movement = get_direction(angle, move_distance) move_results = _move(movement, position, game) # if we reached next point on the path then remove from list if distance_to(position.pos, next_point) < TILE_SIZE // 3: # TODO - why over 3? ai.behaviour.current_path.pop(0) return move_results def _move(movement: pygame.Vector2, position: Position, game: Game) -> List[pygame.Vector2, pygame.Vector2]: """ Splits the movement operation into smaller amounts to prevent issues with high speed movement. Calls the move sub-process anywhere from one to several times depending on the speed. Returns [start_pos. new_pos]. """ move_count = int(abs(movement[0]) // TILE_SIZE + 1) move_count = max(int(abs(movement[1]) // TILE_SIZE + 1), move_count) move_amount = pygame.Vector2(movement[0] / move_count, movement[1] / move_count) start_pos = position.pos new_pos = start_pos for i in range(move_count): new_pos = _sub_move(move_amount, position, game) return [start_pos, new_pos] def _sub_move(movement: pygame.Vector2, position: Position, game: Game) -> pygame.Vector2: """ Small movement. Returns end position. """ check_tile_solid = game.world.model.terrain.check_tile_solid tile_rect_px = game.world.model.terrain.tile_rect_px position.x += movement[0] if check_tile_solid(position.pos): if movement[0] > 0: position.x = tile_rect_px(position.pos).left - 1 if movement[0] < 0: position.x = tile_rect_px(position.pos).right + 1 position.y += movement[1] if check_tile_solid(position.pos): if movement[1] > 0: position.y = tile_rect_px(position.pos).top - 1 if movement[1] < 0: position.y = tile_rect_px(position.pos).bottom + 1 return position.pos
[docs]def process_ai(delta_time: float): """ Update Entity ai. """ for entity, (ai,) in queries.ai_not_dead: ai.behaviour.update(delta_time) # check if behaviour flags need processing if ai.behaviour.new_move_speed is not None: if snecs.has_component(entity, Stats): stats = snecs.entity_component(entity, Stats) stats.move_speed.override(ai.behaviour.new_move_speed) ai.behaviour.new_move_speed = None if ai.behaviour.reset_move_speed: if snecs.has_component(entity, Stats): stats = snecs.entity_component(entity, Stats) stats.move_speed.reset()
[docs]def process_attack(game: Game): """ Execute any outstanding attacks. """ for entity, (attack_flag, position, stats, ai, aesthetic) in queries.attack_position_stats_ai_aesthetic_not_dead: add_projectile = game.world.model.projectiles.add_projectile # check we have someone to target if ai.behaviour.target_entity is None: continue # check that someone has the details we need target_entity = ai.behaviour.target_entity if not snecs.has_component(target_entity, Stats): continue target_pos = snecs.entity_component(target_entity, Position) target_stats = snecs.entity_component(target_entity, Stats) # check in range in_range = distance_to(position.pos, target_pos.pos) - (target_stats.size.value + stats.size.value) if in_range < stats.range.value: # update to attack animation aesthetic.animation.set_current_frame_set_name("attack") # increase damage if in godmode mod = 0 if game.memory.check_for_flag(Flags.GODMODE): if snecs.has_component(entity, Allegiance): if snecs.entity_component(entity, Allegiance).team == "player": mod = 9999 # roll for crit is_crit = False if stats.crit_chance.value > game.rng.roll(): mod += CRIT_MOD is_crit = True # handle ranged attack if snecs.has_component(entity, RangedAttack): ranged = snecs.entity_component(entity, RangedAttack) ranged.ammo.base_value -= 1 projectile_data = {"img": ranged.projectile_sprite, "speed": ranged.projectile_speed} add_projectile(entity, target_entity, projectile_data, stats.attack.value * mod) # switch to melee when out of ammo if ranged.ammo.value <= 0: stats.range.override(0) else: # add damage component snecs.add_component( target_entity, DamageReceived(stats.attack.value * mod, stats.damage_type, stats.penetration.value, is_crit), ) # reset attack timer and remove flag ai.behaviour.attack_timer = 1 / stats.attack_speed.value snecs.remove_component(entity, IsReadyToAttack)
[docs]def push_entities_away_from_one_another(delta_time: float, game: Game): """ Force overlapping units away from one another. """ for entity, (position, stats) in queries.position_stats_not_dead: for other_entity, (other_position, other_stats) in queries.position_stats_not_dead: combined_size = stats.size.value + other_stats.size.value # horizontal scan if abs(other_position.x - position.x) < combined_size: # vertical scan if abs(other_position.y - position.y) < combined_size: # check distance distance = distance_to(position.pos, other_position.pos) if distance < combined_size: # push other away from entity angle = angle_to(position.pos, other_position.pos) force = 1 - distance / combined_size move_distance = ( (stats.weight.value + WEIGHT_SCALE) / (other_stats.weight.value + WEIGHT_SCALE) * delta_time * PUSH_FORCE * force ) movement = get_direction(angle, move_distance) _move(movement, other_position, game) # push entity away from other other_angle = angle + math.pi # TODO - why + pi? move_distance = ( (other_stats.weight.value + WEIGHT_SCALE) / (stats.weight.value + WEIGHT_SCALE) * delta_time * PUSH_FORCE * force ) movement = get_direction(other_angle, move_distance) _move(movement, position, game)
[docs]def process_healing(): """ Process all the Healing Received components, applying healing where allowed. """ for entity, (healing_received, stats, attributes) in queries.heal_stats_attributes_not_dead: for heal in healing_received.heals: amount, source = heal if (source == HealingSource.SELF and attributes.can_be_healed_by_self) or ( source == HealingSource.OTHER and attributes.can_be_healed_by_other ): stats.health.base_value += amount # remove component snecs.remove_component(entity, HealReceived)