diff --git a/assets/ui/widgets/border_round_gold.png b/assets/ui/borders/border_round_gold.png similarity index 100% rename from assets/ui/widgets/border_round_gold.png rename to assets/ui/borders/border_round_gold.png diff --git a/assets/ui/widgets/border_silver.png b/assets/ui/borders/border_silver.png similarity index 100% rename from assets/ui/widgets/border_silver.png rename to assets/ui/borders/border_silver.png diff --git a/assets/ui/widgets/border_square_gold.png b/assets/ui/borders/border_square_gold.png similarity index 100% rename from assets/ui/widgets/border_square_gold.png rename to assets/ui/borders/border_square_gold.png diff --git a/assets/ui/widgets/border_wood.png b/assets/ui/borders/border_wood.png similarity index 100% rename from assets/ui/widgets/border_wood.png rename to assets/ui/borders/border_wood.png diff --git a/assets/ui/widgets/checkbox_ticked.png b/assets/ui/borders/checkbox_ticked.png similarity index 100% rename from assets/ui/widgets/checkbox_ticked.png rename to assets/ui/borders/checkbox_ticked.png diff --git a/assets/ui/widgets/checkbox_unticked.png b/assets/ui/borders/checkbox_unticked.png similarity index 100% rename from assets/ui/widgets/checkbox_unticked.png rename to assets/ui/borders/checkbox_unticked.png diff --git a/assets/ui/widgets/hud_slot1.png b/assets/ui/borders/hud_slot1.png similarity index 100% rename from assets/ui/widgets/hud_slot1.png rename to assets/ui/borders/hud_slot1.png diff --git a/assets/ui/widgets/hud_slot2.png b/assets/ui/borders/hud_slot2.png similarity index 100% rename from assets/ui/widgets/hud_slot2.png rename to assets/ui/borders/hud_slot2.png diff --git a/assets/ui/widgets/progress_bar.png b/assets/ui/borders/progress_bar.png similarity index 100% rename from assets/ui/widgets/progress_bar.png rename to assets/ui/borders/progress_bar.png diff --git a/assets/ui/icons/overworld.png b/assets/ui/icons/overworld.png deleted file mode 100644 index b43869ef4..000000000 --- a/assets/ui/icons/overworld.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ec582ceec3bbf8a9f36928c58198af4ea6f45bee2d012cf627cbf523455fe8bc -size 1689439 diff --git a/assets/ui/icons/town.png b/assets/ui/icons/town.png deleted file mode 100644 index 955ace630..000000000 --- a/assets/ui/icons/town.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d12ab5fb166dac858aa247652136a597e79859abbe32109236318b1eecd2729a -size 313956 diff --git a/assets/stats/dmg_dealt.png b/assets/ui/records/dmg_dealt.png similarity index 100% rename from assets/stats/dmg_dealt.png rename to assets/ui/records/dmg_dealt.png diff --git a/assets/stats/kills.png b/assets/ui/records/kills.png similarity index 100% rename from assets/stats/kills.png rename to assets/ui/records/kills.png diff --git a/assets/stats/gold.png b/assets/ui/resources/gold.png similarity index 100% rename from assets/stats/gold.png rename to assets/ui/resources/gold.png diff --git a/assets/stats/morale.png b/assets/ui/resources/morale.png similarity index 100% rename from assets/stats/morale.png rename to assets/ui/resources/morale.png diff --git a/assets/stats/rations.png b/assets/ui/resources/rations.png similarity index 100% rename from assets/stats/rations.png rename to assets/ui/resources/rations.png diff --git a/assets/stats/ammo.png b/assets/ui/stats/ammo.png similarity index 100% rename from assets/stats/ammo.png rename to assets/ui/stats/ammo.png diff --git a/assets/stats/attack.png b/assets/ui/stats/attack.png similarity index 100% rename from assets/stats/attack.png rename to assets/ui/stats/attack.png diff --git a/assets/stats/attack_speed.png b/assets/ui/stats/attack_speed.png similarity index 100% rename from assets/stats/attack_speed.png rename to assets/ui/stats/attack_speed.png diff --git a/assets/stats/charisma.png b/assets/ui/stats/charisma.png similarity index 100% rename from assets/stats/charisma.png rename to assets/ui/stats/charisma.png diff --git a/assets/stats/count.png b/assets/ui/stats/count.png similarity index 100% rename from assets/stats/count.png rename to assets/ui/stats/count.png diff --git a/assets/stats/crit_chance.png b/assets/ui/stats/crit_chance.png similarity index 100% rename from assets/stats/crit_chance.png rename to assets/ui/stats/crit_chance.png diff --git a/assets/stats/damage_type.png b/assets/ui/stats/damage_type.png similarity index 100% rename from assets/stats/damage_type.png rename to assets/ui/stats/damage_type.png diff --git a/assets/actions/IconAirShield.png b/assets/ui/stats/dodge.png similarity index 100% rename from assets/actions/IconAirShield.png rename to assets/ui/stats/dodge.png diff --git a/assets/stats/health.png b/assets/ui/stats/health.png similarity index 100% rename from assets/stats/health.png rename to assets/ui/stats/health.png diff --git a/assets/stats/leadership.png b/assets/ui/stats/leadership.png similarity index 100% rename from assets/stats/leadership.png rename to assets/ui/stats/leadership.png diff --git a/assets/stats/magic_defence.png b/assets/ui/stats/magic_defence.png similarity index 100% rename from assets/stats/magic_defence.png rename to assets/ui/stats/magic_defence.png diff --git a/assets/stats/move_speed.png b/assets/ui/stats/move_speed.png similarity index 100% rename from assets/stats/move_speed.png rename to assets/ui/stats/move_speed.png diff --git a/assets/stats/mundane_defence.png b/assets/ui/stats/mundane_defence.png similarity index 100% rename from assets/stats/mundane_defence.png rename to assets/ui/stats/mundane_defence.png diff --git a/assets/stats/penetration.png b/assets/ui/stats/penetration.png similarity index 100% rename from assets/stats/penetration.png rename to assets/ui/stats/penetration.png diff --git a/assets/stats/range.png b/assets/ui/stats/range.png similarity index 100% rename from assets/stats/range.png rename to assets/ui/stats/range.png diff --git a/assets/actions/IconCure.png b/assets/ui/stats/regen.png similarity index 100% rename from assets/actions/IconCure.png rename to assets/ui/stats/regen.png diff --git a/assets/stats/size.png b/assets/ui/stats/size.png similarity index 100% rename from assets/stats/size.png rename to assets/ui/stats/size.png diff --git a/data/config.yaml b/data/config.yaml index f78479a83..a9bee4ff3 100644 --- a/data/config.yaml +++ b/data/config.yaml @@ -34,6 +34,8 @@ unit_base_values: weight: 0 crit_chance: 0 penetration: 0 + dodge: 1 + regen: 0 tier_2: ammo: 0 attack: 0 @@ -49,6 +51,8 @@ unit_base_values: weight: 0 crit_chance: 0 penetration: 0 + dodge: 1 + regen: 0 tier_3: ammo: 0 attack: 0 @@ -64,6 +68,8 @@ unit_base_values: weight: 0 crit_chance: 0 penetration: 0 + dodge: 1 + regen: 0 tier_4: ammo: 0 attack: 0 @@ -79,6 +85,8 @@ unit_base_values: weight: 0 crit_chance: 0 penetration: 0 + dodge: 1 + regen: 0 unit_properties: injuries_before_death: 3 unit_tier_occur_rates: diff --git a/data/tooltips.yaml b/data/tooltips.yaml index 76be27bcc..76ca7dd97 100644 --- a/data/tooltips.yaml +++ b/data/tooltips.yaml @@ -1,3 +1,4 @@ +# Unit stats mundane_defence: title: "Mundane Defence" text: "Resistance to mundane damage. Reduces after taking damage." @@ -42,10 +43,6 @@ damage_type: title: "Damage Type" text: "Type of damage dealt by an attack." image: "damage_type" -gold: - title: "Gold" - text: "Gold can be exchanged for goods and services." - image: "gold" health: title: "Health" text: "How much damage can be taken before an entity dies." @@ -62,10 +59,28 @@ range: title: "Range" text: "How far away an entity's attack can reach." image: "range" +dodge: + title: "Dodge" + text: "Chance to ignore an attack entirely." + image: dodge +regen: + title: "Regeneration" + text: "Amount of health gained per second." + image: regen + +# Resources +gold: + title: "Gold" + text: "Gold can be exchanged for goods and services." + image: "gold" + + # Commander Stats ally: title: "Ally" text: "Those who are willing to join you in your ambitions." image: null + +# Factions aberrations_of_nature: title: "Aberrations of Nature" text: "Self-identified as the Naturists, they are widely known as the Aberrations of Nature. Brought into existence by the quirks of fate, or the whims of the gods, they desire most to be acknowledged as alive. Champions of the natural order of the world they despise the Hammerites above all else, and will stop at nothing to prevent them from claiming rule, as this threatens their entire existence." diff --git a/data/traits/armoured.yaml b/data/traits/armoured.yaml new file mode 100644 index 000000000..6f9062e18 --- /dev/null +++ b/data/traits/armoured.yaml @@ -0,0 +1,12 @@ +--- +name: Armoured +description: Particularly resistant to damage, especially from ranged attacks. +effects: + - name: DamageResistanceEffect + target: self + attack_type: ranged + modifier: -20 + - name: DamageResistanceEffect + target: self + attack_type: melee + modifier: -10 \ No newline at end of file diff --git a/data/traits/living_dead.yaml b/data/traits/living_dead.yaml new file mode 100644 index 000000000..fba839eb5 --- /dev/null +++ b/data/traits/living_dead.yaml @@ -0,0 +1,15 @@ +--- +name: Living Dead +description: | + The dead have risen! Though their form slowly pulls itself together while they + walk the land it eschews outside helps. + Self regen and cant be healed by others. +effects: + - name: StatsEffect + target: self + attribute: regen + modifier: 20%*max_health + - name: StatsEffect + target: self + attribute: CanBeHealedByOther + state: false \ No newline at end of file diff --git a/data/traits/lucky.yaml b/data/traits/lucky.yaml new file mode 100644 index 000000000..4f45b6406 --- /dev/null +++ b/data/traits/lucky.yaml @@ -0,0 +1,12 @@ +--- +name: Lucky +description: Increased and . +effects: + - name: StatsEffect + target: self + attribute: crit_chance + modifier: 10 + - name: StatsEffect + target: self + attribute: dodge + modifier: 5 \ No newline at end of file diff --git a/data/traits/spiky.yaml b/data/traits/spiky.yaml new file mode 100644 index 000000000..8154ccc97 --- /dev/null +++ b/data/traits/spiky.yaml @@ -0,0 +1,10 @@ +--- +name: Spiky +description: A prickly exterior. Return damage on being attacked in melee. +effects: + - name: DamageEffect + target: attacker + received_attack_type: melee + trigger: OnAttacked + amount: 10 + damage_type: mundane \ No newline at end of file diff --git a/data/units/bandit.yaml b/data/units/bandit.yaml index 12e7f64e3..4f91ca470 100644 --- a/data/units/bandit.yaml +++ b/data/units/bandit.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: chivalric_order gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: bandit diff --git a/data/units/cavalier_of_the_gleaming_dawn.yaml b/data/units/cavalier_of_the_gleaming_dawn.yaml index 587485cfe..c3f455e09 100644 --- a/data/units/cavalier_of_the_gleaming_dawn.yaml +++ b/data/units/cavalier_of_the_gleaming_dawn.yaml @@ -4,6 +4,7 @@ attack_speed: 1.3 count: 3 crit_chance: 1 damage_type: mundane +dodge: 0 faction: chivalric_order gold_cost: 0 health: 50 @@ -12,6 +13,7 @@ move_speed: 80 mundane_defence: 10 penetration: 10 range: 10 +regen: 0 size: 1 tier: 3 type: cavalier_of_the_gleaming_dawn diff --git a/data/units/caveborn.yaml b/data/units/caveborn.yaml index 7960ed5f1..ba7140543 100644 --- a/data/units/caveborn.yaml +++ b/data/units/caveborn.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: wasters gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: caveborn diff --git a/data/units/conscript_bowman.yaml b/data/units/conscript_bowman.yaml index 1c7d17110..ea32547e2 100644 --- a/data/units/conscript_bowman.yaml +++ b/data/units/conscript_bowman.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: members_of_the_court gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: conscript_bowman diff --git a/data/units/disciple_of_the_hammer.yaml b/data/units/disciple_of_the_hammer.yaml index 3e57e3811..e0f925f78 100644 --- a/data/units/disciple_of_the_hammer.yaml +++ b/data/units/disciple_of_the_hammer.yaml @@ -4,6 +4,7 @@ attack_speed: 0.7 count: 6 crit_chance: 1 damage_type: mundane +dodge: 0 faction: hammerites gold_cost: 0 health: 10 @@ -12,6 +13,7 @@ move_speed: 30 mundane_defence: 2 penetration: 10 range: 0 +regen: 0 size: 1 tier: 1 type: disciple_of_the_hammer diff --git a/data/units/earth_elemental.yaml b/data/units/earth_elemental.yaml index eadc82191..acec6ba2f 100644 --- a/data/units/earth_elemental.yaml +++ b/data/units/earth_elemental.yaml @@ -4,6 +4,7 @@ attack_speed: 0.5 count: 3 crit_chance: 1 damage_type: mundane +dodge: 0 faction: aberrations_of_nature gold_cost: 0 health: 25 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 5 penetration: 10 range: 0 +regen: 0 size: 1 tier: 1 type: earth_elemental diff --git a/data/units/gremlin.yaml b/data/units/gremlin.yaml index 99c3c0cd8..4a6b2498f 100644 --- a/data/units/gremlin.yaml +++ b/data/units/gremlin.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: swarm gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: gremlin diff --git a/data/units/harpy.yaml b/data/units/harpy.yaml index a088ddaa8..30aff7434 100644 --- a/data/units/harpy.yaml +++ b/data/units/harpy.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: wasters gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: harpy diff --git a/data/units/imp.yaml b/data/units/imp.yaml index 343ba20ae..2c2f78e3b 100644 --- a/data/units/imp.yaml +++ b/data/units/imp.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: swarm gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: imp diff --git a/data/units/infantryman.yaml b/data/units/infantryman.yaml index c3e3a76ab..d193e274b 100644 --- a/data/units/infantryman.yaml +++ b/data/units/infantryman.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 7 crit_chance: 1 damage_type: mundane +dodge: 0 faction: imperial_forces gold_cost: 0 health: 3 @@ -12,6 +13,7 @@ move_speed: 50 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: infantryman diff --git a/data/units/peasant_spearman.yaml b/data/units/peasant_spearman.yaml index 5ee4d2e6c..9b83ab183 100644 --- a/data/units/peasant_spearman.yaml +++ b/data/units/peasant_spearman.yaml @@ -4,6 +4,7 @@ attack_speed: 1.2 count: 12 crit_chance: 1 damage_type: mundane +dodge: 0 faction: members_of_the_court gold_cost: 0 health: 5 @@ -12,6 +13,7 @@ move_speed: 30 mundane_defence: 1 penetration: 10 range: 50 +regen: 0 size: 1 tier: 1 type: peasant_spearman diff --git a/data/units/skirmisher.yaml b/data/units/skirmisher.yaml index 077f2d01c..ce8166c23 100644 --- a/data/units/skirmisher.yaml +++ b/data/units/skirmisher.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 10 crit_chance: 1 damage_type: mundane +dodge: 0 faction: imperial_forces gold_cost: 0 health: 5 @@ -12,6 +13,7 @@ move_speed: 40 mundane_defence: 1 penetration: 10 range: 20 +regen: 0 size: 1 tier: 1 type: skirmisher diff --git a/data/units/thug.yaml b/data/units/thug.yaml index 00945746c..158e286f5 100644 --- a/data/units/thug.yaml +++ b/data/units/thug.yaml @@ -4,6 +4,7 @@ attack_speed: 1.0 count: 5 crit_chance: 1 damage_type: mundane +dodge: 0 faction: chivalric_order gold_cost: 0 health: 1 @@ -12,6 +13,7 @@ move_speed: 15 mundane_defence: 1 penetration: 10 range: 200 +regen: 0 size: 1 tier: 1 type: thug diff --git a/docs/api/effects/_effects.rst b/docs/api/effects/_effects.rst index a17f09332..f5e4f0e5f 100644 --- a/docs/api/effects/_effects.rst +++ b/docs/api/effects/_effects.rst @@ -6,4 +6,61 @@ Effects add_item attribute_modifier sildreths_signature + stats_effect + +Using Effects +^^^^^^^^^^^^^^^ +Effects are defined in data files and then built during play. As the state cannot be known ahead of time we must account +for this "unknowability". + +.. note:: + See the Adding New Content section for the data schema. + +At a high level, there are a few parts: + +* *Effect components, which track the changes to a stats object +* *EffectSentinel components, which can find the right targets for stats using the data from yaml +* Stat/Attribute objects, which can have multiple modifiers against the base value + +Let's take a specific example, the `StatEffect`. +StatEffects will add themselves and a modifier function to a Stat object. Whenever the value of a Stat object +is queried (stat.value), the `base_value` is computed against all modifiers. When a StatsEffect object is removed +from play, the modifier is also removed, lazily, the next time the Stat value is computed. +StatsEffectSentinels bridge the gap between the YAML and the game, and are a sort of simple query language, for +matching stats in the YAML to game entities in play. + +Creating the effect looks like this: + +.. code-block:: python + from nqp.effects.stats_effect import apply_effects, new_stats_effect + + # apply a modifier without yaml + # `stats` is the Stats component for the game entity in the ecs + + entity_id = new_stats_effect( + stat=stats.attack_speed, + stats=stats, + modifier="50%", + ) + + # for an aoe buff, you could get a list of nearby entities and apply, then remove when the distance is too great + # can be deleted, and the stat will revert + snecs.schedule_for_deletion(entity_id) + +And when creating a new entity it should look like this: + +.. code-block:: python + # when a new game entity is added to play, call `apply_effects` to search for and apply the correct effects + from nqp.effects.stats_effect import apply_effects + + entity_id = make_new_game_entity(..., ...) + apply_effects([entity_id]) + +A Word on EffectSentinels +"""""""""""""""""""""""""""""""" +The Sentinels are regular ecs components, and if removed from the ecs, will no longer apply effects to new game +entities. + +Sentinels are not required if an *`Effect` is applied directly, only if we want to use the Sentinel to apply the effect +when required. \ No newline at end of file diff --git a/docs/api/effects/stats_effect.rst b/docs/api/effects/stats_effect.rst new file mode 100644 index 000000000..6d39892e1 --- /dev/null +++ b/docs/api/effects/stats_effect.rst @@ -0,0 +1,5 @@ +Stats Effect +============================================ + +.. automodule:: nqp.effects.stats_effect + diff --git a/docs/developer/_developer.rst b/docs/developer/_developer.rst index 0e2961513..9a63ef0e2 100644 --- a/docs/developer/_developer.rst +++ b/docs/developer/_developer.rst @@ -2,8 +2,7 @@ Developer ============================================ .. toctree:: - :maxdepth: 1 + :maxdepth: 2 developer_guide - adding_new_content - todo \ No newline at end of file + adding_new_content \ No newline at end of file diff --git a/docs/developer/adding_new_content.rst b/docs/developer/adding_new_content.rst index ea3c9cbfd..bb61c6890 100644 --- a/docs/developer/adding_new_content.rst +++ b/docs/developer/adding_new_content.rst @@ -311,3 +311,167 @@ yaml Example result: - gold:100 displayed_result: "+gold" + + +Items and Traits +------------------- + +Adding New +^^^^^^^^^^^^^^^^^^^^^^^ +To add a new Item or Trait you need to add 1 thing: +1. a yaml with the items' or traits' name in their respective folder, i.e. ``data/items`` or ``data/traits`` + +yaml Explained +^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: yaml + + --- + name: Albrom's Signature Item + is_signature: true + effects: + - name: StatsEffect # name of the effect to use + target: team # matched against Allegiance.team or unit + unit_type: ranged # is matched against Allegiance.unit.type + attribute: attack # name of a stat on the Stats component + modifier: 20% # int, the amount to change by + + +yaml Example +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: yaml + + --- + name: Albrom's Signature Item + is_signature: true + effects: + - name: StatsEffect + target: team + unit_type: ranged + attribute: attack + modifier: 20% + +Parameters +^^^^^^^^^^^^ + +Name +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - ``StatsEffect`` + - Modify the Stats or Attributes of a Unit, or Resources of a Commander + - + +Target +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - all + - Affects everyone + - This means enemy and ally units/entities. + * - team + - Affects same team as affected unit/entities + - + * - unit + - Affects the unit the affected entity/unit is in + - Cannot be used is affected is commander + * - self + - Affects the affected only + - + +Unit Type +"""""""""" + +.. warning:: + This currently uses ranged/melee but elsewhere means the name of the unit. + Needs to be clarified. + +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - + - + - + +Attribute +"""""""""" + +.. note:: + See `Stats` or `Attribute` component's attrs for options. + +Modifier +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - [int] + - + - can be + (implied) or -. Appending a percent will calculate the value from the base value of the stat + + +Trigger +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - OnAttacked + - When Entity receives an attack + - + * - EnterNewRoom + - When player moves to a new room + - + + +Attack Type +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - ranged + - A ranged Unit + - + * - melee + - A Melee Unit + - + +State +"""""""""" +.. list-table:: Title + :widths: 50 50 50 + :header-rows: 1 + + * - Key + - Definition + - Additional Notes + * - true + - + - + * - false + - + - \ No newline at end of file diff --git a/nqp/core/effect.py b/nqp/base_classes/effect_processor.py similarity index 69% rename from nqp/core/effect.py rename to nqp/base_classes/effect_processor.py index fe8948517..46ef88fda 100644 --- a/nqp/core/effect.py +++ b/nqp/base_classes/effect_processor.py @@ -1,22 +1,18 @@ from __future__ import annotations import logging +from abc import ABC from typing import TYPE_CHECKING -from snecs import RegisteredComponent - if TYPE_CHECKING: from nqp.core.game import Game log = logging.getLogger(__name__) - -class EffectProcessorComponent(RegisteredComponent): - def __init__(self, effect: EffectProcessor): - self.effect = effect +__all__ = ["EffectProcessor"] -class EffectProcessor: +class EffectProcessor(ABC): def update(self, time_delta: float, game: Game): """ Handle changes for this Processor diff --git a/nqp/base_classes/entity_behaviour.py b/nqp/base_classes/entity_behaviour.py index b3ff47f3a..c49c84d87 100644 --- a/nqp/base_classes/entity_behaviour.py +++ b/nqp/base_classes/entity_behaviour.py @@ -6,6 +6,8 @@ import pygame +from nqp.core.constants import PATH_UPDATE_FREQ + if TYPE_CHECKING: from typing import List, Optional, Tuple @@ -16,8 +18,6 @@ __all__ = ["EntityBehaviour"] -PATH_UPDATE_FREQ = 0.4 # TODO - move to constants - class EntityBehaviour(ABC): def __init__(self, game: Game, unit: Unit, entity: EntityID): @@ -33,6 +33,7 @@ def __init__(self, game: Game, unit: Unit, entity: EntityID): self.target_position: Optional[pygame.Vector2] = None self.visibility_line: bool = False self.attack_timer: float = 0 + self.regen_timer: float = 0 self.is_active: bool = True # update flags diff --git a/nqp/base_classes/resource_controller.py b/nqp/base_classes/resource_controller.py index fba2ea7a9..c78d3e8a8 100644 --- a/nqp/base_classes/resource_controller.py +++ b/nqp/base_classes/resource_controller.py @@ -1,8 +1,16 @@ +from __future__ import annotations + import weakref -from typing import Callable +from abc import ABC +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + +__all__ = ["ResourceController"] -class ResourceController: +class ResourceController(ABC): """ Base class with a general specification for lazily loading game resources. diff --git a/nqp/base_classes/stat.py b/nqp/base_classes/stat.py index e6acd3704..f57e26f9b 100644 --- a/nqp/base_classes/stat.py +++ b/nqp/base_classes/stat.py @@ -12,11 +12,11 @@ class Stat(ABC): """ - A container for an Entities Stat + A container for an Entities Stat and related functionality. - Overrider value is a hack until all the ad-hoc changes without - modifiers are removed. To be clear, we should be using effects to - change the value, not setting directly. + `base_value` is used as the reference for modifiers. + `value` is the result after modifiers are applied. + `override` forces a specific value to be used, ignoring modifiers. """ @@ -28,7 +28,7 @@ def __init__(self, base_value): def reset(self): """ - Set value back to base value. + Remove any modifiers and override. """ self._override_value = None self._modifiers.clear() diff --git a/nqp/command/basic_entity_behaviour.py b/nqp/command/basic_entity_behaviour.py index 615e5d7ab..dcd5650f5 100644 --- a/nqp/command/basic_entity_behaviour.py +++ b/nqp/command/basic_entity_behaviour.py @@ -7,16 +7,14 @@ from nqp.base_classes.entity_behaviour import EntityBehaviour from nqp.command.unit import Unit -from nqp.core.components import Allegiance, IsDead, IsReadyToAttack, Position, Stats +from nqp.core.constants import HealingSource, PATH_UPDATE_FREQ from nqp.core.utility import distance_to +from nqp.world_elements.entity_components import Allegiance, HealReceived, IsDead, IsReadyToAttack, Position, Stats if TYPE_CHECKING: from nqp.core.game import Game -PATH_UPDATE_FREQ = 0.4 # TODO - move to consts - - __all__ = ["BasicEntityBehaviour"] @@ -48,8 +46,14 @@ def update(self, delta_time: float): if self.last_path_update > PATH_UPDATE_FREQ: self.update_path() - # update attack timer + # apply and reset regen, if needed + if self.regen_timer < 0: + self.apply_regen() + self.regen_timer = 1 + + # update timers self.attack_timer -= delta_time + self.regen_timer -= delta_time def determine_next_action(self, focus_entity: bool): """ @@ -133,3 +137,17 @@ def update_path(self): if self.target_position: pos = snecs.entity_component(self._entity, Position) self.current_path = self._game.world.model.terrain.pathfind_px(pos.pos, self.target_position) + + def apply_regen(self): + """ + Create heal component on entity + """ + stats = snecs.entity_component(self._entity, Stats) + + # try to apply + try: + snecs.add_component(self._entity, HealReceived(stats.regen.value, HealingSource.SELF)) + + except ValueError: + heal_received = snecs.entity_component(self._entity, HealReceived) + heal_received.add_heal(stats.regen.value, HealingSource.SELF) diff --git a/nqp/command/troupe.py b/nqp/command/troupe.py index 60e13e18d..0dfa94724 100644 --- a/nqp/command/troupe.py +++ b/nqp/command/troupe.py @@ -118,10 +118,11 @@ def generate_units( duplicates: bool = False, ) -> List[int]: """ - Generate units for the Troupe, based on parameters given. If no unit types are given then any unit type can - be chosen from any ally. Returns list of created ids. + Generate units for the Troupe, based on parameters given. + + Returns: + list of created ids. - unit_types is expressed as [unit.type, ...] """ unit_types = [] diff --git a/nqp/command/unit.py b/nqp/command/unit.py index 9074d4298..0d315cdae 100644 --- a/nqp/command/unit.py +++ b/nqp/command/unit.py @@ -68,6 +68,8 @@ def __init__(self, game: Game, id_: int, unit_type: str, team: str, pos: pygame. self.move_speed: int = unit_data["move_speed"] + base_values["move_speed"] self.crit_chance: int = unit_data["crit_chance"] + base_values["crit_chance"] self.penetration: int = unit_data["penetration"] + base_values["penetration"] + self.regen: int = unit_data["regen"] + base_values["regen"] + self.dodge: int = unit_data["dodge"] + base_values["dodge"] # ensure faux-null value is respected if unit_data["ammo"] in [-1, 0]: @@ -96,7 +98,7 @@ def update(self, delta_time: float): @property def is_alive(self): - from nqp.core.components import IsDead # prevent circular import + from nqp.world_elements.entity_components import IsDead # prevent circular import for entity in self.entities: if not snecs.has_component(entity, IsDead): @@ -152,7 +154,15 @@ def spawn_entities(self): Spawn the Unit's Entities. Deletes any existing Entities first. """ # prevent circular import error - from nqp.core.components import Aesthetic, AI, Allegiance, Position, RangedAttack, Resources, Stats + from nqp.world_elements.entity_components import ( + Aesthetic, + AI, + Allegiance, + Attributes, + Position, + RangedAttack, + Stats, + ) self.delete_entities() @@ -161,9 +171,9 @@ def spawn_entities(self): components = [ Position(self.pos), Aesthetic(self._game.visual.create_animation(self.type, "idle")), - Resources(self.health), Stats(self), Allegiance(self.team, self), + Attributes(), ] # conditional components @@ -202,16 +212,13 @@ def reset_for_combat(self): Reset the in combat values ready to begin combat. """ # prevent circular import - from nqp.core.components import DamageReceived, IsDead, IsReadyToAttack, RangedAttack, Resources, Stats + from nqp.world_elements.entity_components import DamageReceived, IsDead, IsReadyToAttack, RangedAttack, Stats # get stat attrs stat_attrs = Stats.get_stat_names() health = self.health for entity in self.entities: - # heal to full - resources = snecs.entity_component(entity, Resources) - resources.health = health # remove flags if snecs.has_component(entity, IsDead): @@ -230,7 +237,7 @@ def reset_for_combat(self): if snecs.has_component(entity, Stats): stats = snecs.entity_component(entity, Stats) for stat_name in stat_attrs: - getattr(stats, stat_name).reset() + getattr(stats, stat_name).base_value = getattr(self, stat_name) self._align_entity_positions_to_unit() @@ -238,7 +245,7 @@ def update_position(self): """ Update unit position by averaging the positions of all its entities. """ - from nqp.core.components import Position # prevent circular import + from nqp.world_elements.entity_components import Position # prevent circular import num_entities = len(self.entities) if num_entities > 0: @@ -257,7 +264,7 @@ def set_position(self, pos: pygame.Vector2): self._align_entity_positions_to_unit() def _align_entity_positions_to_unit(self): - from nqp.core.components import Position # prevent circular import + from nqp.world_elements.entity_components import Position # prevent circular import unit_x = self.pos.x unit_y = self.pos.y diff --git a/nqp/command/unit_behaviour.py b/nqp/command/unit_behaviour.py index 30c322769..bf7e2dbf6 100644 --- a/nqp/command/unit_behaviour.py +++ b/nqp/command/unit_behaviour.py @@ -6,12 +6,12 @@ import pygame import snecs -from nqp.core.components import AI, Allegiance, IsDead, Position from nqp.core.constants import TILE_SIZE from nqp.core.utility import distance_to +from nqp.world_elements.entity_components import AI, Allegiance, IsDead, Position if TYPE_CHECKING: - from typing import List, Optional, Tuple + from typing import List, Optional from snecs.typedefs import EntityID diff --git a/nqp/core/assets.py b/nqp/core/assets.py index 13ecc88b0..3d9949fec 100644 --- a/nqp/core/assets.py +++ b/nqp/core/assets.py @@ -254,7 +254,7 @@ def _load_images() -> Dict[str, Dict[str, pygame.Surface]]: images = {} # specify folders in assets that need to be loaded - folders = ["rooms", "stats", "ui"] + folders = ["rooms"] for folder in folders: path = ASSET_PATH / folder diff --git a/nqp/core/audio.py b/nqp/core/audio.py index c1626bb55..d26bdbaa8 100644 --- a/nqp/core/audio.py +++ b/nqp/core/audio.py @@ -7,7 +7,7 @@ import pygame -from nqp.core.constants import ASSET_PATH +from nqp.core.constants import ASSET_PATH, INFINITE from nqp.core.debug import Timer if TYPE_CHECKING: @@ -69,7 +69,15 @@ def play_sound( allow_duplicates: bool = True, ): """ - Play sound. Set loops = -1 to make the sound loop infinitely. Can still be stopped. + Play sound. + + Args: + sound_name: the name of the sound + loops: how many loops. set to INFINITE to make the sound loop infinitely. Can still be stopped. + max_time: how long the sound can play for. -1 means until end. + fade_in_ms: how long the sound will take to get to full volume. -1 means start at full. + allow_duplicates: when the same sound can be played while this is still active. + """ # check if currently blocked as unique if sound_name in self._unique_sounds.keys(): @@ -83,6 +91,8 @@ def play_sound( max_time = 0 if fade_in_ms == -1: fade_in_ms = 0 + if loops == INFINITE: + loops = -1 sound.play(loops, max_time, fade_in_ms) diff --git a/nqp/core/constants.py b/nqp/core/constants.py index 3b7338bd2..978b972c6 100644 --- a/nqp/core/constants.py +++ b/nqp/core/constants.py @@ -28,6 +28,9 @@ PUSH_FORCE = 14 CRIT_MOD = 2.5 # value to multiply by +# ai +PATH_UPDATE_FREQ = 0.4 + # UI customisation TEXT_FADE_OUT_SPEED = 0.5 # make sure it is slower than the fade in TEXT_FADE_IN_SPEED = 4 # font messes up if this is greater than 4 @@ -198,12 +201,17 @@ class DamageType(IntEnum): class Colour(Enum): + # basics WHITE = (255, 255, 255) BLACK = (0, 0, 0) RED = (255, 0, 0) GREEN = (0, 255, 0) BLUE = (0, 0, 255) + # specifics + BLOOD_RED = (143, 3, 3) + GREY_SMOKE = (63, 58, 71) + class ColourPalette(Enum): MAIN_DARK = (12, 13, 12) # black @@ -223,3 +231,8 @@ class TextRelativePosition(IntEnum): BELOW_IMAGE = auto() RIGHT_OF_IMAGE = auto() LEFT_OF_IMAGE = auto() + + +class HealingSource(IntEnum): + SELF = auto() + OTHER = auto() diff --git a/nqp/core/data.py b/nqp/core/data.py index b8843bb1a..e9cbbfe5c 100644 --- a/nqp/core/data.py +++ b/nqp/core/data.py @@ -246,9 +246,8 @@ def _load_items() -> Dict[str:ItemData]: @staticmethod def _load_effects() -> Dict[str:Any]: # TODO: replace with autodiscover - from nqp.effects.add_item import AddItemEffect + from nqp.effects.effect_components import AddItemEffect, StatsEffectSentinel from nqp.effects.sildreths_signature import SildrethsSignatureEffect - from nqp.effects.stats_effect import StatsEffectSentinel effects = { "StatsEffect": StatsEffectSentinel, @@ -258,9 +257,8 @@ def _load_effects() -> Dict[str:Any]: logging.debug(f"Data: {len(effects)} items loaded.") # TODO: replace with autodiscover - from nqp.core.effect import EffectProcessorComponent from nqp.effects.burn import OnFireStatusProcessor - from nqp.effects.stats_effect import StatsEffectProcessor + from nqp.effects.processors import EffectProcessorComponent, StatsEffectProcessor for processor_class in ( StatsEffectProcessor, diff --git a/nqp/core/game.py b/nqp/core/game.py index ca08dcb0a..36c551dec 100644 --- a/nqp/core/game.py +++ b/nqp/core/game.py @@ -31,7 +31,6 @@ def __init__(self): from nqp.core.memory import Memory from nqp.scenes.main_menu.scene import MainMenuScene from nqp.scenes.run_setup.scene import RunSetupScene - from nqp.scenes.view_troupe.scene import ViewTroupeScene from nqp.scenes.world.scene import WorldScene # init libraries @@ -58,7 +57,6 @@ def __init__(self): # TODO - should these be private? self.main_menu: MainMenuScene = MainMenuScene(self) self.run_setup: RunSetupScene = RunSetupScene(self) - self.troupe: ViewTroupeScene = ViewTroupeScene(self) self.world: WorldScene = WorldScene(self) # dev scenes diff --git a/nqp/core/queries.py b/nqp/core/queries.py index f80c2dbcb..704e6e439 100644 --- a/nqp/core/queries.py +++ b/nqp/core/queries.py @@ -4,54 +4,88 @@ from snecs import Query -from nqp.core.components import Aesthetic, AI, DamageReceived, IsDead, IsReadyToAttack, Position, Resources, Stats -from nqp.core.effect import EffectProcessorComponent +from nqp.effects.effect_components import StatsEffect, StatsEffectSentinel +from nqp.effects.processors import EffectProcessorComponent +from nqp.world_elements.entity_components import ( + Aesthetic, + AI, + Attributes, + DamageReceived, + HealReceived, + IsDead, + IsReadyToAttack, + Position, + Stats, +) if TYPE_CHECKING: - from typing import Dict, Iterator, List, Optional, Tuple, Union + from typing import Iterator, Tuple from snecs.typedefs import EntityID __all__ = [ - "dead", - "resources", "aesthetic_position", - "damage_resources_aesthetic_stats", "ai_not_dead", - "attack_position_stats_ai_aesthetic_not_dead", "ai_position", + "attack_position_stats_ai_aesthetic_not_dead", + "damage_aesthetic_stats", + "dead", + "dead_aesthetic_position", + "effect_stats_query", + "effects_processors", + "heal_stats_attributes_not_dead", + "position", + "position_stats_not_dead", + "position_stats_ai_aesthetic_not_dead", + "sentinels_query", + "stats_query", ] -resources: Iterator[Tuple[EntityID, Tuple[Resources]]] = Query([Resources]).compile() +heal_stats_attributes_not_dead: Iterator[Tuple[EntityID, Tuple[HealReceived, Stats, Attributes]]] +heal_stats_attributes_not_dead = Query([HealReceived, Stats, Attributes]).filter(~IsDead).compile() -dead: Iterator[Tuple[EntityID, Tuple[IsDead]]] = Query([IsDead]).compile() +dead: Iterator[Tuple[EntityID, Tuple[IsDead]]] +dead = Query([IsDead]).compile() -position: Iterator[Tuple[EntityID, Tuple[Position]]] = Query([Position]).compile() +position: Iterator[Tuple[EntityID, Tuple[Position]]] +position = Query([Position]).compile() -ai_position: Iterator[Tuple[EntityID, Tuple[AI, Position]]] = Query([AI, position]).compile() +ai_position: Iterator[Tuple[EntityID, Tuple[AI, Position]]] +ai_position = Query([AI, position]).compile() -ai_not_dead: Iterator[Tuple[EntityID, Tuple[AI]]] = Query([AI]).filter(~IsDead).compile() +ai_not_dead: Iterator[Tuple[EntityID, Tuple[AI]]] +ai_not_dead = Query([AI]).filter(~IsDead).compile() -aesthetic_position: Iterator[Tuple[EntityID, Tuple[Aesthetic, Position]]] = Query([Aesthetic, Position]).compile() +aesthetic_position: Iterator[Tuple[EntityID, Tuple[Aesthetic, Position]]] +aesthetic_position = Query([Aesthetic, Position]).compile() -damage_resources_aesthetic_stats: Iterator[Tuple[EntityID, Tuple[DamageReceived, Resources, Aesthetic, Stats]]] = Query( - [DamageReceived, Resources, Aesthetic, Stats] -).compile() +damage_aesthetic_stats: Iterator[Tuple[EntityID, Tuple[DamageReceived, Aesthetic, Stats]]] +damage_aesthetic_stats = Query([DamageReceived, Aesthetic, Stats]).compile() -dead_aesthetic_position: Iterator[Tuple[EntityID, Tuple[IsDead, Aesthetic, Position]]] = Query( - [IsDead, Aesthetic, Position] -).compile() +dead_aesthetic_position: Iterator[Tuple[EntityID, Tuple[IsDead, Aesthetic, Position]]] +dead_aesthetic_position = Query([IsDead, Aesthetic, Position]).compile() -position_stats_not_dead: Iterator[Tuple[EntityID, Tuple[Position, Stats]]] = ( - Query([Position, Stats]).filter(~IsDead).compile() -) +position_stats_not_dead: Iterator[Tuple[EntityID, Tuple[Position, Stats]]] +position_stats_not_dead = Query([Position, Stats]).filter(~IsDead).compile() -position_stats_ai_aesthetic_not_dead: Iterator[Tuple[EntityID, Tuple[Position, Stats, AI, Aesthetic]]] = ( - Query([Position, Stats, AI, Aesthetic]).filter(~IsDead).compile() -) +position_stats_ai_aesthetic_not_dead: Iterator[Tuple[EntityID, Tuple[Position, Stats, AI, Aesthetic]]] +position_stats_ai_aesthetic_not_dead = Query([Position, Stats, AI, Aesthetic]).filter(~IsDead).compile() attack_position_stats_ai_aesthetic_not_dead: Iterator[ Tuple[EntityID, Tuple[IsReadyToAttack, Position, Stats, AI, Aesthetic]] -] = (Query([IsReadyToAttack, Position, Stats, AI, Aesthetic]).filter(~IsDead).compile()) +] +attack_position_stats_ai_aesthetic_not_dead = ( + Query([IsReadyToAttack, Position, Stats, AI, Aesthetic]).filter(~IsDead).compile() +) + +effects_processors: Iterator[Tuple[EntityID, Tuple[EffectProcessorComponent]]] +effects_processors = Query([EffectProcessorComponent]).compile() + +stats_query: Iterator[Tuple[EntityID, Tuple[Stats]]] +stats_query = Query([Stats]).compile() + +effect_stats_query: Iterator[Tuple[EntityID, Tuple[StatsEffect, Stats]]] +effect_stats_query = Query([StatsEffect, Stats]).compile() -effects_processors: Iterator[Tuple[EntityID, Tuple[EffectProcessorComponent]]] = Query([EffectProcessorComponent]) +sentinels_query: Iterator[Tuple[EntityID, Tuple[StatsEffectSentinel]]] +sentinels_query = Query([StatsEffectSentinel]).compile() diff --git a/nqp/core/systems.py b/nqp/core/systems.py index e8669bf01..6d511e55f 100644 --- a/nqp/core/systems.py +++ b/nqp/core/systems.py @@ -9,22 +9,31 @@ import snecs from nqp.core import queries -from nqp.core.components import ( - Aesthetic, +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, ) -from nqp.core.constants import CRIT_MOD, DamageType, EntityFacing, Flags, PUSH_FORCE, TILE_SIZE, WEIGHT_SCALE -from nqp.core.utility import angle_to, distance_to, get_direction if TYPE_CHECKING: - from typing import Dict, List, Optional, Tuple, Union + from typing import List from nqp.core.game import Game @@ -57,8 +66,9 @@ def draw_entities(surface: pygame.Surface, shift: pygame.Vector2 = (0, 0)): 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, resources, aesthetic, stats) in queries.damage_resources_aesthetic_stats: + for entity, (damage, aesthetic, stats) in queries.damage_aesthetic_stats: damage_dealt = damage.amount # get defence @@ -76,34 +86,39 @@ def apply_damage(game: Game): # mitigate damage by defence, accounting for penetration damage_dealt = max((defence.value - damage.penetration) - damage_dealt, 0) - # reduce defence for being hit - defence.value = max(defence.value - 1, 0) + # calc dodge + dodge_successful = False + if game.rng.roll() <= stats.dodge.value: + dodge_successful = True - # apply damage - resources.health.value -= damage_dealt + # apply hit effects if no dodge + if dodge_successful: + # reduce defence for being hit + defence.base_value = max(defence.value - 1, 0) - # remove damage flag - snecs.remove_component(entity, DamageReceived) + # apply damage + stats.health.base_value -= damage_dealt - # check if dead - if resources.health.value <= 0: - snecs.add_component(entity, IsDead()) - else: - # apply flash - aesthetic.animation.flash((255, 255, 255)) + # 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() - # create blood spray on crit - if damage.is_crit: - position = snecs.entity_component(entity, Position) - create_particle_burst = game.world.model.particles.create_particle_burst - create_particle_burst(position.pos, (255, 50, 100), random.randint(10, 16)) + # remove damage flag + snecs.remove_component(entity, DamageReceived) def process_death(game: Game): """ Update Entity's sprites and intentions. """ - create_particle_burst = game.world.model.particles.create_particle_burst for entity, (dead, aesthetic, position) in queries.dead_aesthetic_position: @@ -112,7 +127,7 @@ def process_death(game: Game): aesthetic.animation.delete_on_finish = False aesthetic.animation.loop = False - create_particle_burst(position.pos, (255, 50, 100), random.randint(10, 16)) + game.world.model.particles.create_blood_spray(position.pos) def process_movement(delta_time: float, game: Game): @@ -273,13 +288,13 @@ def process_attack(game: Game): # handle ranged attack if snecs.has_component(entity, RangedAttack): ranged = snecs.entity_component(entity, RangedAttack) - ranged.ammo.value -= 1 + 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.value = 0 + stats.range.override(0) else: # add damage component @@ -335,3 +350,20 @@ def push_entities_away_from_one_another(delta_time: float, game: Game): ) movement = get_direction(other_angle, move_distance) _move(movement, position, game) + + +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) diff --git a/nqp/core/visual.py b/nqp/core/visual.py index adecd7558..f5967b582 100644 --- a/nqp/core/visual.py +++ b/nqp/core/visual.py @@ -40,13 +40,15 @@ def __init__(self, game: Game): "items", "projectiles", "rooms", - "stats", "tiles", "ui/backgrounds", + "ui/borders", "ui/cursors", "ui/icons", "ui/keys", - "ui/widgets", + "ui/records", + "ui/resources", + "ui/stats", "ui/windows/basic", "ui/windows/fancy", "upgrades", diff --git a/nqp/effects/actions.py b/nqp/effects/actions.py new file mode 100644 index 000000000..d3747dd9e --- /dev/null +++ b/nqp/effects/actions.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import operator +from functools import partial +from typing import TYPE_CHECKING + +import snecs +from snecs.typedefs import EntityID + +from nqp.base_classes.stat import Stat +from nqp.core.constants import INFINITE +from nqp.core.utility import percent_to_float +from nqp.world_elements.entity_components import Stats + +if TYPE_CHECKING: + from typing import List + + +def get_modifier(string: str): + if string.endswith("%"): + return partial(operator.mul, percent_to_float(string)) + else: + string = string.lstrip("+") + return lambda x: float(string) + + +def new_stats_effect( + stat: Stat, + stats: Stats, + modifier: str, + ttl: float = INFINITE, +) -> EntityID: + """ + Apply StatsEffect to Stat + + Args: + stat: Stat instance to modify + stats: Stats Component containing ``stat`` + modifier: Modifier string; "50%", "-50", "-200%", etc + ttl: Time To Live + + """ + modifier = get_modifier(modifier) + from nqp.effects.effect_components import StatsEffect + + attrib_modifier = StatsEffect(stat, ttl) + eid = snecs.new_entity((attrib_modifier, stats)) + stat.apply_modifier(modifier, attrib_modifier) + return eid + + +def apply_effects(entities: List[EntityID]): + """ + Enable effects for entity by checking the sentinels. + + Should be called whenever a new entity or sentinel is created. + + New entities may be created after an effect was started. This can + be used to search for effects that would affect the entity and apply + them. + + """ + # TODO: get components from sentinel and batch search + from nqp.world_elements.entity_components import Allegiance, Stats + + search_components = (Allegiance, Stats) + from nqp.core.queries import sentinels_query + + for sentinel_eid, (sentinel,) in sentinels_query: + for eid in entities: + components = snecs.entity_components(eid, search_components) + sentinel.maybe_apply(components[Allegiance], components[Stats]) diff --git a/nqp/effects/add_item.py b/nqp/effects/add_item.py deleted file mode 100644 index 21308fb83..000000000 --- a/nqp/effects/add_item.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TYPE_CHECKING - -import snecs -from snecs import RegisteredComponent - -from nqp.core.effect import EffectProcessor - -if TYPE_CHECKING: - from nqp.core.game import Game - - -class AddItemEffect(RegisteredComponent): - def __init__(self, item_type: str, item_count: int, trigger=None): - self.item_type = item_type - self.item_count: int = item_count - self.trigger = trigger - - @classmethod - def from_dict(cls, data: Dict[str, str], params: Dict[str:Any]): - """ - Return new instance using data loaded from a file - - """ - item_type = data.get("item_type") - if item_type not in ["gold"]: - raise ValueError(f"Unsupported item_type {item_type}") - trigger = data.get("trigger") - if trigger not in ["EnterNewRoom"]: - raise ValueError(f"Unsupported unit_type {trigger}") - item_count = int(data.get("item_count")) - return cls(item_type, item_count, trigger) - - -class AddItemEffectProcessor(EffectProcessor): - queued_items = snecs.Query([AddItemEffect]) - - def update(self, time_delta: float, game: Game): - for eid, (comp,) in list(AddItemEffectProcessor.queued_items): - pass diff --git a/nqp/effects/burn.py b/nqp/effects/burn.py index 7180e9563..d7131dd1f 100644 --- a/nqp/effects/burn.py +++ b/nqp/effects/burn.py @@ -11,7 +11,7 @@ import snecs from snecs import RegisteredComponent -from nqp.core.effect import EffectProcessor +from nqp.base_classes.effect_processor import EffectProcessor if TYPE_CHECKING: from nqp.core.game import Game diff --git a/nqp/effects/effect_components.py b/nqp/effects/effect_components.py new file mode 100644 index 000000000..ffac43d34 --- /dev/null +++ b/nqp/effects/effect_components.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import uuid +from typing import Any, Dict, Optional + +import snecs +from snecs import RegisteredComponent + +from nqp.base_classes.stat import Stat +from nqp.core.constants import INFINITE +from nqp.effects.actions import get_modifier +from nqp.world_elements.entity_components import Allegiance, Stats + +__all__ = ["AddItemEffect", "StatsEffect", "StatsEffectSentinel"] + + +class AddItemEffect(RegisteredComponent): + def __init__(self, item_type: str, item_count: int, trigger=None): + self.item_type = item_type + self.item_count: int = item_count + self.trigger = trigger + + @classmethod + def from_dict(cls, data: Dict[str, str], params: Dict[str:Any]): + """ + Return new instance using data loaded from a file + + """ + item_type = data.get("item_type") + if item_type not in ["gold"]: + raise ValueError(f"Unsupported item_type {item_type}") + trigger = data.get("trigger") + if trigger not in ["EnterNewRoom"]: + raise ValueError(f"Unsupported unit_type {trigger}") + item_count = int(data.get("item_count")) + return cls(item_type, item_count, trigger) + + +class StatsEffectSentinel(RegisteredComponent): + """ + Fancy way to search for targets of an effect + + """ + + def __init__( + self, + target: str, + unit_type: str, + attribute: str, + modifier: str, + params: Optional[Dict[str:Any]] = None, + ttl: float = INFINITE, + ): + if params is None: + params = dict() + self.target = target + self.unit_type = unit_type + self.attribute = attribute + self.modifier = get_modifier(modifier) + self.params = params + self.ttl = ttl + self.key = uuid.uuid4() + + @classmethod + def from_dict(cls, data: Dict[str, str], params: Dict): + """ + Return new instance using data loaded from a file + + Args: + data: Dictionary of generic params, probably from a file + params: Dictionary of data unique to the context + + For params, consider an effect which would affect the users + "team". We cannot know which team that is in the data files, + since it is only known when the effect is created. So the + ``params`` dictionary is required to get the team value when + the effect is created. See ``maybe_apply``. + + """ + target = data.get("target") + unit_type = data.get("unit_type") + attribute = data.get("attribute") + modifier = data.get("modifier") + ttl = float(data.get("ttl", INFINITE)) + + if target not in ("all", "game", "self", "team", "unit"): + raise ValueError(f"Unsupported target {target}") + if unit_type not in ("all", "ranged"): + raise ValueError(f"Unsupported unit_type {unit_type}") + + return cls(target, unit_type, attribute, modifier, params, ttl) + + def maybe_apply(self, allegiance: Allegiance, stats: Stats): + """ + Match and test modifier. Apply if needed. + + """ + if self.target == "all": + pass + elif self.target == "team" and allegiance.team != self.params["team"]: + return False + elif self.target == "unit" and allegiance.unit != self.params["unit"]: + return False + if self.unit_type == "all": + pass + elif self.unit_type != allegiance.unit.type: + return False + stat = getattr(stats, self.attribute, None) + if stat is None or not isinstance(stat, Stat): + raise ValueError(f"Unsupported attribute {self.attribute}") + if not stat.has_modifier(self.key): + stat.apply_modifier(self.modifier, self.key) + attrib_modifier = StatsEffect(stat) + snecs.new_entity((attrib_modifier, stats)) + + +class StatsEffect(RegisteredComponent): + """ + Currently, only modifying ``Stats`` components are supported + + Args: + stat: Stat instance on the Stats component + ttl: Time To Live + + ttl: + INFINITE : never removed + 0 : runs once + > 0 : lasts X seconds + + """ + + def __init__(self, stat: Any, ttl: float = INFINITE): + self.stat: Any = stat + self.ttl: float = ttl diff --git a/nqp/effects/processors.py b/nqp/effects/processors.py new file mode 100644 index 000000000..1187d354f --- /dev/null +++ b/nqp/effects/processors.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import snecs +from snecs import RegisteredComponent + +from nqp.base_classes.effect_processor import EffectProcessor +from nqp.core.constants import INFINITE +from nqp.effects.effect_components import AddItemEffect + +if TYPE_CHECKING: + from nqp.core.game import Game + + +__all__ = ["EffectProcessorComponent", "AddItemEffectProcessor", "StatsEffectProcessor"] + + +class EffectProcessorComponent(RegisteredComponent): + def __init__(self, effect: EffectProcessor): + self.effect = effect + + +class AddItemEffectProcessor(EffectProcessor): + queued_items = snecs.Query([AddItemEffect]) + + def update(self, time_delta: float, game: Game): + for eid, (comp,) in list(AddItemEffectProcessor.queued_items): + pass + + +class StatsEffectProcessor(EffectProcessor): + """ + Processor for Effects + + * Remove expired effects + + """ + + def update(self, time_delta: float, game: Game): + from nqp.core.queries import effect_stats_query + + for eid, (effect, stats) in effect_stats_query: + # remove expired modifiers + if effect.ttl == INFINITE: + continue + if effect.ttl >= 0: + effect.ttl -= time_delta + if effect.ttl <= 0: + effect.stat.remove_modifier(effect) + snecs.schedule_for_deletion(eid) diff --git a/nqp/effects/sildreths_signature.py b/nqp/effects/sildreths_signature.py index aa9ab260a..f36e6a85d 100644 --- a/nqp/effects/sildreths_signature.py +++ b/nqp/effects/sildreths_signature.py @@ -1,4 +1,4 @@ -from nqp.core.effect import EffectProcessor +from nqp.base_classes.effect_processor import EffectProcessor class SildrethsSignatureEffect(EffectProcessor): diff --git a/nqp/effects/stats_effect.py b/nqp/effects/stats_effect.py deleted file mode 100644 index 17163c981..000000000 --- a/nqp/effects/stats_effect.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -import operator -import uuid -from functools import partial -from typing import TYPE_CHECKING - -import snecs -from snecs.typedefs import EntityID - -from nqp.base_classes.stat import Stat -from nqp.core.components import Allegiance, Stats -from nqp.core.effect import EffectProcessor -from nqp.core.utility import percent_to_float - -if TYPE_CHECKING: - from typing import Any, Dict, Iterator, List, Optional, Tuple - - from nqp.core.game import Game - - -def get_modifier(string: str): - if string.endswith("%"): - return partial(operator.mul, percent_to_float(string)) - else: - string = string.lstrip("+") - return lambda x: float(string) - - -class StatsEffect(snecs.RegisteredComponent): - """ - Currently, only modifying ``Stats`` components are supported - - Args: - stat: Stat instance on the Stats component - ttl: Time To Live - - ttl: - -1 : never removed - 0 : runs once - > 0 : lasts X seconds - - """ - - def __init__(self, stat: Any, ttl: float = -1): - self.stat: Any = stat - self.ttl: float = ttl - - -class StatsEffectSentinel(snecs.RegisteredComponent): - """ - Fancy way to search for targets of an effect - - """ - - def __init__( - self, - target: str, - unit_type: str, - attribute: str, - modifier: str, - params: Optional[Dict[str:Any]] = None, - ttl: float = -1, - ): - if params is None: - params = dict() - self.target = target - self.unit_type = unit_type - self.attribute = attribute - self.modifier = get_modifier(modifier) - self.params = params - self.ttl = ttl - self.key = uuid.uuid4() - - @classmethod - def from_dict(cls, data: Dict[str, str], params: Dict): - """ - Return new instance using data loaded from a file - - Args: - data: Dictionary of generic params, probably from a file - params: Dictionary of data unique to the context - - For params, consider an effect which would affect the users - "team". We cannot know which team that is in the data files, - since it is only known when the effect is created. So the - ``params`` dictionary is required to get the team value when - the effect is created. See ``maybe_apply``. - - """ - target = data.get("target") - unit_type = data.get("unit_type") - attribute = data.get("attribute") - modifier = data.get("modifier") - ttl = float(data.get("ttl", -1)) - - if target not in ("all", "game", "self", "team", "unit"): - raise ValueError(f"Unsupported target {target}") - if unit_type not in ("all", "ranged"): - raise ValueError(f"Unsupported unit_type {unit_type}") - - return cls(target, unit_type, attribute, modifier, params, ttl) - - def maybe_apply(self, allg: Allegiance, stats: Stats): - """ - Match and test modifier. Apply if needed. - - """ - if self.target == "all": - pass - elif self.target == "team" and allg.team != self.params["team"]: - return False - elif self.target == "unit" and allg.unit != self.params["unit"]: - return False - if self.unit_type == "all": - pass - elif self.unit_type != allg.unit.type: - return False - stat = getattr(stats, self.attribute, None) - if stat is None or not isinstance(stat, Stat): - raise ValueError(f"Unsupported attribute {self.attribute}") - if not stat.has_modifier(self.key): - stat.apply_modifier(self.modifier, self.key) - attrib_modifier = StatsEffect(stat) - snecs.new_entity((attrib_modifier, stats)) - - -def new_stats_effect( - stat: Stat, - stats: Stats, - modifier: str, - ttl: float = -1, -) -> EntityID: - """ - Apply StatsEffect to Stat - - Args: - stat: Stat instance to modify - stats: Stats Component containing ``stat`` - modifier: Modifier string; "50%", "-50". "-200%", etc - ttl: Time To Live - - """ - modifier = get_modifier(modifier) - attrib_modifier = StatsEffect(stat, ttl) - eid = snecs.new_entity((attrib_modifier, stats)) - stat.apply_modifier(modifier, attrib_modifier) - return eid - - -stats_query: Iterator[Tuple[EntityID, Tuple[Stats]]] -stats_query = snecs.Query([Stats]) -effect_stats_query: Iterator[Tuple[EntityID, Tuple[StatsEffect, Stats]]] -effect_stats_query = snecs.Query([StatsEffect, Stats]) -sentinels_query: Iterator[Tuple[EntityID, Tuple[StatsEffectSentinel]]] -sentinels_query = snecs.Query([StatsEffectSentinel]) - - -def apply_effects(entities: List[EntityID]): - """ - Enable effects for entity by checking the sentinels - - New entities may be created after an effect was started. This can - be used to search for effects that would affect the entity and apply - them. - - """ - # TODO: get components from sentinel and batch search - search_components = (Allegiance, Stats) - for sentinel_eid, (sentinel,) in sentinels_query: - for eid in entities: - components = snecs.entity_components(eid, search_components) - sentinel.maybe_apply(components[Allegiance], components[Stats]) - - -class StatsEffectProcessor(EffectProcessor): - """ - Processor for Effects - - * Remove expired effects - - """ - - def update(self, time_delta: float, game: Game): - for eid, (effect, stats) in effect_stats_query: - # remove expired modifiers - if effect.ttl == -1: - continue - if effect.ttl >= 0: - effect.ttl -= time_delta - if effect.ttl <= 0: - effect.stat.remove_modifier(effect) - snecs.schedule_for_deletion(eid) diff --git a/nqp/resource_controllers/image_resource_controller.py b/nqp/resource_controllers/image_resource_controller.py index e787d6c86..0954160fb 100644 --- a/nqp/resource_controllers/image_resource_controller.py +++ b/nqp/resource_controllers/image_resource_controller.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from pathlib import Path from typing import List @@ -7,6 +9,8 @@ from nqp.base_classes.resource_controller import ResourceController from nqp.core.constants import ASSET_PATH, DEFAULT_IMAGE_SIZE, IMG_FORMATS +__all__ = ["ImageResourceController"] + class ImageResourceController(ResourceController): """ @@ -69,7 +73,7 @@ def _load_image(self, item: str) -> pygame.Surface: such as town@640.0x360.0 :return: a Surface with the loaded image. """ - logging.info(f"Loading image '{item}'") + # logging.info(f"Loading image '{item}'") # The image's name is before the at sign, width and height are after name, after_at_sign = item.split("@") diff --git a/nqp/scenes/post_combat/scene.py b/nqp/scenes/post_combat/scene.py deleted file mode 100644 index ce403048d..000000000 --- a/nqp/scenes/post_combat/scene.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations - -import logging -import time -from typing import Any, TYPE_CHECKING - -from nqp.base_classes.scene import Scene -from nqp.command.troupe import Troupe -from nqp.command.unit import Unit -from nqp.core.constants import PostCombatState, RewardType, SceneType -from nqp.scenes.post_combat.ui import PostCombatUI - -if TYPE_CHECKING: - from typing import Optional - - from nqp.core.game import Game - -__all__ = ["PostCombatScene"] - - -class PostCombatScene(Scene): - """ - Handles RewardScene interactions and consolidates the rendering. RewardScene is used to provide a choice of - rewards for the player to pick from. - """ - - def __init__(self, game: Game): - # start timer - start_time = time.time() - - super().__init__(game, SceneType.POST_COMBAT) - - self.ui: PostCombatUI = PostCombatUI(game, self) - - self.state: PostCombatState = PostCombatState.VICTORY - - # reward management - self.current_rewards = None # holds the current rewards, e.g. troupe_rewards - self.reward_type: RewardType = RewardType.UNIT - self.num_rewards: int = 3 - - # reward options - self.gold_reward: int = 0 - - self.troupe_rewards: Optional[Troupe] = None - self.resource_rewards = None - self.upgrade_rewards = None - self.action_rewards = None - - # record duration - end_time = time.time() - logging.debug(f"RewardScene: initialised in {format(end_time - start_time, '.2f')}s.") - - def update(self, delta_time: float): - super().update(delta_time) - self.ui.update(delta_time) - - def reset(self): - self.ui = PostCombatUI(self._game, self) - - self.state = PostCombatState.VICTORY - - # reward management - self.current_rewards = None # holds the current rewards, e.g. troupe_rewards - self.reward_type = RewardType.UNIT - self.num_rewards = 3 - - # reward options - self.gold_reward = 0 - player_troupe = self._game.memory.player_troupe - self.troupe_rewards = Troupe(self._game, "reward", player_troupe.allies) - self.resource_rewards = None - self.upgrade_rewards = None - self.action_rewards = None - - def generate_reward(self): - """ - Generate reward to offer. Overwrites existing rewards. - """ - gold_min = self._game.data.config["post_combat"]["gold_min"] - gold_max = self._game.data.config["post_combat"]["gold_max"] - gold_level_multiplier = self._game.data.config["post_combat"]["gold_level_multiplier"] - level = self._game.memory.level - - # only apply multiplier after level 1 - if level > 1: - mod = level * gold_level_multiplier - else: - mod = 1 - - # roll gold - self.gold_reward = int(self._game.rng.randint(gold_min, gold_max) * mod) - - # generate required rewards - reward_type = self.reward_type - if reward_type == RewardType.UNIT: - self._generate_troupe_rewards() - current_reward = self.troupe_rewards - - elif reward_type == RewardType.ACTION: - self._generate_action_rewards() - current_reward = self.action_rewards - - elif reward_type == RewardType.UPGRADE: - self._generate_upgrade_rewards() - current_reward = self.upgrade_rewards - - else: - # reward_type == RewardType.RESOURCE - current_reward = self.resource_rewards - self._generate_resource_rewards() - - # update current rewards - self.current_rewards = current_reward - - def _generate_troupe_rewards(self): - - # update troupe to match players - player_troupe = self._game.memory.player_troupe - self.troupe_rewards.allies = player_troupe.allies - - # generate units in Troupe - self.troupe_rewards.remove_all_units() - self.troupe_rewards.generate_units(self.num_rewards) - - def _generate_action_rewards(self): - """STUB""" - pass - - def _generate_upgrade_rewards(self): - """STUB""" - pass - - def _generate_resource_rewards(self): - """STUB""" - pass - - def choose_reward(self, reward: Any): - """ - Add the current reward and the reward gold. - """ - # add current reward - reward_type = self.reward_type - if reward_type == RewardType.UNIT: - self._choose_troupe_rewards(reward) - - elif reward_type == RewardType.ACTION: - self._choose_action_rewards(reward) - - elif reward_type == RewardType.UPGRADE: - self._choose_upgrade_rewards(reward) - - else: - # reward_type == RewardType.RESOURCE - self._choose_resource_rewards(reward) - - # add gold - self._game.memory.amend_gold(self.gold_reward) - - def _choose_troupe_rewards(self, reward: Unit): - if isinstance(reward, Unit): - # check can afford - has_enough_charisma = self._game.memory.commander.charisma_remaining > 0 - if has_enough_charisma: - self._game.memory.player_troupe.add_unit(reward) - else: - logging.error( - f"Chose {reward} as a unit reward. As it isnt a unit, something has " - f"seriously gone wrong! No reward added." - ) - - def _choose_action_rewards(self, reward: Any): - """STUB""" - pass - - def _choose_upgrade_rewards(self, reward: Any): - """STUB""" - pass - - def _choose_resource_rewards(self, reward: Any): - """STUB""" - pass diff --git a/nqp/scenes/post_combat/ui.py b/nqp/scenes/post_combat/ui.py deleted file mode 100644 index 5dd0de83c..000000000 --- a/nqp/scenes/post_combat/ui.py +++ /dev/null @@ -1,378 +0,0 @@ -### -### For reference only -### - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pygame - -from nqp.base_classes.ui import UI -from nqp.command.unit import Unit -from nqp.core.constants import DEFAULT_IMAGE_SIZE, FontType, PostCombatState, RewardType, SceneType -from nqp.ui_elements.generic.ui_frame import UIFrame - -if TYPE_CHECKING: - from typing import Optional - - from nqp.core.game import Game - from nqp.scenes.post_combat.scene import PostCombatScene - -__all__ = ["PostCombatUI"] - - -################ TO DO LIST ################## - - -# noinspection PyTypeChecker -class PostCombatUI(UI): - """ - Represent the UI of the RewardScene. - """ - - def __init__(self, game: Game, parent_scene: PostCombatScene): - super().__init__(game, True) - self._parent_scene: PostCombatScene = parent_scene - - self.selected_reward: Optional[Unit] = None - - self.stats_max_width = 5 - - self.set_instruction_text("Choose your rewards.") - - def update(self, delta_time: float): - super().update(delta_time) - - def process_input(self, delta_time: float): - super().process_input(delta_time) - - if self._game.input.states["right"]: - if self.selected_ui_row == 0: - self.selected_ui_col += 1 - self._game.input.states["right"] = False - if self._game.input.states["left"]: - if self.selected_ui_row == 0: - self.selected_ui_col -= 1 - self._game.input.states["left"] = False - - if not self._game.combat.end_data: - end_data_length = 1 - else: - end_data_length = len(self._game.combat.end_data) - - self.selected_ui_col = self.selected_ui_col % end_data_length - if self.selected_ui_col < self.stats_scroll: - self.stats_scroll = self.selected_ui_col - if self.selected_ui_col >= self.stats_scroll + self.stats_max_width: - self.stats_scroll = self.selected_ui_col - self.stats_max_width + 1 - - if self._game.input.states["up"]: - self._game.input.states["up"] = False - self.selected_ui_row -= 1 - if self._game.input.states["down"]: - self._game.input.states["down"] = False - self.selected_ui_row += 1 - self.selected_ui_row = self.selected_ui_row % 2 - - if self._game.post_combat.state == PostCombatState.VICTORY: - self.handle_victory_input() - elif self._game.post_combat.state == PostCombatState.DEFEAT: - self.handle_defeat_input() - elif self._game.post_combat.state == PostCombatState.BOSS_VICTORY: - self.handle_boss_victory_input() - - def draw(self, surface: pygame.Surface): - combat_data = self._game.combat.end_data - create_font = self._game.visual.create_font - - empty_font = create_font(FontType.DEFAULT, "") - - unit_width = 120 - if combat_data is not None: - for i, unit in enumerate(combat_data): - x = unit_width * (i - self.stats_scroll) + unit_width // 2 - if (i < self.stats_scroll) or (i >= self.stats_scroll + self.stats_max_width): - continue - y = self._game.window.base_resolution[1] // 2 + 40 - if self.selected_ui_row == 0: - if self.selected_ui_col == i: - surface.blit(self._game.visual.ui["select_arrow"], (x - 6, y - 14)) - unit_img = self._game.visual.unit_animations[unit[0]]["icon"][0] - surface.blit(unit_img, (x - unit_img.get_width() // 2, y)) - y += unit_img.get_height() + 4 - font = create_font(FontType.DEFAULT, unit[0], (x - empty_font.get_text_width(unit[0]) // 2, y)) - font.draw(surface) - y += 13 - for i, v in enumerate([unit[1], unit[2], unit[3], unit[5]]): - v = str(v) - if i != 3: - font = create_font(FontType.DEFAULT, v, (x, y + 4)) - font.draw(surface) - img = self._game.visual._images["stats"][("dmg_dealt@16x16", "kills@16x16", "defence@16x16")[i]] - surface.blit(img, (x - img.get_width() - 2, y)) - else: - font = create_font(FontType.DEFAULT, v, (x - empty_font.get_text_width(v) // 2, y)) - font.draw(surface) - y += 18 - for i in range(unit[4]): - x_offset = -unit[4] * 10 + i * 20 - surface.blit(self._game.visual._images["stats"]["health@16x16"], (x + x_offset, y)) - - if self.selected_ui_row == 1: - surface.blit( - self._game.visual.ui["select_arrow"], - (self._game.window.base_resolution[0] - 20, self._game.window.base_resolution[1] - 30), - ) - - if self._game.post_combat.state == PostCombatState.VICTORY: - - reward_type = self._game.post_combat.reward_type - if reward_type == RewardType.UNIT: - pass - # self._render_unit_rewards(surface) - - elif reward_type == RewardType.ACTION: - pass - - elif reward_type == RewardType.UPGRADE: - pass - - else: - # reward_type == RewardType.RESOURCE - pass - - # show core info - self._draw_instruction(surface) - - elif self._game.post_combat.state == PostCombatState.DEFEAT: - self._draw_instruction(surface) - - # draw elements - self._draw_elements(surface) - - def rebuild_ui(self): - super().rebuild_ui() - state = self._game.post_combat.state - - self.selected_ui_row = 0 - self.selected_ui_col = 0 - self.stats_scroll = 0 - - if self._game.post_combat.state == PostCombatState.VICTORY: - self._rebuild_victory_ui() - elif state == PostCombatState.DEFEAT: - self._rebuild_defeat_ui() - elif state == PostCombatState.BOSS_VICTORY: - self._rebuild_boss_victory_ui() - - self.rebuild_resource_elements() - - def _rebuild_victory_ui(self): - window_width = self._game.window.width - window_height = self._game.window.height - create_font = self._game.visual.create_font - - start_x = 20 - start_y = 40 - icon_width = DEFAULT_IMAGE_SIZE - icon_height = DEFAULT_IMAGE_SIZE - icon_size = (icon_width, icon_height) - - # draw header - text = "Victory" - font = create_font(FontType.POSITIVE, text) - current_x = (window_width // 2) - font.width - current_y = start_y - frame = UIFrame(self._game, pygame.Vector2(current_x, current_y), font=font, is_selectable=False) - self._elements["header"] = frame - - # draw gold reward - current_y += 50 - gold_icon = self._game.visual.get_image("stats", "gold", icon_size) - gold_reward = str(self._game.post_combat.gold_reward) - frame = UIFrame( - self._game, - (current_x, current_y), - image=gold_icon, - font=create_font(FontType.DEFAULT, gold_reward), - is_selectable=False, - ) - self._elements["gold_reward"] = frame - - # draw exit button - self.add_exit_button() - - def _rebuild_defeat_ui(self): - create_font = self._game.visual.create_font - - start_x = 20 - start_y = 40 - window_width = self._game.window.width - window_height = self._game.window.height - - self.set_instruction_text("Return to the main menu") - - # draw header - text = "Defeat" - font = create_font(FontType.NEGATIVE, text) - current_x = (window_width // 2) - font.width - current_y = start_y - frame = UIFrame(self._game, (current_x, current_y), font=font, is_selectable=False) - self._elements["header"] = frame - - # draw lost morale - current_y = window_height // 2 - morale = self._game.memory.morale - - if morale <= 0: - # game over - text = "Your forces, like your ambitions, lie in ruin." - font = create_font(FontType.DISABLED, text) - current_x = (window_width // 2) - (font.width // 2) - frame = UIFrame(self._game, (current_x, current_y), font=font, is_selectable=False) - self._elements["morale"] = frame - - # draw exit button - self.add_exit_button("Abandon hope") - else: - # lose morale - morale_image = self._game.visual.get_image("stats", "morale") - frame = UIFrame( - self._game, - (current_x, current_y), - image=morale_image, - font=create_font(FontType.NEGATIVE, str("-1")), - is_selectable=False, - ) - self._elements["morale"] = frame - - # draw exit button - self.add_exit_button() - - def _render_unit_rewards(self, surface: pygame.Surface): - pass - # # FIXME - this no longer works - # reward_units = list(self._game.reward.troupe_rewards.units.values()) - # default_font = self.default_font - # disabled_font = self.disabled_font - # warning_font = self.warning_font - # positive_font = self.positive_font - # stats = ["type", "health", "defence", "attack", "range", "attack_speed", "move_speed", "ammo", "count"] - # - # # positions - # start_x = 20 - # start_y = 40 - # font_height = 12 - # window_width = self._game.window.width - # window_height = self._game.window.height - # col_width = int((window_width - (start_x * 2)) / len(stats)) - # - # # victory message - # positive_font.draw("Victory!", surface, (start_x, start_y)) - # - # # gold reward - # current_y = start_y + (font_height * 2) - # gold_reward = self._game.reward.gold_reward - # default_font.draw(f"{gold_reward} gold scavenged from the dead.", surface, (start_x, current_y)) - # - # # instruction - # current_y = window_height // 2 - # warning_font.draw(f"Choose one of the following rewards.", surface, (start_x, current_y)) - # - # # draw headers - # current_y = current_y + (font_height * 2) - # col_count = 0 - # for stat in stats: - # col_x = start_x + (col_width * col_count) - # default_font.draw(stat, surface, (col_x, current_y)) - # - # col_count += 1 - # - # # draw unit info - # row_count = 0 - # for unit in reward_units: - # active_font = default_font - # - # option_y = current_y + ((font_height + GAP_SIZE) * (row_count + 1)) # + 1 due to headers - # - # # draw stats - # col_count = 0 - # for stat in stats: - # col_x = start_x + (col_width * col_count) - # - # text = str(getattr(unit, stat)) - # active_font.draw(text, surface, (col_x, option_y)) - # - # col_count += 1 - # - # # draw selector - # if row_count == self.selected_row: - # # note the selected unit - # self.selected_reward = unit - # - # pygame.draw.line( - # surface, - # (255, 255, 255), - # (start_x, option_y + font_height), - # (start_x + active_font.width(unit.type), option_y + font_height), - # ) - # - # row_count += 1 - - def handle_victory_input(self): - if self.selected_ui_row == 1: - if self._game.input.states["select"]: - self._game.input.states["select"] = False - - # there's only 1 thing to select so we know it is the exit button - self._game.change_scene(SceneType.OVERWORLD) - - def handle_defeat_input(self): - if self.selected_ui_row == 1: - if self._game.input.states["select"]: - self._game.input.states["select"] = False - - # there's only 1 thing to select so we know it is the exit button - but exit to what? - morale = self._game.memory.morale - if morale <= 0: - # game over - self._game.run_setup.reset() - self._game.change_scene(SceneType.MAIN_MENU) - else: - # bakc to overworld - self._game.change_scene(SceneType.OVERWORLD) - - def handle_boss_victory_input(self): - if self._game.input.states["select"]: - self._game.input.states["select"] = False - - # there's only 1 thing to select so we know it is the exit button - self._game.change_scene(SceneType.MAIN_MENU) - - def _rebuild_boss_victory_ui(self): - start_y = 40 - window_width = self._game.window.width - - # draw header - header_text = "Victory" - header_font = self._game.visual.create_font(FontType.DEFAULT, header_text) - current_x = (window_width // 2) - header_font.width - current_y = start_y - frame = UIFrame(self._game, (current_x, current_y), font=header_font, is_selectable=False) - self._elements["header"] = frame - - # draw victory message - current_y += 50 - text = "That's all there is. You've beaten the boss, so why not try another commander?" - victory_font = self._game.visual.create_font(FontType.POSITIVE, text) - frame = UIFrame( - self._game, - (current_x, current_y), - font=victory_font, - is_selectable=False, - ) - self._elements["info"] = frame - - # draw exit button - self.add_exit_button() diff --git a/nqp/scenes/view_troupe/__init__.py b/nqp/scenes/view_troupe/__init__.py deleted file mode 100644 index 4d21ee850..000000000 --- a/nqp/scenes/view_troupe/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -__all__ = [] diff --git a/nqp/scenes/view_troupe/scene.py b/nqp/scenes/view_troupe/scene.py deleted file mode 100644 index bbd35f4af..000000000 --- a/nqp/scenes/view_troupe/scene.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from nqp.base_classes.scene import Scene -from nqp.core.constants import SceneType -from nqp.core.debug import Timer -from nqp.scenes.view_troupe.ui import ViewTroupeUI - -if TYPE_CHECKING: - from nqp.core.game import Game - -__all__ = ["ViewTroupeScene"] - - -class ViewTroupeScene(Scene): - """ - Handles ViewTroupeScene interactions and consolidates the rendering. ViewTroupeScene is used to view the troupe - information. - """ - - def __init__(self, game: Game): - with Timer("ViewTroupeScene: initialised"): - - super().__init__(game, SceneType.VIEW_TROUPE) - - self.ui: ViewTroupeUI = ViewTroupeUI(game, self) - - self.previous_scene_type: SceneType = SceneType.VIEW_TROUPE - - def update(self, delta_time: float): - super().update(delta_time) - self.ui.update(delta_time) - - def reset(self): - self.ui = ViewTroupeUI(self._game, self) - self.previous_scene_type = SceneType.VIEW_TROUPE diff --git a/nqp/scenes/view_troupe/ui.py b/nqp/scenes/view_troupe/ui.py deleted file mode 100644 index a9c587cba..000000000 --- a/nqp/scenes/view_troupe/ui.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pygame - -from nqp.base_classes.ui import UI - -if TYPE_CHECKING: - from nqp.core.game import Game - from nqp.scenes.view_troupe.scene import ViewTroupeScene - -__all__ = ["ViewTroupeUI"] - - -class ViewTroupeUI(UI): - """ - Represent the UI of the ViewTroupeScene. - """ - - def __init__(self, game: Game, parent_scene: ViewTroupeScene): - super().__init__(game, True) - self._parent_scene: ViewTroupeScene = parent_scene - - self.set_instruction_text(f"Press X to exit the troupe overview.") - - def update(self, delta_time: float): - super().update(delta_time) - - def process_input(self, delta_time: float): - super().process_input(delta_time) - - # generic input - if self._game.input.states["down"]: - self._game.input.states["down"] = False - - self._current_container.select_next_element() - - if self._game.input.states["up"]: - self._game.input.states["up"] = False - - self._current_container.select_previous_element() - - if self._game.input.states["cancel"]: - self._game.input.states["cancel"] = False - - # return to previous scene - self._game.change_scene(self._game.troupe.previous_scene_type) - - def draw(self, surface: pygame.Surface): - # show core info - self._draw_instruction(surface) - - # draw elements - self._draw_elements(surface) - - def rebuild_ui(self): - super().rebuild_ui() - - units = self._game.memory.player_troupe.units - - # positions - start_x = 20 - start_y = 40 - - # draw options - current_x = start_x - current_y = start_y - for count, unit in enumerate(units.values()): - - frame = UnitStatsFrame(self._game, (current_x, current_y), unit, False) - self._elements[f"{unit.id}"] = frame - # if we need to refer back to this we will need to change key - - current_x += 70 - - self.rebuild_resource_elements() diff --git a/nqp/scenes/world/ui.py b/nqp/scenes/world/ui.py index 54384c233..06756b61a 100644 --- a/nqp/scenes/world/ui.py +++ b/nqp/scenes/world/ui.py @@ -1174,6 +1174,6 @@ def _conditionally_create_stat_window(self): self._containers.pop("unit_info", None) if should_create_unit_window: - unit_info_pos = pygame.Vector2(10, 100) + unit_info_pos = pygame.Vector2(10, 40) info = UnitStatsWindow(self._game, unit_info_pos, selected_unit, True) self.add_container(info, "unit_info") diff --git a/nqp/ui_elements/generic/ui_tooltip.py b/nqp/ui_elements/generic/ui_tooltip.py index 3d7b4d430..2e1e51418 100644 --- a/nqp/ui_elements/generic/ui_tooltip.py +++ b/nqp/ui_elements/generic/ui_tooltip.py @@ -125,7 +125,7 @@ def _parse_text(text: str) -> Tuple[str, List[str]]: """ Return text without tags and a list of any keys for secondary tooltips. """ - # check for secondary tag + # check for secondary tags start_indices = [i for i, char in enumerate(text) if char == "<"] end_indices = [i for i, char in enumerate(text) if char == ">"] secondary_tooltip_keys = [] diff --git a/nqp/ui_elements/tailored/dev_console.py b/nqp/ui_elements/tailored/dev_console.py index 8fcb86be6..a2e892e0b 100644 --- a/nqp/ui_elements/tailored/dev_console.py +++ b/nqp/ui_elements/tailored/dev_console.py @@ -221,6 +221,8 @@ def _load_unit_csv(self): data["damage_type"] = str(row["damage_type"]) data["crit_chance"] = int(row["crit_chance"]) data["penetration"] = int(row["penetration"]) + data["regen"] = int(row["regen"]) + data["dodge"] = int(row["dodge"]) # delete previous file os.remove(str_path) diff --git a/nqp/ui_elements/tailored/unit_grid.py b/nqp/ui_elements/tailored/unit_grid.py index 14387e3ae..c652560ea 100644 --- a/nqp/ui_elements/tailored/unit_grid.py +++ b/nqp/ui_elements/tailored/unit_grid.py @@ -6,10 +6,10 @@ import snecs from nqp.command.unit import Unit -from nqp.core.components import AI from nqp.core.constants import InputType, TILE_SIZE from nqp.core.game import Game from nqp.core.utility import grid_down, grid_left, grid_right, grid_up +from nqp.world_elements.entity_components import AI __all__ = ["UnitGrid"] diff --git a/nqp/ui_elements/tailored/unit_stats_window.py b/nqp/ui_elements/tailored/unit_stats_window.py index 0c929dd60..c90918dfb 100644 --- a/nqp/ui_elements/tailored/unit_stats_window.py +++ b/nqp/ui_elements/tailored/unit_stats_window.py @@ -3,10 +3,8 @@ from typing import TYPE_CHECKING import pygame -from pygame import SRCALPHA from nqp.command.unit import Unit -from nqp.core.components import Stats from nqp.core.constants import ( DEFAULT_IMAGE_SIZE, FontType, @@ -17,10 +15,9 @@ ) from nqp.ui_elements.generic.ui_frame import UIFrame from nqp.ui_elements.generic.ui_window import UIWindow +from nqp.world_elements.entity_components import Stats if TYPE_CHECKING: - from typing import Tuple - from nqp.core.game import Game @@ -33,7 +30,7 @@ class UnitStatsWindow(UIWindow): """ def __init__(self, game: Game, pos: pygame.Vector2, unit: Unit, is_active: bool = False): - size = pygame.Vector2(100, 160) + size = pygame.Vector2(100, 280) super().__init__(game, WindowType.BASIC, pos, size, [], is_active) self.unit: Unit = unit @@ -51,7 +48,7 @@ def _rebuild_stat_frames(self): stat_icon_size = pygame.Vector2(DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE) current_x = start_x + ((self.width // 2) - (stat_icon_size[0] // 2)) - current_y = start_y + 2 + current_y = start_y + GAP_SIZE + 8 # draw icon unit_icon = create_animation(self.unit.type, "icon") @@ -67,7 +64,7 @@ def _rebuild_stat_frames(self): # increment current_x = start_x + GAP_SIZE - current_y += unit_icon.height + (GAP_SIZE * 2) + current_y += unit_icon.height + (GAP_SIZE * 3) # draw stats stats = Stats.get_stat_names() @@ -75,9 +72,9 @@ def _rebuild_stat_frames(self): for count, stat in enumerate(stats): # recalc x and y - y_mod = count % 4 # this is the rows in the col - x_mod = count // 4 # must match int used for y - frame_x = current_x + (x_mod * (stat_icon_size[0] + GAP_SIZE)) + x_mod = count % 2 # this is the rows in the col + y_mod = count // 2 # must match int used for y + frame_x = current_x + (x_mod * (stat_icon_size[0] + (GAP_SIZE * 3))) frame_y = current_y + (y_mod * (stat_icon_size[1] + GAP_SIZE)) # determine font to use diff --git a/nqp/world/controllers/choose_room_controller.py b/nqp/world/controllers/choose_room_controller.py index 22e5012eb..e79a8f4a3 100644 --- a/nqp/world/controllers/choose_room_controller.py +++ b/nqp/world/controllers/choose_room_controller.py @@ -7,9 +7,9 @@ from nqp.base_classes.controller import Controller from nqp.core import queries -from nqp.core.components import Position from nqp.core.constants import ChooseRoomState, GameSpeed, WorldState from nqp.core.debug import Timer +from nqp.world_elements.entity_components import Position if TYPE_CHECKING: from typing import List, Optional, Tuple diff --git a/nqp/world/model.py b/nqp/world/model.py index 0e2928848..5b4e5769c 100644 --- a/nqp/world/model.py +++ b/nqp/world/model.py @@ -134,6 +134,7 @@ def process_update_systems(self, delta_time: float): Process the non-drawing related ECS systems. """ systems.process_ai(delta_time) + systems.process_healing() systems.process_movement(delta_time, self._game) systems.process_attack(self._game) systems.apply_damage(self._game) diff --git a/nqp/world_elements/camera.py b/nqp/world_elements/camera.py index dc94ea6af..36f5963e9 100644 --- a/nqp/world_elements/camera.py +++ b/nqp/world_elements/camera.py @@ -1,15 +1,15 @@ from __future__ import annotations -from typing import Any, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING import pygame import snecs from snecs.typedefs import EntityID -from nqp.core.components import Position +from nqp.world_elements.entity_components import Position if TYPE_CHECKING: - from typing import Dict, List, Tuple, Union + pass class Camera: diff --git a/nqp/core/components.py b/nqp/world_elements/entity_components.py similarity index 80% rename from nqp/core/components.py rename to nqp/world_elements/entity_components.py index 7540d4d65..cc8f6823c 100644 --- a/nqp/core/components.py +++ b/nqp/world_elements/entity_components.py @@ -8,11 +8,12 @@ from nqp.base_classes.animation import Animation from nqp.base_classes.image import Image from nqp.command.unit import Unit -from nqp.core.constants import DamageType, EntityFacing +from nqp.core.constants import DamageType, EntityFacing, HealingSource from nqp.world_elements.stats import FloatStat, IntStat +from nqp.world_elements.unit_attribute import UnitAttribute if TYPE_CHECKING: - from typing import List + from typing import List, Tuple from nqp.command.basic_entity_behaviour import BasicEntityBehaviour @@ -20,7 +21,6 @@ "Position", "Aesthetic", "Tracked", - "Resources", "Stats", "Allegiance", "AI", @@ -28,6 +28,8 @@ "DamageReceived", "IsDead", "IsReadyToAttack", + "Attributes", + "HealReceived", ] @@ -100,30 +102,13 @@ def deserialize(cls, *serialised): return Tracked(*serialised) -class Resources(RegisteredComponent): - """ - An Entity's resources, such as health. - """ - - def __init__(self, health: int): - self.health: IntStat = IntStat(health) - - def serialize(self): - # TODO - add serialisation - return True - - @classmethod - def deserialize(cls, *serialised): - # TODO - add deserialisation - return Resources(*serialised) - - class Stats(RegisteredComponent): """ An Entity's stats, such as attack. """ def __init__(self, parent_unit: Unit): + self.health: IntStat = IntStat(parent_unit.health) self.mundane_defence: IntStat = IntStat(parent_unit.mundane_defence) self.magic_defence: IntStat = IntStat(parent_unit.magic_defence) self.attack: IntStat = IntStat(parent_unit.attack) @@ -135,6 +120,8 @@ def __init__(self, parent_unit: Unit): self.weight: IntStat = IntStat(parent_unit.weight) self.penetration: IntStat = IntStat(parent_unit.penetration) self.crit_chance: IntStat = IntStat(parent_unit.crit_chance) + self.regen: IntStat = IntStat(parent_unit.regen) + self.dodge: IntStat = IntStat(parent_unit.dodge) def serialize(self): # TODO - add serialisation @@ -143,14 +130,17 @@ def serialize(self): @classmethod def deserialize(cls, *serialised): # TODO - add deserialisation - return Resources(*serialised) + return Stats(*serialised) @classmethod def get_stat_names(cls) -> List[str]: """ Get a list of all the stats. + + N.B. this is manually populated so if a stat is missing check here. """ stat_attrs = [ + "health", "mundane_defence", "magic_defence", "attack", @@ -162,6 +152,8 @@ def get_stat_names(cls) -> List[str]: "weight", "penetration", "crit_chance", + "regen", + "dodge", ] return stat_attrs @@ -240,7 +232,28 @@ def serialize(self): @classmethod def deserialize(cls, *serialised): # TODO - add deserialisation - return Allegiance(*serialised) + return DamageReceived(*serialised) + + +class HealReceived(RegisteredComponent): + """ + Healing to be applied to the Entity. + """ + + def __init__(self, amount: int, healing_source: HealingSource): + self.heals: List[Tuple[int, HealingSource]] = [(amount, healing_source)] + + def serialize(self): + # TODO - add serialisation + return True + + @classmethod + def deserialize(cls, *serialised): + # TODO - add deserialisation + return DamageReceived(*serialised) + + def add_heal(self, amount: int, healing_source: HealingSource): + self.heals.append((amount, healing_source)) class IsDead(RegisteredComponent): @@ -261,3 +274,22 @@ class IsReadyToAttack(RegisteredComponent): # doesnt need init as has no details # doesnt need serialising as will never be about to attack when saving. + + +class Attributes(RegisteredComponent): + """ + A series of flags defining the attributes of a Unit + """ + + def __init__(self): + self.can_be_healed_by_other = UnitAttribute(True) + self.can_be_healed_by_self = UnitAttribute(True) + + def serialize(self): + # TODO - add serialisation + return True + + @classmethod + def deserialize(cls, *serialised): + # TODO - add deserialisation + return Attributes(*serialised) diff --git a/nqp/world_elements/particle_manager.py b/nqp/world_elements/particle_manager.py index 09781b571..a2605a0fc 100644 --- a/nqp/world_elements/particle_manager.py +++ b/nqp/world_elements/particle_manager.py @@ -7,6 +7,7 @@ import pygame +from nqp.core.constants import Colour from nqp.world_elements.particle import Particle if TYPE_CHECKING: @@ -16,22 +17,64 @@ class ParticleManager: + """ + Class to manage all particles and their functionality, including creating, drawing and deletion. + """ + def __init__(self): - self.particles = [] + self._particles = [] + + def _create_particle_burst( + self, + position: pygame.Vector2, + colour: Colour | Tuple[int, int, int], + count_range: List[int, int], + speed_range: List[int, int] = None, + duration_range: List[int, int] = None, + allow_shade_variations: bool = False, + ): + """ + Create a short burst of coloured circles from a target location in a randomised direction. + + Args: + count_range: the range of how many particles to create. + speed_range: the range of the speed of the particles movement + duration_range: the range of how long the particles will last, in seconds. + allow_shade_variations: whether the colour used can vary slightly from the one given + """ + + # handle mutable defaults + if duration_range is None: + duration_range = [0.2, 0.3] + if speed_range is None: + speed_range = [30, 60] + + # get random count + count = random.randint(count_range[0], count_range[1]) - def create_particle_burst(self, loc, colour, count, speed_range=[30, 60], dur_range=[0.2, 0.3]): for i in range(count): speed = random.random() * (speed_range[1] - speed_range[0]) + speed_range[0] - dur = random.random() * (dur_range[1] - dur_range[0]) + dur_range[0] + dur = random.random() * (duration_range[1] - duration_range[0]) + duration_range[0] angle = random.random() * math.pi * 2 - p = Particle(loc, pygame.Vector2(math.cos(angle) * speed, math.sin(angle) * speed), dur, colour) - self.particles.append(p) - def update(self, dt): - for i, p in sorted(enumerate(self.particles), reverse=True): - if not p.update(dt): - self.particles.pop(i) + if allow_shade_variations: + variation = random.randint(-5, 5) + colour = (colour[0] - variation, colour[1] - variation, colour[2] - variation) + + p = Particle(position, pygame.Vector2(math.cos(angle) * speed, math.sin(angle) * speed), dur, colour) + self._particles.append(p) + + def create_blood_spray(self, pos: pygame.Vector2, blood_colour: Tuple[int, int, int] = Colour.BLOOD_RED): + self._create_particle_burst(pos, blood_colour, [10, 16], allow_shade_variations=True) + + def create_smoke(self, pos: pygame.Vector2): + self._create_particle_burst(pos, Colour.GREY_SMOKE, [30, 40]) + + def update(self, delta_time: float): + for i, p in sorted(enumerate(self._particles), reverse=True): + if not p.update(delta_time): + self._particles.pop(i) - def draw(self, surf, offset=(0, 0)): - for p in self.particles: - p.draw(surf, offset=offset) + def draw(self, surface: pygame.Surface, offset=(0, 0)): + for p in self._particles: + p.draw(surface, offset=offset) diff --git a/nqp/world_elements/projectile.py b/nqp/world_elements/projectile.py index d2046a814..da029004f 100644 --- a/nqp/world_elements/projectile.py +++ b/nqp/world_elements/projectile.py @@ -7,12 +7,12 @@ import snecs from nqp.base_classes.image import Image -from nqp.core.components import Allegiance, DamageReceived, Position from nqp.core.constants import DamageType from nqp.core.utility import angle_to +from nqp.world_elements.entity_components import Allegiance, DamageReceived, Position if TYPE_CHECKING: - from typing import Dict, Tuple, Union + from typing import Dict, Union from snecs.typedefs import EntityID diff --git a/nqp/world_elements/stats.py b/nqp/world_elements/stats.py index e89096f41..907b512a5 100644 --- a/nqp/world_elements/stats.py +++ b/nqp/world_elements/stats.py @@ -20,7 +20,12 @@ def __init__(self, base_value: int): def value(self) -> int: return super().value - def set_base_value(self, value: int): + @property + def base_value(self) -> int: + return self._base_value + + @base_value.setter + def base_value(self, value: int): self._base_value = value @@ -34,5 +39,10 @@ def __init__(self, base_value: float): def value(self) -> float: return super().value - def set_base_value(self, value: float): + @property + def base_value(self) -> float: + return self._base_value + + @base_value.setter + def base_value(self, value: float): self._base_value = value diff --git a/nqp/world_elements/unit_attribute.py b/nqp/world_elements/unit_attribute.py new file mode 100644 index 000000000..9000aaea7 --- /dev/null +++ b/nqp/world_elements/unit_attribute.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import weakref +from abc import ABC +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable + +__all__ = ["UnitAttribute"] + + +class UnitAttribute(ABC): + """ + A container for a Unit's Attribute + + # TODO - This is currently a copy of Stat and needs to be updated to work for bools only + with False taking precedence over True + + """ + + def __init__(self, base_value: bool): + self._base_value: bool = base_value + # weakkey dict will drop modifiers if they have been deleted + self._modifiers = weakref.WeakKeyDictionary() + self._override_value = None + + def reset(self): + """ + Set value back to base value. + """ + self._override_value = None + self._modifiers.clear() + + @property + def value(self): + if self._override_value is not None: + return self._override_value + acc = 0 + for key, func in self._modifiers.items(): + acc += func(self._base_value) + return self._base_value + acc + + @property + def base_value(self): + return self._base_value + + @base_value.setter + def base_value(self, value): + """ + Set the base or original value for the Stat. + """ + self._base_value = value + + def override(self, value): + """ + Force the value and ignore modifiers + + """ + self._override_value = value + + def apply_modifier(self, func: Callable, key: Any): + """ + Add a modifier + + When value is calculated, ``func`` will be called with the base value + + Args: + func: Any callable function + key: Unique identifier for adding and removing + + """ + self._modifiers[key] = func + + def remove_modifier(self, key: Any): + """ + Remove a modifier + + Args: + key: Unique identifier for adding and removing + + """ + del self._modifiers[key] + + def has_modifier(self, key: Any): + """ + Check if modifier is applied + + Args: + key: Unique identifier for adding and removing + + """ + return key in self._modifiers diff --git a/tests/npq/core/test_effect.py b/tests/npq/core/test_effect.py index 0189bd550..08e79ec78 100644 --- a/tests/npq/core/test_effect.py +++ b/tests/npq/core/test_effect.py @@ -4,10 +4,11 @@ import snecs from nqp.core import queries -from nqp.core.components import Aesthetic, Allegiance, Position, Resources, Stats from nqp.core.data import Data +from nqp.effects.actions import apply_effects, new_stats_effect from nqp.effects.burn import OnFireStatusEffect -from nqp.effects.stats_effect import apply_effects, new_stats_effect, StatsEffectSentinel +from nqp.effects.effect_components import StatsEffectSentinel +from nqp.world_elements.entity_components import Aesthetic, Allegiance, Position, Stats class EffectTestCase(unittest.TestCase): @@ -29,7 +30,6 @@ def setUp(self) -> None: components = [ Position(None), Aesthetic(None), - Resources(None), self.stats0, Allegiance("team0", self.unit0), ] @@ -42,7 +42,6 @@ def setUp(self) -> None: components = [ Position(None), Aesthetic(None), - Resources(None), self.stats1, Allegiance("team1", self.unit1), ] diff --git a/tests/npq/core/test_stats.py b/tests/npq/core/test_stats.py index 714476a45..31615d1c2 100644 --- a/tests/npq/core/test_stats.py +++ b/tests/npq/core/test_stats.py @@ -3,8 +3,8 @@ from functools import partial from unittest import mock -from nqp.core.components import Stats from nqp.core.data import Data +from nqp.world_elements.entity_components import Stats data = Data(mock.Mock())