189 lines
8.4 KiB
Python
189 lines
8.4 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
|
|
|
|
|
|
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
|
|
|
|
|
|
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 just bots) 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:
|
|
"""Bot-specific (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 bot_cpu/bot_npu (the organs those same checks
|
|
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.
|
|
"""
|
|
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))
|