229 lines
10 KiB
Python
229 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
from engine.character import Ailment, Character
|
|
from engine.world import TimeWarpZone, World
|
|
from resources.logic.combat import find_ranged_target
|
|
|
|
DEFAULT_RADIUS = 3.0
|
|
DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere
|
|
DEFAULT_DURATION = 5.0 # seconds
|
|
|
|
ADRENABLEND_STRENGTH_MULTIPLIER = 1.6
|
|
ADRENABLEND_SPEED_MULTIPLIER = 1.6
|
|
ADRENABLEND_DURATION = 20.0 # seconds
|
|
ADRENABLEND_BRAIN_FOG_SEVERITY = 0.7 # same ailment/severity a flu infection causes (see
|
|
# resources/logic/contagion.py) - reused directly rather than a near-identical duplicate, since
|
|
# mechanically "your head's not all there" is the same regardless of cause
|
|
|
|
|
|
def cast_time_warp_sphere(
|
|
caster: Character,
|
|
world: World,
|
|
now: float,
|
|
center: tuple[float, float, float] | None = None,
|
|
radius: float = DEFAULT_RADIUS,
|
|
speed_factor: float = DEFAULT_SPEED_FACTOR,
|
|
duration: float = DEFAULT_DURATION,
|
|
whitelist: set[str] | None = None,
|
|
aim_direction: tuple[int, int, int] | None = None,
|
|
) -> TimeWarpZone:
|
|
"""Sample ability: everything inside the sphere (except the caster and `whitelist`, e.g.
|
|
allies) moves at `speed_factor` of normal for `duration` seconds. Size, speed factor,
|
|
whitelist, and time are all just arguments - this is deliberately not tied to being cast
|
|
from a character ability vs. an item; see grants_ability on ItemDef and resources/logic/
|
|
actions.py::activate_hand for the item attachment point, and Character.can_perform_ability
|
|
for gating a direct character-ability trigger the same way leap/melee/ranged already are.
|
|
Note: only entity *movement* speed is actually slowed (see World.speed_multiplier_at) -
|
|
this engine resolves shots as instant hit-scans with no travel-time to slow. `aim_direction`
|
|
is accepted (and ignored) only so every ABILITY_CASTERS entry shares one calling
|
|
convention with directional abilities like cast_launch_lightning below.
|
|
"""
|
|
assert caster.position is not None
|
|
if center is None:
|
|
center = (caster.position.x, caster.position.y, caster.position.z)
|
|
immune = set(whitelist) if whitelist is not None else set()
|
|
immune.add(caster.entity_id)
|
|
zone = TimeWarpZone(
|
|
map_id=caster.position.map_id,
|
|
center=center,
|
|
radius=radius,
|
|
speed_factor=speed_factor,
|
|
expires_at=now + duration,
|
|
immune_entity_ids=immune,
|
|
)
|
|
world.time_warp_zones.append(zone)
|
|
return zone
|
|
|
|
|
|
def cast_adrenablend(
|
|
caster: Character,
|
|
world: World,
|
|
now: float,
|
|
aim_direction: tuple[int, int, int] | None = None,
|
|
duration: float = ADRENABLEND_DURATION,
|
|
) -> Character:
|
|
"""A shot of stimulant: greatly boosts strength and speed (see Character.active_boosts,
|
|
consumed by effective_strength/effective_speed) for `duration` seconds, at the cost of the
|
|
same brain_fog ailment a flu infection causes - reused directly (see
|
|
ADRENABLEND_BRAIN_FOG_SEVERITY above). Meant for a consumable item (ItemDef.consumable) via
|
|
resources/logic/actions.py::activate_hand, which unwields it after this succeeds - unlike
|
|
time_warp_sphere/launch_lightning this isn't a reusable wand-like ability, just item
|
|
plumbing reused for the "activate to trigger an effect" part. `aim_direction` is accepted
|
|
(and ignored) only to match every other ABILITY_CASTERS entry's calling convention.
|
|
"""
|
|
caster.active_boosts["strength"] = ADRENABLEND_STRENGTH_MULTIPLIER
|
|
caster.active_boosts["speed"] = ADRENABLEND_SPEED_MULTIPLIER
|
|
caster.boost_expirations["strength"] = now + duration
|
|
caster.boost_expirations["speed"] = now + duration
|
|
|
|
head = caster.get_or_create_body_part_override("head")
|
|
if head is not None:
|
|
existing = next((a for a in head.ailments if a.id == "brain_fog"), None)
|
|
if existing is not None:
|
|
existing.severity = max(existing.severity, ADRENABLEND_BRAIN_FOG_SEVERITY)
|
|
else:
|
|
head.ailments.append(Ailment(id="brain_fog", name="Brain Fog", severity=ADRENABLEND_BRAIN_FOG_SEVERITY))
|
|
caster.log_medical_event("Took Adrenablend: strength/speed boosted, brain fog")
|
|
return caster
|
|
|
|
|
|
DEFAULT_LAUNCH_LIGHTNING_RANGE = 8 # tiles
|
|
|
|
LIGHTNING_BOLT_BURN_FRACTION = 0.5 # roughly half the body parts, same flavor as the weather strike
|
|
LIGHTNING_BOLT_BURN_SEVERITY = 0.9
|
|
LIGHTNING_BOLT_INTEGRITY_DAMAGE = 40.0 # per burned body part
|
|
|
|
HEART_PALPITATIONS_SEVERITY = 0.85 # near-total loss of the check_weights this ailment covers
|
|
HEART_ORGAN_DAMAGE = 60.0 # taken directly off the heart-equivalent organ's own integrity
|
|
|
|
SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE = 120.0 # "120%" - more than one organ's full integrity,
|
|
# spread randomly across every organ a synthetic (see SpeciesDef.is_synthetic) target has
|
|
SHORTCIRCUIT_SEVERITY = 0.9 # huge debuff to insight/perception/sleight_of_hand (ailments.json)
|
|
|
|
|
|
def cast_launch_lightning(
|
|
caster: Character,
|
|
world: World,
|
|
now: float,
|
|
aim_direction: tuple[int, int, int] = (0, 1, 0),
|
|
max_range: int = DEFAULT_LAUNCH_LIGHTNING_RANGE,
|
|
rng: random.Random | None = None,
|
|
) -> Character | None:
|
|
"""Sample ability: a lightning bolt along aim_direction that always hits the first Character
|
|
it reaches within max_range - no skill check, lightning doesn't miss once something's in its
|
|
path (same straight-line/wall-stopping traversal as a ranged weapon shot, see
|
|
find_ranged_target; the walls-block-it part is the only "miss" condition, via range/line of
|
|
sight, not chance). On a hit it burns roughly half the target's body parts (integrity damage
|
|
+ a burn ailment each) and inflicts heart_palpitations: a severe ailment that directly
|
|
weighs down heart-dependent checks on its own (see resources/defs/ailments.json) *and*
|
|
damages the target's heart-equivalent organ directly (see OrganDef.role and
|
|
Character.find_organ_by_role) - so whatever that organ itself affects (e.g. kidneys via
|
|
OrganDef.affects_organs) degrades too over subsequent organ-interaction ticks, a real
|
|
physiological cascade rather than a flat stat penalty. A species with no heart-equivalent
|
|
organ still gets burned, just skips the organ-damage half. On a synthetic target (see
|
|
SpeciesDef.is_synthetic, currently hydraulibot/motorbot) it also short-circuits - see
|
|
_apply_shortcircuit - on top of everything else, since electronics are especially
|
|
vulnerable to a lightning strike.
|
|
"""
|
|
assert caster.position is not None
|
|
rng = rng or random.Random()
|
|
dx, dy, dz = aim_direction
|
|
target, _impact, _steps = find_ranged_target(
|
|
world, caster.position.map_id, caster.position.x, caster.position.y, caster.position.z,
|
|
dx, dy, dz, max_range,
|
|
)
|
|
if target is None:
|
|
return None
|
|
|
|
_burn_random_body_parts(target, rng)
|
|
_apply_heart_palpitations(target, world.registry)
|
|
_apply_shortcircuit(target, world.registry, rng)
|
|
return target
|
|
|
|
|
|
def _burn_random_body_parts(character: Character, rng: random.Random) -> None:
|
|
slots = [p.slot for p in character.default_body_parts]
|
|
if not slots:
|
|
return
|
|
count = max(1, round(len(slots) * LIGHTNING_BOLT_BURN_FRACTION))
|
|
for slot in rng.sample(slots, min(count, len(slots))):
|
|
part = character.get_or_create_body_part_override(slot)
|
|
if part is None:
|
|
continue
|
|
part.integrity = max(0.0, part.integrity - LIGHTNING_BOLT_INTEGRITY_DAMAGE)
|
|
if part.integrity <= 0.0:
|
|
part.removed = True
|
|
part.ailments.append(Ailment(id="burn", name="Burn", severity=LIGHTNING_BOLT_BURN_SEVERITY, progress=100.0))
|
|
|
|
|
|
def _apply_heart_palpitations(character: Character, registry) -> None:
|
|
found = character.find_organ_by_role("heart", registry)
|
|
if found is None:
|
|
return
|
|
body_part, heart = found
|
|
heart.integrity = max(0.0, heart.integrity - HEART_ORGAN_DAMAGE)
|
|
existing = next((a for a in body_part.ailments if a.id == "heart_palpitations"), None)
|
|
if existing is not None:
|
|
existing.severity = max(existing.severity, HEART_PALPITATIONS_SEVERITY)
|
|
else:
|
|
body_part.ailments.append(
|
|
Ailment(id="heart_palpitations", name="Heart Palpitations", severity=HEART_PALPITATIONS_SEVERITY)
|
|
)
|
|
|
|
|
|
def _apply_shortcircuit(character: Character, registry, rng: random.Random) -> None:
|
|
"""Synthetic-only (see SpeciesDef.is_synthetic - a no-op for anyone else, including a
|
|
speciesless character): splits SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE randomly across every organ
|
|
the target has - a short circuit doesn't discriminate which components it fries - then
|
|
layers a shortcircuit ailment onto every body part that hosts at least one of them. The
|
|
ailment alone already weighs heavily on insight/perception/sleight_of_hand (see
|
|
resources/defs/ailments.json), and since every synthetic species' own CPU/NPU-equivalent
|
|
organs (whichever checks those depend on, see organs.json) are themselves among the
|
|
randomly-damaged organs, the "huge debuff to intellect" falls straight out of the existing
|
|
organ_penalty math - no separate intellect-specific mechanic needed, and no per-species
|
|
organ-id branching either.
|
|
"""
|
|
if character.species_id is None:
|
|
return
|
|
species = registry.get_species_def(character.species_id)
|
|
if not species.is_synthetic:
|
|
return
|
|
|
|
organ_refs = [
|
|
(slot, organ.organ_def_id)
|
|
for slot, part in character.resolved_body_parts().items()
|
|
if part is not None
|
|
for organ in part.organs
|
|
]
|
|
if not organ_refs:
|
|
return
|
|
|
|
weights = [rng.random() for _ in organ_refs]
|
|
weight_total = sum(weights) or 1.0
|
|
|
|
affected_slots: set[str] = set()
|
|
for (slot, organ_def_id), weight in zip(organ_refs, weights):
|
|
damage = SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE * weight / weight_total
|
|
override = character.get_or_create_body_part_override(slot)
|
|
if override is None:
|
|
continue
|
|
organ = next((o for o in override.organs if o.organ_def_id == organ_def_id), None)
|
|
if organ is None:
|
|
continue
|
|
organ.integrity = max(0.0, organ.integrity - damage)
|
|
if organ.integrity <= 0.0:
|
|
organ.removed = True
|
|
affected_slots.add(slot)
|
|
|
|
for slot in affected_slots:
|
|
part = character.get_or_create_body_part_override(slot)
|
|
if part is None:
|
|
continue
|
|
existing = next((a for a in part.ailments if a.id == "shortcircuit"), None)
|
|
if existing is not None:
|
|
existing.severity = max(existing.severity, SHORTCIRCUIT_SEVERITY)
|
|
else:
|
|
part.ailments.append(Ailment(id="shortcircuit", name="Short Circuit", severity=SHORTCIRCUIT_SEVERITY))
|