Initial commit: top-down RPG engine (wgpu-py + glfw)
3D tile maps with embedded/rotating sub-maps, continuous movement, a body-part/organ health system with bespoke per-species anatomy, grid inventories, clothing with species/arm-count fit rules, melee/ranged combat with blocking/knockback/ammo, weather, staircases, a sample time-warp ability with cooldowns, multiplayer scaffolding, keyboard/ mouse/gamepad input, and persistent characters via SQLite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95main
commit
67c9435d9e
|
|
@ -0,0 +1,5 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/*.sqlite3
|
||||||
|
resources/textures/**/*.png
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
; Each action maps to a comma-separated list of gamepad buttons - add as many as you like to
|
||||||
|
; the same action. Recognized tokens (standard GLFW gamepad mapping): gp_a, gp_b, gp_x, gp_y,
|
||||||
|
; gp_left_bumper, gp_right_bumper, gp_left_trigger, gp_right_trigger (analog triggers, treated
|
||||||
|
; as pressed past a threshold), gp_back, gp_start, gp_guide, gp_left_thumb, gp_right_thumb,
|
||||||
|
; gp_dpad_up, gp_dpad_down, gp_dpad_left, gp_dpad_right. The left stick always drives movement
|
||||||
|
; directly (not rebindable here) - see engine/gamepad.py's AXIS_LEFT_X/AXIS_LEFT_Y.
|
||||||
|
|
||||||
|
[actions]
|
||||||
|
leap = gp_a
|
||||||
|
block = gp_left_bumper
|
||||||
|
|
||||||
|
[hands]
|
||||||
|
activate_right_hand = gp_right_bumper
|
||||||
|
activate_left_hand = gp_x
|
||||||
|
activate_right_hand_2 = gp_right_trigger
|
||||||
|
activate_left_hand_2 = gp_left_trigger
|
||||||
|
|
||||||
|
[abilities]
|
||||||
|
select_ability_1 = gp_dpad_up
|
||||||
|
select_ability_2 = gp_dpad_right
|
||||||
|
select_ability_3 = gp_dpad_down
|
||||||
|
select_ability_4 = gp_dpad_left
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
; Each action maps to a comma-separated list of keys/buttons - add as many as you like to the
|
||||||
|
; same action. Recognized tokens: printable keys ("w", "f"), named keys ("space", "ArrowUp"),
|
||||||
|
; and mouse buttons ("mouse_left", "mouse_right"), optionally prefixed "shift+".
|
||||||
|
|
||||||
|
[movement]
|
||||||
|
move_up = w, ArrowUp
|
||||||
|
move_down = s, ArrowDown
|
||||||
|
move_left = a, ArrowLeft
|
||||||
|
move_right = d, ArrowRight
|
||||||
|
|
||||||
|
[actions]
|
||||||
|
leap = space
|
||||||
|
block = b
|
||||||
|
|
||||||
|
[hands]
|
||||||
|
activate_right_hand = mouse_left
|
||||||
|
activate_left_hand = mouse_right
|
||||||
|
activate_right_hand_2 = shift+mouse_left
|
||||||
|
activate_left_hand_2 = shift+mouse_right
|
||||||
|
|
||||||
|
[abilities]
|
||||||
|
select_ability_1 = 1
|
||||||
|
select_ability_2 = 2
|
||||||
|
select_ability_3 = 3
|
||||||
|
select_ability_4 = 4
|
||||||
|
select_ability_5 = 5
|
||||||
|
select_ability_6 = 6
|
||||||
|
select_ability_7 = 7
|
||||||
|
select_ability_8 = 8
|
||||||
|
select_ability_9 = 9
|
||||||
|
select_ability_0 = 0
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Clockwise from east, matching the y-down world convention used throughout (see camera.py).
|
||||||
|
_EIGHT_WAY_DIRECTIONS: tuple[tuple[int, int], ...] = (
|
||||||
|
(1, 0),
|
||||||
|
(1, 1),
|
||||||
|
(0, 1),
|
||||||
|
(-1, 1),
|
||||||
|
(-1, 0),
|
||||||
|
(-1, -1),
|
||||||
|
(0, -1),
|
||||||
|
(1, -1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snap_to_8way(dx: float, dy: float) -> tuple[int, int]:
|
||||||
|
"""Snaps a free direction to the nearest of the 8 compass directions.
|
||||||
|
|
||||||
|
The mouse can point anywhere ("no fixed angles" while aiming), but combat/interaction still
|
||||||
|
resolves against the tile grid, so this is where continuous aim meets discrete tile logic.
|
||||||
|
"""
|
||||||
|
if dx == 0.0 and dy == 0.0:
|
||||||
|
return 0, 1
|
||||||
|
angle = math.atan2(dy, dx)
|
||||||
|
index = round(angle / (math.pi / 4)) % 8
|
||||||
|
return _EIGHT_WAY_DIRECTIONS[index]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cone_deviation(dx: int, dy: int, cone_degrees: float, rng) -> tuple[int, int]:
|
||||||
|
"""Randomly deviates a direction within +/- cone_degrees/2, snapped back to the grid.
|
||||||
|
|
||||||
|
This is where "the exact direction of the shot is randomized" within the aim cone happens -
|
||||||
|
the caller (e.g. perform_shoot) supplies the cone's size (from skill + weapon mods).
|
||||||
|
"""
|
||||||
|
if cone_degrees <= 0:
|
||||||
|
return dx, dy
|
||||||
|
base_angle = math.atan2(dy, dx)
|
||||||
|
deviation = math.radians(rng.uniform(-cone_degrees / 2, cone_degrees / 2))
|
||||||
|
angle = base_angle + deviation
|
||||||
|
return snap_to_8way(math.cos(angle), math.sin(angle))
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class OrthoCamera:
|
||||||
|
"""Top-down orthographic camera; world space is tile units with y increasing downward."""
|
||||||
|
|
||||||
|
def __init__(self, viewport_w: int, viewport_h: int, tiles_visible_y: float = 12.0):
|
||||||
|
self.x = 0.0
|
||||||
|
self.y = 0.0
|
||||||
|
self.viewport_w = viewport_w
|
||||||
|
self.viewport_h = viewport_h
|
||||||
|
self.tiles_visible_y = tiles_visible_y
|
||||||
|
|
||||||
|
def set_target(self, x: float, y: float) -> None:
|
||||||
|
self.x, self.y = x, y
|
||||||
|
|
||||||
|
def resize(self, viewport_w: int, viewport_h: int) -> None:
|
||||||
|
self.viewport_w, self.viewport_h = viewport_w, viewport_h
|
||||||
|
|
||||||
|
def _world_bounds(self) -> tuple[float, float, float, float]:
|
||||||
|
half_h = self.tiles_visible_y / 2
|
||||||
|
aspect = self.viewport_w / max(self.viewport_h, 1)
|
||||||
|
half_w = half_h * aspect
|
||||||
|
return self.x - half_w, self.x + half_w, self.y - half_h, self.y + half_h
|
||||||
|
|
||||||
|
def scale_offset(self) -> tuple[tuple[float, float], tuple[float, float]]:
|
||||||
|
"""Returns (scale, offset) such that ndc = world * scale + offset."""
|
||||||
|
left, right, top, bottom = self._world_bounds()
|
||||||
|
|
||||||
|
scale_x = 2.0 / (right - left)
|
||||||
|
scale_y = -2.0 / (bottom - top)
|
||||||
|
offset_x = -left * scale_x - 1.0
|
||||||
|
offset_y = 1.0 - top * scale_y
|
||||||
|
|
||||||
|
return (scale_x, scale_y), (offset_x, offset_y)
|
||||||
|
|
||||||
|
def screen_to_world(self, px: float, py: float) -> tuple[float, float]:
|
||||||
|
"""Unprojects a canvas logical-pixel position (0,0 top-left) into world tile coords."""
|
||||||
|
left, right, top, bottom = self._world_bounds()
|
||||||
|
wx = left + (px / max(self.viewport_w, 1)) * (right - left)
|
||||||
|
wy = top + (py / max(self.viewport_h, 1)) * (bottom - top)
|
||||||
|
return wx, wy
|
||||||
|
|
@ -0,0 +1,446 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, Sequence, TypeVar
|
||||||
|
|
||||||
|
from engine.entity import Entity
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.skills import default_bio
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ailment:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
severity: float = 1.0
|
||||||
|
progress: float = 0.0 # 0-100 meter for ailments that advance over time (e.g. heatstroke)
|
||||||
|
|
||||||
|
|
||||||
|
class _SlottedOverride(Protocol):
|
||||||
|
slot: str
|
||||||
|
removed: bool
|
||||||
|
|
||||||
|
|
||||||
|
_T = TypeVar("_T", bound=_SlottedOverride)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_slot(overrides: Sequence[_T], defaults: Sequence[_T], slot: str) -> _T | None:
|
||||||
|
"""Shared by body parts and clothing: an override wins (or explicitly removes), else fall back to the default."""
|
||||||
|
for item in overrides:
|
||||||
|
if item.slot == slot:
|
||||||
|
return None if item.removed else item
|
||||||
|
for item in defaults:
|
||||||
|
if item.slot == slot:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Organ:
|
||||||
|
"""A character's actual organ instance, living inside a specific body part (e.g. the heart
|
||||||
|
lives in the torso). Independent of BodyPart's one-per-slot model - a single body part can
|
||||||
|
host several organs at once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
organ_def_id: str
|
||||||
|
integrity: float = 100.0
|
||||||
|
removed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrganDef:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
host_slot: str # which body part slot this organ normally lives in (e.g. "torso")
|
||||||
|
check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.6, "speed": 0.4}
|
||||||
|
affects_organs: dict[str, float] = field(default_factory=dict) # other organ_id -> damage/tick per damage-fraction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BodyPart:
|
||||||
|
"""A character's actual body part instance (as opposed to its static def)."""
|
||||||
|
|
||||||
|
slot: str
|
||||||
|
part_def_id: str
|
||||||
|
integrity: float = 100.0
|
||||||
|
ailments: list[Ailment] = field(default_factory=list)
|
||||||
|
organs: list[Organ] = field(default_factory=list)
|
||||||
|
removed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BodyPartDef:
|
||||||
|
id: str
|
||||||
|
slot: str
|
||||||
|
texture: str | None = None
|
||||||
|
color: tuple[int, int, int] = (255, 0, 255)
|
||||||
|
bleeding_weight: float = 0.0
|
||||||
|
bleeding_speed: float = 0.0
|
||||||
|
skill_weights: dict[str, float] = field(default_factory=dict)
|
||||||
|
speed_factor: float = 0.0 # this part's contribution to movement speed when present (e.g. a leg)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClothingPiece:
|
||||||
|
"""A worn item instance. Mirrors BodyPart's override/fallback pattern (see _resolve_slot)."""
|
||||||
|
|
||||||
|
slot: str
|
||||||
|
item_def_id: str
|
||||||
|
removed: bool = False
|
||||||
|
inventory: Inventory | None = None # lazily created if the item def grants one (e.g. a backpack)
|
||||||
|
|
||||||
|
|
||||||
|
# Hand slots map to the arm body part that must be present to use them. The "_2" pair only
|
||||||
|
# applies to species with 4 arm slots (e.g. left_arm_2/right_arm_2) - a plain 2-armed human
|
||||||
|
# simply never has those hands available (wield_item fails the body-part check for them).
|
||||||
|
HAND_TO_ARM_SLOT = {
|
||||||
|
"right_hand": "right_arm",
|
||||||
|
"left_hand": "left_arm",
|
||||||
|
"right_hand_2": "right_arm_2",
|
||||||
|
"left_hand_2": "left_arm_2",
|
||||||
|
}
|
||||||
|
HAND_PAIRS = {
|
||||||
|
"right_hand": "left_hand",
|
||||||
|
"left_hand": "right_hand",
|
||||||
|
"right_hand_2": "left_hand_2",
|
||||||
|
"left_hand_2": "right_hand_2",
|
||||||
|
}
|
||||||
|
|
||||||
|
ARM_SLOTS = ("left_arm", "right_arm", "left_arm_2", "right_arm_2")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeldItem:
|
||||||
|
hand_slot: str
|
||||||
|
item_def_id: str
|
||||||
|
attachments: list[str] = field(default_factory=list) # attached mod item_def_ids (scopes, stocks, mags, ...)
|
||||||
|
loaded_ammo_type: str | None = None
|
||||||
|
loaded_ammo_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Character(Entity):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
species_id: str | None = None,
|
||||||
|
gender: str | None = None,
|
||||||
|
body_parts: list[BodyPart] | None = None,
|
||||||
|
default_body_parts: list[BodyPart] | None = None,
|
||||||
|
clothing: list[ClothingPiece] | None = None,
|
||||||
|
default_clothing: list[ClothingPiece] | None = None,
|
||||||
|
held_items: list[HeldItem] | None = None,
|
||||||
|
implants: list[str] | None = None,
|
||||||
|
is_blocking: bool = False,
|
||||||
|
strength: int = 10,
|
||||||
|
blood_percentage: float = 100.0,
|
||||||
|
mental_stats: dict[str, float] | None = None,
|
||||||
|
bio: dict[str, int] | None = None,
|
||||||
|
ability_cooldowns: dict[str, float] | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.species_id = species_id
|
||||||
|
self.gender = gender # open marker; valid values are species-defined, not enforced here
|
||||||
|
self.body_parts: list[BodyPart] = body_parts or []
|
||||||
|
self.default_body_parts: list[BodyPart] = default_body_parts or []
|
||||||
|
self.clothing: list[ClothingPiece] = clothing or []
|
||||||
|
self.default_clothing: list[ClothingPiece] = default_clothing or []
|
||||||
|
self.held_items: list[HeldItem] = held_items or []
|
||||||
|
self.implants: list[str] = implants or [] # installed implant item_def_ids - no hand/body slot needed
|
||||||
|
self.is_blocking = is_blocking
|
||||||
|
self.strength = strength
|
||||||
|
self.blood_percentage = blood_percentage
|
||||||
|
self.mental_stats: dict[str, float] | None = mental_stats # optional: NPC/player mechanics like insanity
|
||||||
|
self.bio: dict[str, int] = bio if bio is not None else default_bio()
|
||||||
|
self.ability_cooldowns: dict[str, float] = ability_cooldowns or {} # ability_id -> game-clock time it's ready again
|
||||||
|
|
||||||
|
def is_ability_ready(self, ability_id: str, now: float) -> bool:
|
||||||
|
return now >= self.ability_cooldowns.get(ability_id, 0.0)
|
||||||
|
|
||||||
|
def start_ability_cooldown(self, ability_id: str, now: float, duration: float) -> None:
|
||||||
|
if duration > 0.0:
|
||||||
|
self.ability_cooldowns[ability_id] = now + duration
|
||||||
|
|
||||||
|
def get_body_part(self, slot: str) -> BodyPart | None:
|
||||||
|
return _resolve_slot(self.body_parts, self.default_body_parts, slot)
|
||||||
|
|
||||||
|
def get_or_create_body_part_override(self, slot: str) -> BodyPart | None:
|
||||||
|
"""The override entry for `slot`, creating one (copied from the current resolved state,
|
||||||
|
including its ailments and organs) if none exists yet. Anything that needs to mutate a
|
||||||
|
body part in place (damage, ailments, organs, ...) should go through this rather than
|
||||||
|
get_body_part, which may return a shared default instance that must not be mutated
|
||||||
|
directly.
|
||||||
|
"""
|
||||||
|
for part in self.body_parts:
|
||||||
|
if part.slot == slot:
|
||||||
|
return part
|
||||||
|
current = self.get_body_part(slot)
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
override = BodyPart(
|
||||||
|
slot=slot,
|
||||||
|
part_def_id=current.part_def_id,
|
||||||
|
integrity=current.integrity,
|
||||||
|
ailments=list(current.ailments),
|
||||||
|
organs=[Organ(o.organ_def_id, o.integrity, o.removed) for o in current.organs],
|
||||||
|
)
|
||||||
|
self.body_parts.append(override)
|
||||||
|
return override
|
||||||
|
|
||||||
|
def resolved_body_parts(self) -> dict[str, BodyPart | None]:
|
||||||
|
slots = {p.slot for p in self.default_body_parts} | {p.slot for p in self.body_parts}
|
||||||
|
return {slot: self.get_body_part(slot) for slot in slots}
|
||||||
|
|
||||||
|
def get_clothing(self, slot: str) -> ClothingPiece | None:
|
||||||
|
return _resolve_slot(self.clothing, self.default_clothing, slot)
|
||||||
|
|
||||||
|
def resolved_clothing(self) -> dict[str, ClothingPiece | None]:
|
||||||
|
slots = {p.slot for p in self.default_clothing} | {p.slot for p in self.clothing}
|
||||||
|
return {slot: self.get_clothing(slot) for slot in slots}
|
||||||
|
|
||||||
|
def arm_slot_count(self) -> int:
|
||||||
|
"""How many arm slots this character currently has present (a missing/removed arm
|
||||||
|
doesn't count) - used to check whether a sleeved garment fits, see wear_item.
|
||||||
|
"""
|
||||||
|
return sum(1 for slot in ARM_SLOTS if self.get_body_part(slot) is not None)
|
||||||
|
|
||||||
|
def wear_item(self, slot: str, item_def_id: str, registry) -> bool:
|
||||||
|
"""Puts on a clothing item in the given slot, replacing whatever was already there.
|
||||||
|
|
||||||
|
Fails (no-op) if the item doesn't belong in that slot, if the wearer's species'
|
||||||
|
body_type isn't one the item is tailored for at all (see ItemDef.compatible_body_types -
|
||||||
|
e.g. a dog can't wear a human's jacket, full stop, regardless of sleeves), or - for a
|
||||||
|
sleeved garment within a compatible body type (see ItemDef.arm_slots_required) - the
|
||||||
|
wearer has more arms than the garment has sleeves for. A garment tailored for more arms
|
||||||
|
than the wearer has still fits fine, the extra sleeves just go unused (e.g. a 2-armed
|
||||||
|
human can wear a 4-armed reptilian's jacket, both being bipeds), but the reverse doesn't
|
||||||
|
work (a 4-armed wearer can't fit into a 2-sleeve jacket).
|
||||||
|
"""
|
||||||
|
item_def = registry.get_item_def(item_def_id)
|
||||||
|
if item_def.clothing_slot != slot:
|
||||||
|
return False
|
||||||
|
if item_def.compatible_body_types and self.species_id is not None:
|
||||||
|
species = registry.get_species_def(self.species_id)
|
||||||
|
if species.body_type not in item_def.compatible_body_types:
|
||||||
|
return False
|
||||||
|
if item_def.arm_slots_required > 0 and self.arm_slot_count() > item_def.arm_slots_required:
|
||||||
|
return False
|
||||||
|
self.clothing = [c for c in self.clothing if c.slot != slot]
|
||||||
|
self.clothing.append(ClothingPiece(slot, item_def_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def take_off(self, slot: str) -> None:
|
||||||
|
self.clothing = [c for c in self.clothing if c.slot != slot]
|
||||||
|
|
||||||
|
def get_equipped_inventory(self, slot: str, registry) -> Inventory | None:
|
||||||
|
"""Returns the inventory granted by a worn item (e.g. a backpack), creating it on first access."""
|
||||||
|
piece = self.get_clothing(slot)
|
||||||
|
if piece is None:
|
||||||
|
return None
|
||||||
|
item_def = registry.get_item_def(piece.item_def_id)
|
||||||
|
if item_def.grants_inventory_size is None:
|
||||||
|
return None
|
||||||
|
if piece.inventory is None:
|
||||||
|
piece.inventory = Inventory(*item_def.grants_inventory_size)
|
||||||
|
return piece.inventory
|
||||||
|
|
||||||
|
def get_held_item(self, hand_slot: str) -> HeldItem | None:
|
||||||
|
return next((h for h in self.held_items if h.hand_slot == hand_slot), None)
|
||||||
|
|
||||||
|
def install_implant(self, item_def_id: str, registry) -> bool:
|
||||||
|
"""Installs a slotless implant - no hand or body slot needed, unlike wielding/clothing."""
|
||||||
|
item_def = registry.get_item_def(item_def_id)
|
||||||
|
if not item_def.is_implant or item_def_id in self.implants:
|
||||||
|
return False
|
||||||
|
self.implants.append(item_def_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def uninstall_implant(self, item_def_id: str) -> None:
|
||||||
|
if item_def_id in self.implants:
|
||||||
|
self.implants.remove(item_def_id)
|
||||||
|
|
||||||
|
def wield_item(self, hand_slot: str, item_def_id: str, registry) -> bool:
|
||||||
|
"""Puts an item in the given hand; two-handed items also occupy its paired hand.
|
||||||
|
|
||||||
|
Fails (no-op) if the corresponding arm is missing, or (for two-handed items) the
|
||||||
|
paired hand's arm is missing too.
|
||||||
|
"""
|
||||||
|
item_def = registry.get_item_def(item_def_id)
|
||||||
|
arm_slot = HAND_TO_ARM_SLOT.get(hand_slot)
|
||||||
|
if arm_slot is None or self.get_body_part(arm_slot) is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hands_needed = [hand_slot]
|
||||||
|
if item_def.hands_required >= 2:
|
||||||
|
paired = HAND_PAIRS.get(hand_slot)
|
||||||
|
paired_arm = HAND_TO_ARM_SLOT.get(paired) if paired else None
|
||||||
|
if paired is None or paired_arm is None or self.get_body_part(paired_arm) is None:
|
||||||
|
return False
|
||||||
|
hands_needed.append(paired)
|
||||||
|
|
||||||
|
self.held_items = [h for h in self.held_items if h.hand_slot not in hands_needed]
|
||||||
|
for hand in hands_needed:
|
||||||
|
self.held_items.append(HeldItem(hand, item_def_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unwield(self, hand_slot: str) -> None:
|
||||||
|
self.held_items = [h for h in self.held_items if h.hand_slot != hand_slot]
|
||||||
|
|
||||||
|
def wielded_shield(self, registry):
|
||||||
|
"""The first held item that acts as a shield (has a block bonus or blockable types), if any."""
|
||||||
|
for held in self.held_items:
|
||||||
|
item_def = registry.get_item_def(held.item_def_id)
|
||||||
|
if item_def.shield_block_bonus > 0 or item_def.blockable_projectile_types:
|
||||||
|
return item_def
|
||||||
|
return None
|
||||||
|
|
||||||
|
def attach_mod(self, hand_slot: str, mod_item_def_id: str, registry) -> bool:
|
||||||
|
"""Attaches a mod (scope/stock/magazine/...) to the item held in `hand_slot`."""
|
||||||
|
held = self.get_held_item(hand_slot)
|
||||||
|
if held is None:
|
||||||
|
return False
|
||||||
|
weapon_def = registry.get_item_def(held.item_def_id)
|
||||||
|
mod_def = registry.get_item_def(mod_item_def_id)
|
||||||
|
if mod_def.attachment_kind is None:
|
||||||
|
return False
|
||||||
|
if weapon_def.magazine_size <= 0 and weapon_def.aim_cone_degrees <= 0:
|
||||||
|
return False # only ranged/ammo weapons take mods
|
||||||
|
held.attachments.append(mod_item_def_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def magazine_capacity(self, hand_slot: str, registry) -> int:
|
||||||
|
"""The held weapon's ammo capacity: its base magazine_size plus any attached mod bonuses."""
|
||||||
|
held = self.get_held_item(hand_slot)
|
||||||
|
if held is None:
|
||||||
|
return 0
|
||||||
|
weapon_def = registry.get_item_def(held.item_def_id)
|
||||||
|
bonus = sum(registry.get_item_def(mod_id).magazine_capacity_bonus for mod_id in held.attachments)
|
||||||
|
return weapon_def.magazine_size + bonus
|
||||||
|
|
||||||
|
def reload(self, hand_slot: str, registry) -> bool:
|
||||||
|
"""Manually reloads the held weapon from the character's own inventory.
|
||||||
|
|
||||||
|
Tops up from whichever compatible ammo type is already loaded (if any and still
|
||||||
|
available), otherwise the first compatible type found. Fails as a no-op if there's no
|
||||||
|
ammo-fed weapon held, no inventory, no matching ammo, or the magazine is already full.
|
||||||
|
"""
|
||||||
|
held = self.get_held_item(hand_slot)
|
||||||
|
if held is None or self.inventory is None:
|
||||||
|
return False
|
||||||
|
weapon_def = registry.get_item_def(held.item_def_id)
|
||||||
|
if weapon_def.magazine_size <= 0:
|
||||||
|
return False
|
||||||
|
capacity = self.magazine_capacity(hand_slot, registry)
|
||||||
|
if held.loaded_ammo_count >= capacity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ammo_type = held.loaded_ammo_type if held.loaded_ammo_count > 0 else None
|
||||||
|
placed = next(
|
||||||
|
(
|
||||||
|
p
|
||||||
|
for p in self.inventory.items()
|
||||||
|
if registry.get_item_def(p.item.def_id).ammo_type
|
||||||
|
in ((ammo_type,) if ammo_type else weapon_def.compatible_ammo_types)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if placed is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ammo_def = registry.get_item_def(placed.item.def_id)
|
||||||
|
take = min(capacity - held.loaded_ammo_count, placed.item.quantity)
|
||||||
|
if take <= 0:
|
||||||
|
return False
|
||||||
|
placed.item.quantity -= take
|
||||||
|
if placed.item.quantity <= 0:
|
||||||
|
self.inventory.remove(placed.item.item_id, ammo_def)
|
||||||
|
held.loaded_ammo_type = ammo_def.ammo_type
|
||||||
|
held.loaded_ammo_count += take
|
||||||
|
return True
|
||||||
|
|
||||||
|
def effective_speed(self, base_speed: float, registry) -> float:
|
||||||
|
"""Movement speed = base_speed * sum(each present body part's own speed_factor,
|
||||||
|
scaled by its current integrity), further reduced by organ damage (e.g. a weakened
|
||||||
|
heart or lungs). A missing leg contributes nothing; an installed cybernetic replacement
|
||||||
|
(e.g. a bionic leg) contributes its own (possibly higher) factor instead of the default
|
||||||
|
part's - no separate mechanism needed, it's just a slot override.
|
||||||
|
"""
|
||||||
|
total_factor = 0.0
|
||||||
|
for part in self.resolved_body_parts().values():
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
part_def = registry.get_body_part_def(part.part_def_id)
|
||||||
|
if part_def.speed_factor <= 0.0:
|
||||||
|
continue
|
||||||
|
total_factor += part_def.speed_factor * (part.integrity / 100.0)
|
||||||
|
return base_speed * total_factor * (1.0 - self.organ_penalty("speed", registry))
|
||||||
|
|
||||||
|
def effective_strength(self, registry) -> float:
|
||||||
|
"""Raw strength reduced by organ damage (e.g. a weakened heart saps physical power)."""
|
||||||
|
return self.strength * (1.0 - self.organ_penalty("strength", registry))
|
||||||
|
|
||||||
|
def can_perform_ability(self, ability_id: str, registry) -> bool:
|
||||||
|
"""Whether this character's species defines `ability_id` and its required body parts are all present."""
|
||||||
|
if self.species_id is None:
|
||||||
|
return False
|
||||||
|
species = registry.get_species_def(self.species_id)
|
||||||
|
ability = next((a for a in species.abilities if a.id == ability_id), None)
|
||||||
|
if ability is None:
|
||||||
|
return False
|
||||||
|
return all(self.get_body_part(slot) is not None for slot in ability.required_slots)
|
||||||
|
|
||||||
|
def blood_status(self, species_def) -> str:
|
||||||
|
if self.blood_percentage <= species_def.blood_critical_threshold:
|
||||||
|
return "critical"
|
||||||
|
if self.blood_percentage <= species_def.blood_danger_threshold:
|
||||||
|
return "danger"
|
||||||
|
return "normal"
|
||||||
|
|
||||||
|
def skill_penalty(self, skill_id: str, registry) -> float:
|
||||||
|
"""Fraction (0..1) of `skill_id` lost to missing/damaged body parts, weighted per the species' default parts."""
|
||||||
|
total_weight = 0.0
|
||||||
|
lost_weight = 0.0
|
||||||
|
for default_part in self.default_body_parts:
|
||||||
|
part_def = registry.get_body_part_def(default_part.part_def_id)
|
||||||
|
weight = part_def.skill_weights.get(skill_id, 0.0)
|
||||||
|
if weight == 0.0:
|
||||||
|
continue
|
||||||
|
total_weight += weight
|
||||||
|
current = self.get_body_part(default_part.slot)
|
||||||
|
integrity_fraction = 0.0 if current is None else current.integrity / 100.0
|
||||||
|
lost_weight += weight * (1.0 - integrity_fraction)
|
||||||
|
if total_weight == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return lost_weight / total_weight
|
||||||
|
|
||||||
|
def organ_penalty(self, check_id: str, registry) -> float:
|
||||||
|
"""Fraction (0..1) of `check_id` lost to damaged/missing organs, weighted per organ -
|
||||||
|
mirrors skill_penalty exactly, but over organs instead of body parts, and over whatever
|
||||||
|
named check an organ's check_weights lists (not just D&D skills - e.g. "strength",
|
||||||
|
"speed", or a not-yet-implemented "constitution"/"poison_resistance").
|
||||||
|
"""
|
||||||
|
total_weight = 0.0
|
||||||
|
lost_weight = 0.0
|
||||||
|
for part in self.resolved_body_parts().values():
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
for organ in part.organs:
|
||||||
|
organ_def = registry.get_organ_def(organ.organ_def_id)
|
||||||
|
weight = organ_def.check_weights.get(check_id, 0.0)
|
||||||
|
if weight == 0.0:
|
||||||
|
continue
|
||||||
|
total_weight += weight
|
||||||
|
integrity_fraction = 0.0 if organ.removed else organ.integrity / 100.0
|
||||||
|
lost_weight += weight * (1.0 - integrity_fraction)
|
||||||
|
if total_weight == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return lost_weight / total_weight
|
||||||
|
|
||||||
|
def total_check_penalty(self, check_id: str, registry) -> float:
|
||||||
|
"""Combined penalty from both damaged body parts and damaged organs for a named check,
|
||||||
|
applied multiplicatively (as independent sources of lost effectiveness) so it can never
|
||||||
|
exceed 1.0 lost. Reduces to skill_penalty alone when no organ affects this check or all
|
||||||
|
relevant organs are healthy - existing skill-only checks are unaffected either way.
|
||||||
|
"""
|
||||||
|
remaining = (1.0 - self.skill_penalty(check_id, registry)) * (1.0 - self.organ_penalty(check_id, registry))
|
||||||
|
return 1.0 - remaining
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
from engine.character_library import CharacterLibrary
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.skills import default_bio
|
||||||
|
|
||||||
|
DEFAULT_SKILL_POINTS = 10
|
||||||
|
DEFAULT_MAX_SKILL_LEVEL = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CharacterCreator:
|
||||||
|
"""Pure state/logic for building a new persistent character: pick a species, a gender
|
||||||
|
valid for that species (species define their own valid genders - see SpeciesDef.genders),
|
||||||
|
a name, and spend a fixed point budget across the classic D&D-style bio skills
|
||||||
|
(engine/skills.py) - then confirm() builds an actual Character and saves it into the
|
||||||
|
CharacterLibrary under a given account, ready to appear on the StartScreen.
|
||||||
|
|
||||||
|
No rendering here - same boundary as StartScreen/CharacterMenu/LayerPickerMenu/AbilityBar
|
||||||
|
(engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, and skill
|
||||||
|
+/- steppers is a follow-up UI-layer task. This is the state/logic side: valid choices,
|
||||||
|
point-budget bookkeeping, and producing the finished Character.
|
||||||
|
"""
|
||||||
|
|
||||||
|
registry: DefRegistry
|
||||||
|
skill_points_budget: int = DEFAULT_SKILL_POINTS
|
||||||
|
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
species_id: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
bio: dict[str, int] = field(default_factory=default_bio)
|
||||||
|
|
||||||
|
def set_name(self, name: str) -> None:
|
||||||
|
self.name = name.strip()
|
||||||
|
|
||||||
|
def set_species(self, species_id: str) -> bool:
|
||||||
|
"""Picks a species, resetting gender to that species' first valid option if the
|
||||||
|
current gender (from a previous species pick) isn't valid for it.
|
||||||
|
"""
|
||||||
|
if species_id not in self.registry.species_defs:
|
||||||
|
return False
|
||||||
|
self.species_id = species_id
|
||||||
|
species = self.registry.get_species_def(species_id)
|
||||||
|
if self.gender not in species.genders:
|
||||||
|
self.gender = species.genders[0] if species.genders else None
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_gender(self, gender: str) -> bool:
|
||||||
|
if self.species_id is None:
|
||||||
|
return False
|
||||||
|
species = self.registry.get_species_def(self.species_id)
|
||||||
|
if gender not in species.genders:
|
||||||
|
return False
|
||||||
|
self.gender = gender
|
||||||
|
return True
|
||||||
|
|
||||||
|
def spent_skill_points(self) -> int:
|
||||||
|
return sum(self.bio.values())
|
||||||
|
|
||||||
|
def remaining_skill_points(self) -> int:
|
||||||
|
return self.skill_points_budget - self.spent_skill_points()
|
||||||
|
|
||||||
|
def increase_skill(self, skill_id: str) -> bool:
|
||||||
|
if skill_id not in self.bio:
|
||||||
|
return False
|
||||||
|
if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level:
|
||||||
|
return False
|
||||||
|
self.bio[skill_id] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def decrease_skill(self, skill_id: str) -> bool:
|
||||||
|
if skill_id not in self.bio or self.bio[skill_id] <= 0:
|
||||||
|
return False
|
||||||
|
self.bio[skill_id] -= 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def preview_body_parts(self) -> list[BodyPart]:
|
||||||
|
"""Read-only preview of the anatomy this character will start with, for a UI to render
|
||||||
|
without needing to build a whole Character first.
|
||||||
|
"""
|
||||||
|
if self.species_id is None:
|
||||||
|
return []
|
||||||
|
return self.registry.new_default_body_parts(self.species_id)
|
||||||
|
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return bool(self.name) and self.species_id is not None and self.gender is not None
|
||||||
|
|
||||||
|
def build_character(self) -> Character | None:
|
||||||
|
if not self.is_ready():
|
||||||
|
return None
|
||||||
|
assert self.species_id is not None
|
||||||
|
return Character(
|
||||||
|
name=self.name,
|
||||||
|
species_id=self.species_id,
|
||||||
|
gender=self.gender,
|
||||||
|
default_body_parts=self.registry.new_default_body_parts(self.species_id),
|
||||||
|
bio=dict(self.bio),
|
||||||
|
)
|
||||||
|
|
||||||
|
def confirm(self, library: CharacterLibrary, account_id: str, character_id: str) -> Character | None:
|
||||||
|
"""Builds the character and saves it into the library under (account_id, character_id) -
|
||||||
|
the "create" action once name/species/gender/skills are all set. No-op (returns None,
|
||||||
|
nothing saved) if the creator isn't ready yet.
|
||||||
|
"""
|
||||||
|
character = self.build_character()
|
||||||
|
if character is None:
|
||||||
|
return None
|
||||||
|
library.save_character(account_id, character_id, character)
|
||||||
|
return character
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character, ClothingPiece, HeldItem, Organ
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS library_characters (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
species_id TEXT,
|
||||||
|
gender TEXT,
|
||||||
|
blood_percentage REAL NOT NULL DEFAULT 100.0,
|
||||||
|
strength INTEGER NOT NULL DEFAULT 10,
|
||||||
|
PRIMARY KEY (account_id, character_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_body_parts (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
part_def_id TEXT NOT NULL,
|
||||||
|
integrity REAL NOT NULL DEFAULT 100.0,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (account_id, character_id, is_default, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_body_part_ailments (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
ailment_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
severity REAL NOT NULL DEFAULT 1.0,
|
||||||
|
progress REAL NOT NULL DEFAULT 0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_organs (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
organ_def_id TEXT NOT NULL,
|
||||||
|
integrity REAL NOT NULL DEFAULT 100.0,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_clothing (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
item_def_id TEXT NOT NULL,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (account_id, character_id, is_default, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_held_items (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
hand_slot TEXT NOT NULL,
|
||||||
|
item_def_id TEXT NOT NULL,
|
||||||
|
loaded_ammo_type TEXT,
|
||||||
|
loaded_ammo_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (account_id, character_id, hand_slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_held_item_attachments (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
hand_slot TEXT NOT NULL,
|
||||||
|
attachment_item_def_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_implants (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
item_def_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, character_id, item_def_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_mental_stats (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
stat TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, character_id, stat)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_bio (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, character_id, skill)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_inventories (
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
character_id TEXT NOT NULL,
|
||||||
|
inventory_id TEXT PRIMARY KEY,
|
||||||
|
owner_clothing_is_default INTEGER,
|
||||||
|
owner_clothing_slot TEXT,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS library_inventory_items (
|
||||||
|
inventory_id TEXT NOT NULL REFERENCES library_inventories(inventory_id),
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
def_id TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (inventory_id, item_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CLEAR_TABLES = (
|
||||||
|
# library_inventory_items has no account_id/character_id of its own (see _clear_existing) - it's
|
||||||
|
# keyed only by inventory_id, so it's cleared via a join through library_inventories instead.
|
||||||
|
"library_inventories",
|
||||||
|
"library_bio",
|
||||||
|
"library_mental_stats",
|
||||||
|
"library_implants",
|
||||||
|
"library_held_item_attachments",
|
||||||
|
"library_held_items",
|
||||||
|
"library_clothing",
|
||||||
|
"library_organs",
|
||||||
|
"library_body_part_ailments",
|
||||||
|
"library_body_parts",
|
||||||
|
"library_characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterLibrary:
|
||||||
|
"""Cross-session character persistence, keyed by (account_id, character_id) - separate
|
||||||
|
from WorldRepository's per-world save file. A character saved here has no position or
|
||||||
|
map; it's a portable template a user can spawn into any world (single-player continuation
|
||||||
|
or joining someone else's multiplayer session with a character built up over time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, conn: sqlite3.Connection):
|
||||||
|
self.conn = conn
|
||||||
|
self.conn.executescript(SCHEMA)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, path: str | Path) -> "CharacterLibrary":
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
return cls(conn)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def list_characters(self, account_id: str) -> list[tuple[str, str, str | None]]:
|
||||||
|
"""(character_id, name, species_id) for every character saved under this account."""
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
return cur.execute(
|
||||||
|
"SELECT character_id, name, species_id FROM library_characters WHERE account_id = ? ORDER BY name",
|
||||||
|
(account_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def _clear_existing(self, cur: sqlite3.Cursor, account_id: str, character_id: str) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM library_inventory_items WHERE inventory_id IN"
|
||||||
|
" (SELECT inventory_id FROM library_inventories WHERE account_id = ? AND character_id = ?)",
|
||||||
|
(account_id, character_id),
|
||||||
|
)
|
||||||
|
for table in _CLEAR_TABLES:
|
||||||
|
cur.execute(f"DELETE FROM {table} WHERE account_id = ? AND character_id = ?", (account_id, character_id))
|
||||||
|
|
||||||
|
def delete_character(self, account_id: str, character_id: str) -> None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
self._clear_existing(cur, account_id, character_id)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def save_character(self, account_id: str, character_id: str, character: Character) -> None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
self._clear_existing(cur, account_id, character_id)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_characters (account_id, character_id, name, species_id, gender,"
|
||||||
|
" blood_percentage, strength) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
account_id, character_id, character.name, character.species_id, character.gender,
|
||||||
|
character.blood_percentage, character.strength,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for is_default, parts in ((0, character.body_parts), (1, character.default_body_parts)):
|
||||||
|
for part in parts:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_body_parts (account_id, character_id, is_default, slot, part_def_id,"
|
||||||
|
" integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)),
|
||||||
|
)
|
||||||
|
for ailment in part.ailments:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_body_part_ailments (account_id, character_id, is_default, slot,"
|
||||||
|
" ailment_id, name, severity, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
account_id, character_id, is_default, part.slot, ailment.id, ailment.name,
|
||||||
|
ailment.severity, ailment.progress,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for organ in part.organs:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_organs (account_id, character_id, is_default, slot, organ_def_id,"
|
||||||
|
" integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, is_default, part.slot, organ.organ_def_id, organ.integrity, int(organ.removed)),
|
||||||
|
)
|
||||||
|
|
||||||
|
for is_default, pieces in ((0, character.clothing), (1, character.default_clothing)):
|
||||||
|
for piece in pieces:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_clothing (account_id, character_id, is_default, slot, item_def_id, removed)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)),
|
||||||
|
)
|
||||||
|
if piece.inventory is not None:
|
||||||
|
self._save_inventory(
|
||||||
|
cur, f"lib-{account_id}-{character_id}-clothing-{is_default}-{piece.slot}",
|
||||||
|
account_id, character_id, piece.inventory, owner_clothing=(is_default, piece.slot),
|
||||||
|
)
|
||||||
|
|
||||||
|
for held in character.held_items:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_held_items (account_id, character_id, hand_slot, item_def_id,"
|
||||||
|
" loaded_ammo_type, loaded_ammo_count) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, held.hand_slot, held.item_def_id, held.loaded_ammo_type, held.loaded_ammo_count),
|
||||||
|
)
|
||||||
|
for attachment_id in held.attachments:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_held_item_attachments (account_id, character_id, hand_slot,"
|
||||||
|
" attachment_item_def_id) VALUES (?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, held.hand_slot, attachment_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
for implant_item_def_id in character.implants:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_implants (account_id, character_id, item_def_id) VALUES (?, ?, ?)",
|
||||||
|
(account_id, character_id, implant_item_def_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if character.mental_stats is not None:
|
||||||
|
for stat, value in character.mental_stats.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_mental_stats (account_id, character_id, stat, value) VALUES (?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, stat, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
for skill, level in character.bio.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_bio (account_id, character_id, skill, level) VALUES (?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, skill, level),
|
||||||
|
)
|
||||||
|
|
||||||
|
if character.inventory is not None:
|
||||||
|
self._save_inventory(cur, f"lib-{account_id}-{character_id}", account_id, character_id, character.inventory)
|
||||||
|
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def _save_inventory(
|
||||||
|
self,
|
||||||
|
cur: sqlite3.Cursor,
|
||||||
|
inventory_id: str,
|
||||||
|
account_id: str,
|
||||||
|
character_id: str,
|
||||||
|
inventory: Inventory,
|
||||||
|
owner_clothing: tuple[int, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
owner_clothing_is_default, owner_clothing_slot = owner_clothing if owner_clothing else (None, None)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_inventories (account_id, character_id, inventory_id, owner_clothing_is_default,"
|
||||||
|
" owner_clothing_slot, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(account_id, character_id, inventory_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height),
|
||||||
|
)
|
||||||
|
for placed in inventory.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO library_inventory_items (inventory_id, item_id, def_id, quantity, x, y)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(inventory_id, placed.item.item_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_character(self, account_id: str, character_id: str, registry: DefRegistry) -> Character | None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT name, species_id, gender, blood_percentage, strength FROM library_characters"
|
||||||
|
" WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
name, species_id, gender, blood_percentage, strength = row
|
||||||
|
|
||||||
|
body_parts: list[BodyPart] = []
|
||||||
|
default_body_parts: list[BodyPart] = []
|
||||||
|
part_rows = cur.execute(
|
||||||
|
"SELECT is_default, slot, part_def_id, integrity, removed FROM library_body_parts"
|
||||||
|
" WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
).fetchall()
|
||||||
|
for is_default, slot, part_def_id, integrity, removed in part_rows:
|
||||||
|
part = BodyPart(slot=slot, part_def_id=part_def_id, integrity=integrity, removed=bool(removed))
|
||||||
|
ailment_rows = cur.execute(
|
||||||
|
"SELECT ailment_id, name, severity, progress FROM library_body_part_ailments"
|
||||||
|
" WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?",
|
||||||
|
(account_id, character_id, is_default, slot),
|
||||||
|
).fetchall()
|
||||||
|
for ailment_id, ailment_name, severity, progress in ailment_rows:
|
||||||
|
part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity, progress=progress))
|
||||||
|
organ_rows = cur.execute(
|
||||||
|
"SELECT organ_def_id, integrity, removed FROM library_organs"
|
||||||
|
" WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?",
|
||||||
|
(account_id, character_id, is_default, slot),
|
||||||
|
).fetchall()
|
||||||
|
for organ_def_id, organ_integrity, organ_removed in organ_rows:
|
||||||
|
part.organs.append(Organ(organ_def_id=organ_def_id, integrity=organ_integrity, removed=bool(organ_removed)))
|
||||||
|
(default_body_parts if is_default else body_parts).append(part)
|
||||||
|
|
||||||
|
clothing: list[ClothingPiece] = []
|
||||||
|
default_clothing: list[ClothingPiece] = []
|
||||||
|
clothing_rows = cur.execute(
|
||||||
|
"SELECT is_default, slot, item_def_id, removed FROM library_clothing"
|
||||||
|
" WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
).fetchall()
|
||||||
|
for is_default, slot, item_def_id, removed in clothing_rows:
|
||||||
|
piece = ClothingPiece(slot=slot, item_def_id=item_def_id, removed=bool(removed))
|
||||||
|
inv_row = cur.execute(
|
||||||
|
"SELECT inventory_id, width, height FROM library_inventories"
|
||||||
|
" WHERE account_id = ? AND character_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?",
|
||||||
|
(account_id, character_id, is_default, slot),
|
||||||
|
).fetchone()
|
||||||
|
if inv_row is not None:
|
||||||
|
inventory_id, width, height = inv_row
|
||||||
|
piece.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
|
||||||
|
(default_clothing if is_default else clothing).append(piece)
|
||||||
|
|
||||||
|
held_items: list[HeldItem] = []
|
||||||
|
held_rows = cur.execute(
|
||||||
|
"SELECT hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count FROM library_held_items"
|
||||||
|
" WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
).fetchall()
|
||||||
|
for hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count in held_rows:
|
||||||
|
attachment_rows = cur.execute(
|
||||||
|
"SELECT attachment_item_def_id FROM library_held_item_attachments"
|
||||||
|
" WHERE account_id = ? AND character_id = ? AND hand_slot = ?",
|
||||||
|
(account_id, character_id, hand_slot),
|
||||||
|
).fetchall()
|
||||||
|
held_items.append(
|
||||||
|
HeldItem(
|
||||||
|
hand_slot=hand_slot,
|
||||||
|
item_def_id=item_def_id,
|
||||||
|
attachments=[a for (a,) in attachment_rows],
|
||||||
|
loaded_ammo_type=loaded_ammo_type,
|
||||||
|
loaded_ammo_count=loaded_ammo_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
implants = [
|
||||||
|
item_def_id
|
||||||
|
for (item_def_id,) in cur.execute(
|
||||||
|
"SELECT item_def_id FROM library_implants WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
mental_stats = {
|
||||||
|
stat: value
|
||||||
|
for stat, value in cur.execute(
|
||||||
|
"SELECT stat, value FROM library_mental_stats WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
)
|
||||||
|
} or None
|
||||||
|
|
||||||
|
bio = {
|
||||||
|
skill: level
|
||||||
|
for skill, level in cur.execute(
|
||||||
|
"SELECT skill, level FROM library_bio WHERE account_id = ? AND character_id = ?",
|
||||||
|
(account_id, character_id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
character = Character(
|
||||||
|
entity_id=character_id,
|
||||||
|
name=name,
|
||||||
|
def_id=None,
|
||||||
|
position=None,
|
||||||
|
species_id=species_id,
|
||||||
|
gender=gender,
|
||||||
|
blood_percentage=blood_percentage,
|
||||||
|
strength=strength,
|
||||||
|
body_parts=body_parts,
|
||||||
|
default_body_parts=default_body_parts,
|
||||||
|
clothing=clothing,
|
||||||
|
default_clothing=default_clothing,
|
||||||
|
held_items=held_items,
|
||||||
|
implants=implants,
|
||||||
|
mental_stats=mental_stats,
|
||||||
|
bio=bio,
|
||||||
|
)
|
||||||
|
|
||||||
|
inv_row = cur.execute(
|
||||||
|
"SELECT inventory_id, width, height FROM library_inventories"
|
||||||
|
" WHERE account_id = ? AND character_id = ? AND owner_clothing_slot IS NULL",
|
||||||
|
(account_id, character_id),
|
||||||
|
).fetchone()
|
||||||
|
if inv_row is not None:
|
||||||
|
inventory_id, width, height = inv_row
|
||||||
|
character.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
|
||||||
|
|
||||||
|
return character
|
||||||
|
|
||||||
|
def _load_inventory(
|
||||||
|
self, cur: sqlite3.Cursor, inventory_id: str, width: int, height: int, registry: DefRegistry
|
||||||
|
) -> Inventory:
|
||||||
|
inventory = Inventory(width, height)
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT item_id, def_id, quantity, x, y FROM library_inventory_items WHERE inventory_id = ?",
|
||||||
|
(inventory_id,),
|
||||||
|
).fetchall()
|
||||||
|
for item_id, def_id, quantity, ix, iy in rows:
|
||||||
|
item_def = registry.get_item_def(def_id)
|
||||||
|
inventory.place(ItemInstance(item_id, def_id, quantity), item_def, ix, iy)
|
||||||
|
return inventory
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_KEYBINDINGS: dict[str, list[str]] = {
|
||||||
|
"move_up": ["w", "ArrowUp"],
|
||||||
|
"move_down": ["s", "ArrowDown"],
|
||||||
|
"move_left": ["a", "ArrowLeft"],
|
||||||
|
"move_right": ["d", "ArrowRight"],
|
||||||
|
"leap": ["space"],
|
||||||
|
"block": ["b"],
|
||||||
|
"activate_right_hand": ["mouse_left"],
|
||||||
|
"activate_left_hand": ["mouse_right"],
|
||||||
|
"activate_right_hand_2": ["shift+mouse_left"],
|
||||||
|
"activate_left_hand_2": ["shift+mouse_right"],
|
||||||
|
"select_ability_1": ["1"],
|
||||||
|
"select_ability_2": ["2"],
|
||||||
|
"select_ability_3": ["3"],
|
||||||
|
"select_ability_4": ["4"],
|
||||||
|
"select_ability_5": ["5"],
|
||||||
|
"select_ability_6": ["6"],
|
||||||
|
"select_ability_7": ["7"],
|
||||||
|
"select_ability_8": ["8"],
|
||||||
|
"select_ability_9": ["9"],
|
||||||
|
"select_ability_0": ["0"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human-friendly config names for keys that don't have a printable representation.
|
||||||
|
_KEY_ALIASES = {"space": " "}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(key: str) -> str:
|
||||||
|
return _KEY_ALIASES.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyBindings:
|
||||||
|
"""Action -> list of keys, loaded from an editable .conf file (arrays, so several keys
|
||||||
|
can share one action). Falls back to DEFAULT_KEYBINDINGS for anything the file omits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bindings: dict[str, list[str]]):
|
||||||
|
self.bindings = bindings
|
||||||
|
self._action_by_key: dict[str, str] = {}
|
||||||
|
for action, keys in bindings.items():
|
||||||
|
for key in keys:
|
||||||
|
self._action_by_key[_normalize(key)] = action
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "KeyBindings":
|
||||||
|
bindings = {action: list(keys) for action, keys in DEFAULT_KEYBINDINGS.items()}
|
||||||
|
if path.exists():
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.read(path)
|
||||||
|
for section in parser.sections():
|
||||||
|
for action, raw in parser.items(section):
|
||||||
|
keys = [k.strip() for k in raw.split(",") if k.strip()]
|
||||||
|
if keys:
|
||||||
|
bindings[action] = keys
|
||||||
|
return cls(bindings)
|
||||||
|
|
||||||
|
def action_for_key(self, key: str) -> str | None:
|
||||||
|
return self._action_by_key.get(key)
|
||||||
|
|
||||||
|
def keys_for_action(self, action: str) -> list[str]:
|
||||||
|
return self.bindings.get(action, [])
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character, ClothingPiece
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
from engine.tile import TILE_LAYERS, Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maps (
|
||||||
|
map_id TEXT PRIMARY KEY,
|
||||||
|
source_def TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS map_embeddings (
|
||||||
|
parent_map_id TEXT NOT NULL REFERENCES maps(map_id),
|
||||||
|
child_map_id TEXT NOT NULL REFERENCES maps(map_id),
|
||||||
|
anchor_x INTEGER NOT NULL,
|
||||||
|
anchor_y INTEGER NOT NULL,
|
||||||
|
anchor_z INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (parent_map_id, child_map_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tile_overrides (
|
||||||
|
map_id TEXT NOT NULL REFERENCES maps(map_id),
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
z INTEGER NOT NULL,
|
||||||
|
subfloor TEXT,
|
||||||
|
subfloor_integrity REAL,
|
||||||
|
flooring TEXT,
|
||||||
|
flooring_integrity REAL,
|
||||||
|
room TEXT,
|
||||||
|
room_integrity REAL,
|
||||||
|
roof TEXT,
|
||||||
|
roof_integrity REAL,
|
||||||
|
PRIMARY KEY (map_id, x, y, z)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
|
entity_id TEXT PRIMARY KEY,
|
||||||
|
def_id TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
map_id TEXT NOT NULL REFERENCES maps(map_id),
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
z INTEGER NOT NULL,
|
||||||
|
species_id TEXT,
|
||||||
|
gender TEXT,
|
||||||
|
blood_percentage REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_body_parts (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
part_def_id TEXT NOT NULL,
|
||||||
|
integrity REAL NOT NULL DEFAULT 100.0,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (entity_id, is_default, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_body_part_ailments (
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
ailment_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
severity REAL NOT NULL DEFAULT 1.0,
|
||||||
|
FOREIGN KEY (entity_id, is_default, slot) REFERENCES character_body_parts(entity_id, is_default, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_clothing (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
item_def_id TEXT NOT NULL,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (entity_id, is_default, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_mental_stats (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
||||||
|
stat TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
PRIMARY KEY (entity_id, stat)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS character_bio (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (entity_id, skill)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventories (
|
||||||
|
inventory_id TEXT PRIMARY KEY,
|
||||||
|
owner_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
||||||
|
owner_clothing_is_default INTEGER,
|
||||||
|
owner_clothing_slot TEXT,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||||
|
item_id TEXT PRIMARY KEY,
|
||||||
|
inventory_id TEXT NOT NULL REFERENCES inventories(inventory_id),
|
||||||
|
def_id TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SAVE_CLEAR_TABLES = (
|
||||||
|
"inventory_items",
|
||||||
|
"inventories",
|
||||||
|
"character_bio",
|
||||||
|
"character_mental_stats",
|
||||||
|
"character_body_part_ailments",
|
||||||
|
"character_body_parts",
|
||||||
|
"character_clothing",
|
||||||
|
"entities",
|
||||||
|
"tile_overrides",
|
||||||
|
"map_embeddings",
|
||||||
|
"maps",
|
||||||
|
"meta",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorldRepository:
|
||||||
|
def __init__(self, conn: sqlite3.Connection):
|
||||||
|
self.conn = conn
|
||||||
|
self.conn.executescript(SCHEMA)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, path: str | Path) -> "WorldRepository":
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
return cls(conn)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def save_world(self, world: World) -> None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
for table in _SAVE_CLEAR_TABLES:
|
||||||
|
cur.execute(f"DELETE FROM {table}")
|
||||||
|
|
||||||
|
for map_ in world.maps.values():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO maps (map_id, source_def) VALUES (?, ?)",
|
||||||
|
(map_.map_id, map_.source_def),
|
||||||
|
)
|
||||||
|
for map_ in world.maps.values():
|
||||||
|
for embedding in map_.embeddings:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO map_embeddings (parent_map_id, child_map_id, anchor_x, anchor_y, anchor_z)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(map_.map_id, embedding.child_map.map_id, *embedding.anchor),
|
||||||
|
)
|
||||||
|
|
||||||
|
for map_ in world.maps.values():
|
||||||
|
self._save_tiles(cur, map_)
|
||||||
|
|
||||||
|
for entity in world.entities.values():
|
||||||
|
self._save_entity(cur, entity)
|
||||||
|
|
||||||
|
if world.player is not None:
|
||||||
|
cur.execute("INSERT INTO meta (key, value) VALUES ('player_entity_id', ?)", (world.player.entity_id,))
|
||||||
|
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def _save_tiles(self, cur: sqlite3.Cursor, map_: Map) -> None:
|
||||||
|
"""Full snapshot of every tile (not a sparse diff) - simple and correct for base-building
|
||||||
|
style runtime edits: any change to any layer of any tile survives a save/reload.
|
||||||
|
"""
|
||||||
|
for z in range(map_.depth):
|
||||||
|
for y in range(map_.height):
|
||||||
|
for x in range(map_.width):
|
||||||
|
tile = map_.get_tile(x, y, z)
|
||||||
|
values: list = [map_.map_id, x, y, z]
|
||||||
|
for layer in TILE_LAYERS:
|
||||||
|
instance = tile.get_layer(layer)
|
||||||
|
values.append(instance.def_id if instance else None)
|
||||||
|
values.append(instance.integrity if instance else None)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO tile_overrides"
|
||||||
|
" (map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity,"
|
||||||
|
" room, room_integrity, roof, roof_integrity)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_entity(self, cur: sqlite3.Cursor, entity: Entity) -> None:
|
||||||
|
assert entity.position is not None
|
||||||
|
is_character = isinstance(entity, Character)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
entity.entity_id,
|
||||||
|
entity.def_id,
|
||||||
|
"character" if is_character else "entity",
|
||||||
|
entity.name,
|
||||||
|
entity.position.map_id,
|
||||||
|
entity.position.x,
|
||||||
|
entity.position.y,
|
||||||
|
entity.position.z,
|
||||||
|
entity.species_id if is_character else None,
|
||||||
|
entity.gender if is_character else None,
|
||||||
|
entity.blood_percentage if is_character else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if isinstance(entity, Character):
|
||||||
|
for is_default, parts in ((0, entity.body_parts), (1, entity.default_body_parts)):
|
||||||
|
for part in parts:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO character_body_parts (entity_id, is_default, slot, part_def_id, integrity, removed)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(entity.entity_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)),
|
||||||
|
)
|
||||||
|
for ailment in part.ailments:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO character_body_part_ailments"
|
||||||
|
" (entity_id, is_default, slot, ailment_id, name, severity) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(entity.entity_id, is_default, part.slot, ailment.id, ailment.name, ailment.severity),
|
||||||
|
)
|
||||||
|
for is_default, pieces in ((0, entity.clothing), (1, entity.default_clothing)):
|
||||||
|
for piece in pieces:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO character_clothing (entity_id, is_default, slot, item_def_id, removed)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(entity.entity_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)),
|
||||||
|
)
|
||||||
|
if piece.inventory is not None:
|
||||||
|
self._save_inventory(
|
||||||
|
cur,
|
||||||
|
f"inv-{entity.entity_id}-clothing-{is_default}-{piece.slot}",
|
||||||
|
entity.entity_id,
|
||||||
|
piece.inventory,
|
||||||
|
owner_clothing=(is_default, piece.slot),
|
||||||
|
)
|
||||||
|
if entity.mental_stats is not None:
|
||||||
|
for stat, value in entity.mental_stats.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO character_mental_stats (entity_id, stat, value) VALUES (?, ?, ?)",
|
||||||
|
(entity.entity_id, stat, value),
|
||||||
|
)
|
||||||
|
for skill, level in entity.bio.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO character_bio (entity_id, skill, level) VALUES (?, ?, ?)",
|
||||||
|
(entity.entity_id, skill, level),
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity.inventory is not None:
|
||||||
|
self._save_inventory(cur, f"inv-{entity.entity_id}", entity.entity_id, entity.inventory)
|
||||||
|
|
||||||
|
def _save_inventory(
|
||||||
|
self,
|
||||||
|
cur: sqlite3.Cursor,
|
||||||
|
inventory_id: str,
|
||||||
|
owner_entity_id: str,
|
||||||
|
inventory: Inventory,
|
||||||
|
owner_clothing: tuple[int, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
owner_clothing_is_default, owner_clothing_slot = owner_clothing if owner_clothing else (None, None)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO inventories (inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, width, height)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height),
|
||||||
|
)
|
||||||
|
for placed in inventory.items():
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO inventory_items (item_id, inventory_id, def_id, quantity, x, y)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(placed.item.item_id, inventory_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_world(self, registry: DefRegistry, map_loader: MapLoader) -> World | None:
|
||||||
|
cur = self.conn.cursor()
|
||||||
|
map_rows = cur.execute("SELECT map_id, source_def FROM maps").fetchall()
|
||||||
|
if not map_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
world = World(registry=registry)
|
||||||
|
for map_id, source_def in map_rows:
|
||||||
|
m = map_loader.load(source_def)
|
||||||
|
assert m.map_id == map_id
|
||||||
|
world.add_map(m)
|
||||||
|
|
||||||
|
for map_id, x, y, z, subfloor, subfloor_i, flooring, flooring_i, room, room_i, roof, roof_i in cur.execute(
|
||||||
|
"SELECT map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity,"
|
||||||
|
" room, room_integrity, roof, roof_integrity FROM tile_overrides"
|
||||||
|
):
|
||||||
|
m = world.maps[map_id]
|
||||||
|
m.set_tile(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
Tile(
|
||||||
|
subfloor=TileLayerInstance(subfloor, subfloor_i) if subfloor else None,
|
||||||
|
flooring=TileLayerInstance(flooring, flooring_i) if flooring else None,
|
||||||
|
room=TileLayerInstance(room, room_i) if room else None,
|
||||||
|
roof=TileLayerInstance(roof, roof_i) if roof else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_rows = cur.execute(
|
||||||
|
"SELECT entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage FROM entities"
|
||||||
|
).fetchall()
|
||||||
|
for entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage in entity_rows:
|
||||||
|
position = EntityPosition(map_id, x, y, z)
|
||||||
|
if kind == "character":
|
||||||
|
entity: Entity = self._load_character(
|
||||||
|
cur, registry, entity_id, def_id, name, position, species_id, gender, blood_percentage
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position)
|
||||||
|
|
||||||
|
inv_row = cur.execute(
|
||||||
|
"SELECT inventory_id, width, height FROM inventories"
|
||||||
|
" WHERE owner_entity_id = ? AND owner_clothing_slot IS NULL",
|
||||||
|
(entity_id,),
|
||||||
|
).fetchone()
|
||||||
|
if inv_row is not None:
|
||||||
|
inventory_id, width, height = inv_row
|
||||||
|
entity.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
|
||||||
|
|
||||||
|
world.add_entity(entity)
|
||||||
|
|
||||||
|
player_row = cur.execute("SELECT value FROM meta WHERE key = 'player_entity_id'").fetchone()
|
||||||
|
if player_row is not None:
|
||||||
|
world.player = world.entities.get(player_row[0])
|
||||||
|
|
||||||
|
return world
|
||||||
|
|
||||||
|
def _load_inventory(self, cur: sqlite3.Cursor, inventory_id: str, width: int, height: int, registry: DefRegistry) -> Inventory:
|
||||||
|
inventory = Inventory(width, height)
|
||||||
|
rows = cur.execute(
|
||||||
|
"SELECT item_id, def_id, quantity, x, y FROM inventory_items WHERE inventory_id = ?",
|
||||||
|
(inventory_id,),
|
||||||
|
).fetchall()
|
||||||
|
for item_id, def_id, quantity, ix, iy in rows:
|
||||||
|
item_def = registry.get_item_def(def_id)
|
||||||
|
inventory.place(ItemInstance(item_id, def_id, quantity), item_def, ix, iy)
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
def _load_character(
|
||||||
|
self, cur, registry: DefRegistry, entity_id, def_id, name, position, species_id, gender, blood_percentage
|
||||||
|
) -> Character:
|
||||||
|
body_parts: list[BodyPart] = []
|
||||||
|
default_body_parts: list[BodyPart] = []
|
||||||
|
part_rows = cur.execute(
|
||||||
|
"SELECT is_default, slot, part_def_id, integrity, removed FROM character_body_parts WHERE entity_id = ?",
|
||||||
|
(entity_id,),
|
||||||
|
).fetchall()
|
||||||
|
for is_default, slot, part_def_id, integrity, removed in part_rows:
|
||||||
|
part = BodyPart(slot=slot, part_def_id=part_def_id, integrity=integrity, removed=bool(removed))
|
||||||
|
ailment_rows = cur.execute(
|
||||||
|
"SELECT ailment_id, name, severity FROM character_body_part_ailments"
|
||||||
|
" WHERE entity_id = ? AND is_default = ? AND slot = ?",
|
||||||
|
(entity_id, is_default, slot),
|
||||||
|
).fetchall()
|
||||||
|
for ailment_id, ailment_name, severity in ailment_rows:
|
||||||
|
part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity))
|
||||||
|
(default_body_parts if is_default else body_parts).append(part)
|
||||||
|
|
||||||
|
clothing: list[ClothingPiece] = []
|
||||||
|
default_clothing: list[ClothingPiece] = []
|
||||||
|
clothing_rows = cur.execute(
|
||||||
|
"SELECT is_default, slot, item_def_id, removed FROM character_clothing WHERE entity_id = ?",
|
||||||
|
(entity_id,),
|
||||||
|
).fetchall()
|
||||||
|
for is_default, slot, item_def_id, removed in clothing_rows:
|
||||||
|
piece = ClothingPiece(slot=slot, item_def_id=item_def_id, removed=bool(removed))
|
||||||
|
inv_row = cur.execute(
|
||||||
|
"SELECT inventory_id, width, height FROM inventories"
|
||||||
|
" WHERE owner_entity_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?",
|
||||||
|
(entity_id, is_default, slot),
|
||||||
|
).fetchone()
|
||||||
|
if inv_row is not None:
|
||||||
|
inventory_id, width, height = inv_row
|
||||||
|
piece.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
|
||||||
|
(default_clothing if is_default else clothing).append(piece)
|
||||||
|
|
||||||
|
mental_stats = {
|
||||||
|
stat: value
|
||||||
|
for stat, value in cur.execute(
|
||||||
|
"SELECT stat, value FROM character_mental_stats WHERE entity_id = ?", (entity_id,)
|
||||||
|
)
|
||||||
|
} or None
|
||||||
|
|
||||||
|
bio = {
|
||||||
|
skill: level
|
||||||
|
for skill, level in cur.execute(
|
||||||
|
"SELECT skill, level FROM character_bio WHERE entity_id = ?", (entity_id,)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Character(
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=name,
|
||||||
|
def_id=def_id,
|
||||||
|
position=position,
|
||||||
|
species_id=species_id,
|
||||||
|
gender=gender,
|
||||||
|
blood_percentage=blood_percentage if blood_percentage is not None else 100.0,
|
||||||
|
body_parts=body_parts,
|
||||||
|
default_body_parts=default_body_parts,
|
||||||
|
clothing=clothing,
|
||||||
|
default_clothing=default_clothing,
|
||||||
|
mental_stats=mental_stats,
|
||||||
|
bio=bio,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine.character import BodyPart, BodyPartDef, ClothingPiece, Organ, OrganDef
|
||||||
|
from engine.item import ItemDef
|
||||||
|
from engine.species import AbilityDef, SpeciesDef
|
||||||
|
from engine.tile import TILE_LAYERS, TileLayerDef
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EntityDef:
|
||||||
|
"""A spawn template: who to make (species, for characters), starting clothing, and starting inventory size."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
species_id: str | None = None
|
||||||
|
default_clothing: list[ClothingPiece] = field(default_factory=list)
|
||||||
|
inventory_size: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DefRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.tile_layer_defs: dict[tuple[str, str], TileLayerDef] = {}
|
||||||
|
self.item_defs: dict[str, ItemDef] = {}
|
||||||
|
self.body_part_defs: dict[str, BodyPartDef] = {}
|
||||||
|
self.organ_defs: dict[str, OrganDef] = {}
|
||||||
|
self.species_defs: dict[str, SpeciesDef] = {}
|
||||||
|
self.entity_defs: dict[str, EntityDef] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, defs_dir: Path) -> "DefRegistry":
|
||||||
|
registry = cls()
|
||||||
|
registry._load_tiles(defs_dir / "tiles.json")
|
||||||
|
registry._load_items(defs_dir / "items.json")
|
||||||
|
registry._load_body_parts(defs_dir / "body_parts.json")
|
||||||
|
registry._load_organs(defs_dir / "organs.json")
|
||||||
|
registry._load_species(defs_dir / "species.json")
|
||||||
|
registry._load_entities(defs_dir / "entities.json")
|
||||||
|
return registry
|
||||||
|
|
||||||
|
def _load_tiles(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for layer in TILE_LAYERS:
|
||||||
|
for def_id, fields in data.get(layer, {}).items():
|
||||||
|
self.tile_layer_defs[(layer, def_id)] = TileLayerDef(
|
||||||
|
id=def_id,
|
||||||
|
layer=layer,
|
||||||
|
texture=fields.get("texture"),
|
||||||
|
color=tuple(fields.get("color", (255, 0, 255))),
|
||||||
|
walkable=fields.get("walkable", True),
|
||||||
|
blocks_los=fields.get("blocks_los", False),
|
||||||
|
deconstruct_item_id=fields.get("deconstruct_item_id"),
|
||||||
|
is_helm=fields.get("is_helm", False),
|
||||||
|
staircase_direction=fields.get("staircase_direction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_items(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
self.item_defs[def_id] = ItemDef(
|
||||||
|
id=def_id,
|
||||||
|
name=fields["name"],
|
||||||
|
width=fields["width"],
|
||||||
|
height=fields["height"],
|
||||||
|
texture=fields.get("texture"),
|
||||||
|
color=tuple(fields.get("color", (255, 0, 255))),
|
||||||
|
stackable=fields.get("stackable", False),
|
||||||
|
max_stack=fields.get("max_stack", 1),
|
||||||
|
clothing_slot=fields.get("clothing_slot"),
|
||||||
|
grants_inventory_size=(
|
||||||
|
tuple(fields["grants_inventory_size"]) if "grants_inventory_size" in fields else None
|
||||||
|
),
|
||||||
|
armor_reduction=fields.get("armor_reduction", 0.0),
|
||||||
|
arm_slots_required=fields.get("arm_slots_required", 0),
|
||||||
|
compatible_body_types=tuple(fields.get("compatible_body_types", ())),
|
||||||
|
weapon_skill=fields.get("weapon_skill"),
|
||||||
|
weapon_damage=fields.get("weapon_damage", 0.0),
|
||||||
|
weapon_range=fields.get("weapon_range", 1),
|
||||||
|
projectile_type=fields.get("projectile_type"),
|
||||||
|
hands_required=fields.get("hands_required", 1),
|
||||||
|
shield_block_bonus=fields.get("shield_block_bonus", 0.0),
|
||||||
|
blockable_projectile_types=tuple(fields.get("blockable_projectile_types", ())),
|
||||||
|
tool_kind=fields.get("tool_kind"),
|
||||||
|
builds_tile_layer=fields.get("builds_tile_layer"),
|
||||||
|
builds_tile_layer_def_id=fields.get("builds_tile_layer_def_id"),
|
||||||
|
ammo_type=fields.get("ammo_type"),
|
||||||
|
ranged_knockback=fields.get("ranged_knockback", 0.0),
|
||||||
|
compatible_ammo_types=tuple(fields.get("compatible_ammo_types", ())),
|
||||||
|
magazine_size=fields.get("magazine_size", 0),
|
||||||
|
aim_cone_degrees=fields.get("aim_cone_degrees", 0.0),
|
||||||
|
pellet_count=fields.get("pellet_count", 1),
|
||||||
|
blast_radius=fields.get("blast_radius", 0),
|
||||||
|
attachment_kind=fields.get("attachment_kind"),
|
||||||
|
aimcone_reduction_degrees=fields.get("aimcone_reduction_degrees", 0.0),
|
||||||
|
magazine_capacity_bonus=fields.get("magazine_capacity_bonus", 0),
|
||||||
|
melee_knockback=fields.get("melee_knockback", 0.0),
|
||||||
|
grants_ability=fields.get("grants_ability"),
|
||||||
|
is_implant=fields.get("is_implant", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_body_parts(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
self.body_part_defs[def_id] = BodyPartDef(
|
||||||
|
id=def_id,
|
||||||
|
slot=fields["slot"],
|
||||||
|
texture=fields.get("texture"),
|
||||||
|
color=tuple(fields.get("color", (255, 0, 255))),
|
||||||
|
bleeding_weight=fields.get("bleeding_weight", 0.0),
|
||||||
|
bleeding_speed=fields.get("bleeding_speed", 0.0),
|
||||||
|
skill_weights=dict(fields.get("skill_weights", {})),
|
||||||
|
speed_factor=fields.get("speed_factor", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_organs(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
self.organ_defs[def_id] = OrganDef(
|
||||||
|
id=def_id,
|
||||||
|
name=fields.get("name", def_id),
|
||||||
|
host_slot=fields["host_slot"],
|
||||||
|
check_weights=dict(fields.get("check_weights", {})),
|
||||||
|
affects_organs=dict(fields.get("affects_organs", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_species(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
default_parts = [
|
||||||
|
BodyPart(slot=self.body_part_defs[part_id].slot, part_def_id=part_id)
|
||||||
|
for part_id in fields.get("default_body_parts", [])
|
||||||
|
]
|
||||||
|
abilities = [
|
||||||
|
AbilityDef(
|
||||||
|
id=ability["id"],
|
||||||
|
name=ability.get("name", ability["id"]),
|
||||||
|
required_slots=tuple(ability.get("required_slots", ())),
|
||||||
|
)
|
||||||
|
for ability in fields.get("abilities", [])
|
||||||
|
]
|
||||||
|
self.species_defs[def_id] = SpeciesDef(
|
||||||
|
id=def_id,
|
||||||
|
name=fields.get("name", def_id),
|
||||||
|
genders=list(fields.get("genders", [])),
|
||||||
|
default_body_parts=default_parts,
|
||||||
|
default_organs=list(fields.get("default_organs", [])),
|
||||||
|
body_type=fields.get("body_type", "biped"),
|
||||||
|
abilities=abilities,
|
||||||
|
blood_danger_threshold=fields.get("blood_danger_threshold", 50.0),
|
||||||
|
blood_critical_threshold=fields.get("blood_critical_threshold", 25.0),
|
||||||
|
health_item_compatibility=list(fields.get("health_item_compatibility", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_entities(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
inventory_size = fields.get("inventory_size")
|
||||||
|
default_clothing = [
|
||||||
|
ClothingPiece(slot=self.item_defs[item_id].clothing_slot, item_def_id=item_id)
|
||||||
|
for item_id in fields.get("default_clothing", [])
|
||||||
|
]
|
||||||
|
self.entity_defs[def_id] = EntityDef(
|
||||||
|
id=def_id,
|
||||||
|
name=fields.get("name", def_id),
|
||||||
|
species_id=fields.get("species_id"),
|
||||||
|
default_clothing=default_clothing,
|
||||||
|
inventory_size=tuple(inventory_size) if inventory_size else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_tile_layer_def(self, layer: str, def_id: str | None) -> TileLayerDef | None:
|
||||||
|
if def_id is None:
|
||||||
|
return None
|
||||||
|
return self.tile_layer_defs.get((layer, def_id))
|
||||||
|
|
||||||
|
def get_item_def(self, def_id: str) -> ItemDef:
|
||||||
|
return self.item_defs[def_id]
|
||||||
|
|
||||||
|
def get_body_part_def(self, def_id: str) -> BodyPartDef:
|
||||||
|
return self.body_part_defs[def_id]
|
||||||
|
|
||||||
|
def get_organ_def(self, def_id: str) -> OrganDef:
|
||||||
|
return self.organ_defs[def_id]
|
||||||
|
|
||||||
|
def get_species_def(self, def_id: str) -> SpeciesDef:
|
||||||
|
return self.species_defs[def_id]
|
||||||
|
|
||||||
|
def get_entity_def(self, def_id: str) -> EntityDef:
|
||||||
|
return self.entity_defs[def_id]
|
||||||
|
|
||||||
|
def new_default_body_parts(self, species_id: str) -> list[BodyPart]:
|
||||||
|
"""Fresh BodyPart instances copied from a species' defaults, safe to mutate per-character.
|
||||||
|
|
||||||
|
Organs are placed per the species' own explicit default_organs list (not by scanning
|
||||||
|
every loaded organ def for a host_slot match) - a species declares exactly which organs
|
||||||
|
it has, same as it declares exactly which body parts it has. This matters once species
|
||||||
|
stop sharing anatomy: e.g. a robotic species with a "torso" slot must NOT automatically
|
||||||
|
inherit an organic heart/lungs/liver/kidneys just because they happen to host there.
|
||||||
|
Each declared organ is still placed into whichever body part its own OrganDef.host_slot
|
||||||
|
names.
|
||||||
|
"""
|
||||||
|
species = self.get_species_def(species_id)
|
||||||
|
organs_by_slot: dict[str, list[Organ]] = {}
|
||||||
|
for organ_def_id in species.default_organs:
|
||||||
|
organ_def = self.organ_defs[organ_def_id]
|
||||||
|
organs_by_slot.setdefault(organ_def.host_slot, []).append(Organ(organ_def_id=organ_def_id))
|
||||||
|
return [
|
||||||
|
BodyPart(
|
||||||
|
slot=p.slot,
|
||||||
|
part_def_id=p.part_def_id,
|
||||||
|
organs=organs_by_slot.get(p.slot, []),
|
||||||
|
)
|
||||||
|
for p in species.default_body_parts
|
||||||
|
]
|
||||||
|
|
||||||
|
def new_default_clothing(self, entity_def_id: str) -> list[ClothingPiece]:
|
||||||
|
"""Fresh ClothingPiece instances copied from an entity template's starting outfit, safe to mutate per-character."""
|
||||||
|
return [
|
||||||
|
ClothingPiece(slot=p.slot, item_def_id=p.item_def_id)
|
||||||
|
for p in self.get_entity_def(entity_def_id).default_clothing
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
_id_counter = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_entity_id(prefix: str = "entity") -> str:
|
||||||
|
return f"{prefix}-{next(_id_counter)}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EntityPosition:
|
||||||
|
"""x/y/z are continuous (not tile-locked); z stays layer-like (whole numbers by convention).
|
||||||
|
|
||||||
|
Which tile a continuous position falls in is always floor(x), floor(y) - see Map.get_tile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
map_id: str
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
|
||||||
|
|
||||||
|
class Entity:
|
||||||
|
"""Base primitive for all non-tile things in the world."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
name: str = "",
|
||||||
|
def_id: str | None = None,
|
||||||
|
position: EntityPosition | None = None,
|
||||||
|
):
|
||||||
|
self.entity_id = entity_id or generate_entity_id()
|
||||||
|
self.name = name
|
||||||
|
self.def_id = def_id
|
||||||
|
self.position = position
|
||||||
|
self.inventory = None # Inventory | None — assignable to any Entity
|
||||||
|
|
||||||
|
def update(self, dt: float) -> None:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Standard GLFW gamepad button indices (glfwGetGamepadState order) mapped to config-friendly
|
||||||
|
# names, so the .conf format matches KeyBindings' "action = token, token" style exactly.
|
||||||
|
GAMEPAD_BUTTON_NAMES: dict[int, str] = {
|
||||||
|
0: "gp_a",
|
||||||
|
1: "gp_b",
|
||||||
|
2: "gp_x",
|
||||||
|
3: "gp_y",
|
||||||
|
4: "gp_left_bumper",
|
||||||
|
5: "gp_right_bumper",
|
||||||
|
6: "gp_back",
|
||||||
|
7: "gp_start",
|
||||||
|
8: "gp_guide",
|
||||||
|
9: "gp_left_thumb",
|
||||||
|
10: "gp_right_thumb",
|
||||||
|
11: "gp_dpad_up",
|
||||||
|
12: "gp_dpad_right",
|
||||||
|
13: "gp_dpad_down",
|
||||||
|
14: "gp_dpad_left",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standard GLFW gamepad axis indices. The triggers are analog axes (not buttons) in GLFW's
|
||||||
|
# standard mapping - GamepadState.digital_buttons() turns them into gp_left_trigger/
|
||||||
|
# gp_right_trigger pseudo-buttons via TRIGGER_PRESS_THRESHOLD, so bindings only ever deal with
|
||||||
|
# named buttons, never raw axis indices.
|
||||||
|
AXIS_LEFT_X = 0
|
||||||
|
AXIS_LEFT_Y = 1
|
||||||
|
AXIS_RIGHT_X = 2
|
||||||
|
AXIS_RIGHT_Y = 3
|
||||||
|
AXIS_LEFT_TRIGGER = 4
|
||||||
|
AXIS_RIGHT_TRIGGER = 5
|
||||||
|
|
||||||
|
TRIGGER_PRESS_THRESHOLD = 0.5
|
||||||
|
DEFAULT_STICK_DEADZONE = 0.2
|
||||||
|
|
||||||
|
DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = {
|
||||||
|
"leap": ["gp_a"],
|
||||||
|
"block": ["gp_left_bumper"],
|
||||||
|
"activate_right_hand": ["gp_right_bumper"],
|
||||||
|
"activate_left_hand": ["gp_x"],
|
||||||
|
"activate_right_hand_2": ["gp_right_trigger"],
|
||||||
|
"activate_left_hand_2": ["gp_left_trigger"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_deadzone(value: float, deadzone: float = DEFAULT_STICK_DEADZONE) -> float:
|
||||||
|
return 0.0 if abs(value) < deadzone else value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GamepadState:
|
||||||
|
"""One frame's snapshot of a gamepad, decoupled from any actual hardware/glfw call so the
|
||||||
|
binding/mapping logic is fully unit-testable. main.py is responsible for the one-line glue
|
||||||
|
that turns a real glfw.get_gamepad_state(...) result into this shape each frame - see
|
||||||
|
GamepadState.from_glfw.
|
||||||
|
"""
|
||||||
|
|
||||||
|
buttons: dict[str, bool] = field(default_factory=dict) # gp_* name -> pressed
|
||||||
|
axes: list[float] = field(default_factory=lambda: [0.0] * 6)
|
||||||
|
|
||||||
|
def axis(self, index: int) -> float:
|
||||||
|
return self.axes[index] if index < len(self.axes) else 0.0
|
||||||
|
|
||||||
|
def digital_buttons(self) -> set[str]:
|
||||||
|
"""Every currently-pressed gp_* name, including the triggers thresholded into
|
||||||
|
pseudo-buttons (gp_left_trigger/gp_right_trigger) alongside the real digital buttons.
|
||||||
|
"""
|
||||||
|
pressed = {name for name, is_down in self.buttons.items() if is_down}
|
||||||
|
if self.axis(AXIS_LEFT_TRIGGER) > TRIGGER_PRESS_THRESHOLD:
|
||||||
|
pressed.add("gp_left_trigger")
|
||||||
|
if self.axis(AXIS_RIGHT_TRIGGER) > TRIGGER_PRESS_THRESHOLD:
|
||||||
|
pressed.add("gp_right_trigger")
|
||||||
|
return pressed
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_glfw(cls, raw) -> "GamepadState":
|
||||||
|
"""Wraps a glfw.get_gamepad_state(...) result (a _GLFWgamepadstate with .buttons/.axes
|
||||||
|
sequences indexed exactly like GAMEPAD_BUTTON_NAMES/AXIS_*) into this hardware-agnostic
|
||||||
|
shape. Never exercised by tests (no hardware in CI) - kept intentionally thin so the
|
||||||
|
only untested code is "read the raw struct", not any actual logic.
|
||||||
|
"""
|
||||||
|
buttons = {name: bool(raw.buttons[index]) for index, name in GAMEPAD_BUTTON_NAMES.items()}
|
||||||
|
axes = list(raw.axes)
|
||||||
|
return cls(buttons=buttons, axes=axes)
|
||||||
|
|
||||||
|
|
||||||
|
class GamepadBindings:
|
||||||
|
"""Action -> list of gp_* button names, loaded from an editable .conf file - same array-
|
||||||
|
per-action format and fallback behavior as engine.config.KeyBindings, just for gamepad
|
||||||
|
buttons instead of keys/mouse buttons.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bindings: dict[str, list[str]]):
|
||||||
|
self.bindings = bindings
|
||||||
|
self._action_by_button: dict[str, str] = {}
|
||||||
|
for action, buttons in bindings.items():
|
||||||
|
for button in buttons:
|
||||||
|
self._action_by_button[button] = action
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "GamepadBindings":
|
||||||
|
bindings = {action: list(buttons) for action, buttons in DEFAULT_GAMEPAD_BINDINGS.items()}
|
||||||
|
if path.exists():
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.read(path)
|
||||||
|
for section in parser.sections():
|
||||||
|
for action, raw in parser.items(section):
|
||||||
|
buttons = [b.strip() for b in raw.split(",") if b.strip()]
|
||||||
|
if buttons:
|
||||||
|
bindings[action] = buttons
|
||||||
|
return cls(bindings)
|
||||||
|
|
||||||
|
def action_for_button(self, button: str) -> str | None:
|
||||||
|
return self._action_by_button.get(button)
|
||||||
|
|
||||||
|
def buttons_for_action(self, action: str) -> list[str]:
|
||||||
|
return self.bindings.get(action, [])
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.config import DEFAULT_KEYBINDINGS, KeyBindings
|
||||||
|
from engine.gamepad import AXIS_LEFT_X, AXIS_LEFT_Y, GamepadBindings, GamepadState, apply_deadzone
|
||||||
|
|
||||||
|
MOVE_ACTION_VECTORS: dict[str, tuple[int, int, int]] = {
|
||||||
|
"move_up": (0, -1, 0),
|
||||||
|
"move_down": (0, 1, 0),
|
||||||
|
"move_left": (-1, 0, 0),
|
||||||
|
"move_right": (1, 0, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mouse-button-driven "which hand acts" scheme: left/right click for the primary pair of
|
||||||
|
# hands, shift+left/right for a second pair (only meaningful for a 4-armed species; wield_item
|
||||||
|
# already refuses to use a hand slot with no corresponding arm, so this degrades safely).
|
||||||
|
HAND_ACTIVATION_ACTIONS: dict[str, str] = {
|
||||||
|
"activate_right_hand": "right_hand",
|
||||||
|
"activate_left_hand": "left_hand",
|
||||||
|
"activate_right_hand_2": "right_hand_2",
|
||||||
|
"activate_left_hand_2": "left_hand_2",
|
||||||
|
}
|
||||||
|
|
||||||
|
# jupyter_rfb/rendercanvas pointer button numbering (DOM-style): 0 = left, 2 = right.
|
||||||
|
MOUSE_BUTTON_KEYS: dict[int, str] = {0: "mouse_left", 2: "mouse_right"}
|
||||||
|
|
||||||
|
# Numkey hotkeys for the ability bar (engine/ui.py::AbilityBar) - "0" conventionally maps to
|
||||||
|
# the 10th slot (index 9), matching the usual 1-9,0 hotbar ordering.
|
||||||
|
SELECT_ABILITY_ACTIONS: dict[str, int] = {
|
||||||
|
"select_ability_1": 0,
|
||||||
|
"select_ability_2": 1,
|
||||||
|
"select_ability_3": 2,
|
||||||
|
"select_ability_4": 3,
|
||||||
|
"select_ability_5": 4,
|
||||||
|
"select_ability_6": 5,
|
||||||
|
"select_ability_7": 6,
|
||||||
|
"select_ability_8": 7,
|
||||||
|
"select_ability_9": 8,
|
||||||
|
"select_ability_0": 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_FACING: tuple[int, int, int] = (0, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class InputState:
|
||||||
|
"""Discrete key/mouse-driven movement and actions (one press = one step or state toggle),
|
||||||
|
resolved through a KeyBindings config so any action can be bound to multiple inputs. Also
|
||||||
|
tracks raw pointer position for continuous mouse aiming (see engine/aim.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, keybindings: KeyBindings | None = None):
|
||||||
|
self.keybindings = keybindings or KeyBindings(dict(DEFAULT_KEYBINDINGS))
|
||||||
|
self.pending_move: tuple[int, int, int] | None = None
|
||||||
|
self.held_move_actions: set[str] = set()
|
||||||
|
self.pending_leap: tuple[int, int, int] | None = None
|
||||||
|
self.pending_hand_activation: str | None = None
|
||||||
|
self.pending_ability_select: int | None = None
|
||||||
|
self.is_blocking = False
|
||||||
|
self.facing: tuple[int, int, int] = DEFAULT_FACING
|
||||||
|
self.pointer_pos: tuple[float, float] | None = None
|
||||||
|
self.gamepad_move_vector: tuple[float, float] = (0.0, 0.0)
|
||||||
|
self._prev_gamepad_buttons: set[str] = set()
|
||||||
|
|
||||||
|
def handle_event(self, event: dict) -> None:
|
||||||
|
event_type = event.get("event_type")
|
||||||
|
|
||||||
|
if event_type == "pointer_move":
|
||||||
|
self.pointer_pos = (event.get("x", 0.0), event.get("y", 0.0))
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "pointer_down":
|
||||||
|
self._dispatch_down(MOUSE_BUTTON_KEYS.get(event.get("button", -1)), event.get("modifiers", ()))
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type not in ("key_down", "key_up"):
|
||||||
|
return
|
||||||
|
|
||||||
|
key = event.get("key", "")
|
||||||
|
action = self._resolve_action(key, event.get("modifiers", ()))
|
||||||
|
if action is None:
|
||||||
|
return
|
||||||
|
if event_type == "key_down":
|
||||||
|
self._apply_down(action)
|
||||||
|
else:
|
||||||
|
self._apply_up(action)
|
||||||
|
|
||||||
|
def _resolve_action(self, key: str, modifiers) -> str | None:
|
||||||
|
if key and "Shift" in modifiers:
|
||||||
|
action = self.keybindings.action_for_key(f"shift+{key}")
|
||||||
|
if action is not None:
|
||||||
|
return action
|
||||||
|
return self.keybindings.action_for_key(key)
|
||||||
|
|
||||||
|
def _dispatch_down(self, mouse_key: str | None, modifiers) -> None:
|
||||||
|
if mouse_key is None:
|
||||||
|
return
|
||||||
|
action = self._resolve_action(mouse_key, modifiers)
|
||||||
|
if action is not None:
|
||||||
|
self._apply_down(action)
|
||||||
|
|
||||||
|
def _apply_down(self, action: str) -> None:
|
||||||
|
if action in MOVE_ACTION_VECTORS:
|
||||||
|
move = MOVE_ACTION_VECTORS[action]
|
||||||
|
self.pending_move = move # one-shot compat, e.g. for a discrete-step caller
|
||||||
|
self.held_move_actions.add(action)
|
||||||
|
self.facing = move
|
||||||
|
elif action == "leap":
|
||||||
|
self.pending_leap = self.facing
|
||||||
|
elif action == "block":
|
||||||
|
self.is_blocking = True
|
||||||
|
elif action in HAND_ACTIVATION_ACTIONS:
|
||||||
|
self.pending_hand_activation = HAND_ACTIVATION_ACTIONS[action]
|
||||||
|
elif action in SELECT_ABILITY_ACTIONS:
|
||||||
|
self.pending_ability_select = SELECT_ABILITY_ACTIONS[action]
|
||||||
|
|
||||||
|
def _apply_up(self, action: str) -> None:
|
||||||
|
if action in MOVE_ACTION_VECTORS:
|
||||||
|
self.held_move_actions.discard(action)
|
||||||
|
elif action == "block":
|
||||||
|
self.is_blocking = False
|
||||||
|
|
||||||
|
def apply_gamepad_state(self, state: GamepadState, bindings: GamepadBindings) -> None:
|
||||||
|
"""Feeds one frame's polled gamepad snapshot through the same action dispatch as
|
||||||
|
keyboard/mouse events. Unlike glfw's edge-triggered key_down/key_up callbacks, a
|
||||||
|
gamepad is polled every frame, so button transitions have to be detected here by
|
||||||
|
diffing against the previous frame's pressed set.
|
||||||
|
"""
|
||||||
|
pressed = state.digital_buttons()
|
||||||
|
for button in pressed - self._prev_gamepad_buttons:
|
||||||
|
action = bindings.action_for_button(button)
|
||||||
|
if action is not None:
|
||||||
|
self._apply_down(action)
|
||||||
|
for button in self._prev_gamepad_buttons - pressed:
|
||||||
|
action = bindings.action_for_button(button)
|
||||||
|
if action is not None:
|
||||||
|
self._apply_up(action)
|
||||||
|
self._prev_gamepad_buttons = pressed
|
||||||
|
|
||||||
|
self.gamepad_move_vector = (
|
||||||
|
apply_deadzone(state.axis(AXIS_LEFT_X)),
|
||||||
|
apply_deadzone(state.axis(AXIS_LEFT_Y)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def consume_move(self) -> tuple[int, int, int] | None:
|
||||||
|
move, self.pending_move = self.pending_move, None
|
||||||
|
return move
|
||||||
|
|
||||||
|
def current_move_vector(self) -> tuple[float, float, float]:
|
||||||
|
"""The combined direction of all currently-held movement keys plus the left stick's
|
||||||
|
deadzoned analog offset (for continuous movement) - the two sources simply add, so
|
||||||
|
keyboard and gamepad work either standalone or together.
|
||||||
|
|
||||||
|
Holding both an up/down and left/right key gives a diagonal vector - not normalized
|
||||||
|
here (World.try_move_continuous normalizes it), so callers get the raw combination.
|
||||||
|
"""
|
||||||
|
dx = dy = 0.0
|
||||||
|
for action in self.held_move_actions:
|
||||||
|
vx, vy, _ = MOVE_ACTION_VECTORS[action]
|
||||||
|
dx += vx
|
||||||
|
dy += vy
|
||||||
|
dx += self.gamepad_move_vector[0]
|
||||||
|
dy += self.gamepad_move_vector[1]
|
||||||
|
return (dx, dy, 0.0)
|
||||||
|
|
||||||
|
def consume_leap(self) -> tuple[int, int, int] | None:
|
||||||
|
leap, self.pending_leap = self.pending_leap, None
|
||||||
|
return leap
|
||||||
|
|
||||||
|
def consume_hand_activation(self) -> str | None:
|
||||||
|
hand, self.pending_hand_activation = self.pending_hand_activation, None
|
||||||
|
return hand
|
||||||
|
|
||||||
|
def consume_ability_select(self) -> int | None:
|
||||||
|
index, self.pending_ability_select = self.pending_ability_select, None
|
||||||
|
return index
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from engine.item import ItemDef, ItemInstance
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlacedItem:
|
||||||
|
item: ItemInstance
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
|
||||||
|
class Inventory:
|
||||||
|
"""A grid of cells that items occupy according to their w x h footprint."""
|
||||||
|
|
||||||
|
def __init__(self, width: int, height: int):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._cells: list[str | None] = [None] * (width * height)
|
||||||
|
self._items: dict[str, PlacedItem] = {}
|
||||||
|
|
||||||
|
def _footprint(self, item_def: ItemDef, x: int, y: int) -> list[tuple[int, int]]:
|
||||||
|
return [(x + dx, y + dy) for dy in range(item_def.height) for dx in range(item_def.width)]
|
||||||
|
|
||||||
|
def can_place(self, item_def: ItemDef, x: int, y: int) -> bool:
|
||||||
|
for cx, cy in self._footprint(item_def, x, y):
|
||||||
|
if not (0 <= cx < self.width and 0 <= cy < self.height):
|
||||||
|
return False
|
||||||
|
if self._cells[cy * self.width + cx] is not None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def place(self, item: ItemInstance, item_def: ItemDef, x: int, y: int) -> bool:
|
||||||
|
if not self.can_place(item_def, x, y):
|
||||||
|
return False
|
||||||
|
for cx, cy in self._footprint(item_def, x, y):
|
||||||
|
self._cells[cy * self.width + cx] = item.item_id
|
||||||
|
self._items[item.item_id] = PlacedItem(item, x, y)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove(self, item_id: str, item_def: ItemDef) -> ItemInstance | None:
|
||||||
|
placed = self._items.pop(item_id, None)
|
||||||
|
if placed is None:
|
||||||
|
return None
|
||||||
|
for cx, cy in self._footprint(item_def, placed.x, placed.y):
|
||||||
|
self._cells[cy * self.width + cx] = None
|
||||||
|
return placed.item
|
||||||
|
|
||||||
|
def find_first_fit(self, item_def: ItemDef) -> tuple[int, int] | None:
|
||||||
|
for y in range(self.height - item_def.height + 1):
|
||||||
|
for x in range(self.width - item_def.width + 1):
|
||||||
|
if self.can_place(item_def, x, y):
|
||||||
|
return (x, y)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def items(self) -> list[PlacedItem]:
|
||||||
|
return list(self._items.values())
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ItemDef:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
texture: str | None = None
|
||||||
|
color: tuple[int, int, int] = (255, 0, 255)
|
||||||
|
stackable: bool = False
|
||||||
|
max_stack: int = 1
|
||||||
|
clothing_slot: str | None = None # set if this item can be worn (e.g. "torso", "back")
|
||||||
|
grants_inventory_size: tuple[int, int] | None = None # e.g. a backpack, when worn
|
||||||
|
armor_reduction: float = 0.0 # flat damage reduction while worn (clothing)
|
||||||
|
arm_slots_required: int = 0 # sleeved garments only (e.g. a jacket): 0 = no arm-count
|
||||||
|
# constraint (hats, backpacks, pants, ...); a garment with more sleeves than the wearer
|
||||||
|
# has arms still fits (extras go unused), fewer does not - see Character.wear_item
|
||||||
|
compatible_body_types: tuple[str, ...] = () # e.g. ("biped",), ("quadruped",) - which
|
||||||
|
# SpeciesDef.body_type values this item can be worn by at all; empty = fits any body type
|
||||||
|
weapon_skill: str | None = None # bio skill this weapon's attacks are checked against
|
||||||
|
weapon_damage: float = 0.0 # flat bonus added on a successful skill check
|
||||||
|
weapon_range: int = 1 # tiles; 1 = melee (adjacent), >1 = ranged (used by shooting)
|
||||||
|
projectile_type: str | None = None # e.g. "bullet", "arrow" - what a shield needs to block it
|
||||||
|
hands_required: int = 1 # 1 or 2; two-handed items occupy a hand pair when wielded
|
||||||
|
shield_block_bonus: float = 0.0 # bonus added to the defender's blocking skill check
|
||||||
|
blockable_projectile_types: tuple[str, ...] = () # projectile_types this shield stops
|
||||||
|
tool_kind: str | None = None # e.g. "deconstructor", "constructor"
|
||||||
|
builds_tile_layer: str | None = None # which layer this material can construct (used with a constructor tool)
|
||||||
|
builds_tile_layer_def_id: str | None = None # which def gets placed there
|
||||||
|
ammo_type: str | None = None # set on ammo items: "small_caliber", "heavy_caliber", "shotgun_shell", ...
|
||||||
|
ranged_knockback: float = 0.0 # ammo items: tiles of knockback dealt on impact
|
||||||
|
ammo_damage_bonus: float = 0.0 # ammo items: extra flat damage added to the weapon's own bonus
|
||||||
|
compatible_ammo_types: tuple[str, ...] = () # ammo-based weapons: which ammo_types they accept
|
||||||
|
magazine_size: int = 0 # ammo-based weapons: base capacity (0 = not ammo-fed, e.g. bow/pistol_basic)
|
||||||
|
aim_cone_degrees: float = 0.0 # ranged weapons: base spread; 0 = perfectly accurate
|
||||||
|
pellet_count: int = 1 # shotgun-style weapons: independent projectiles fired per shot
|
||||||
|
blast_radius: int = 0 # rocket-style weapons: AoE radius in tiles (0 = no AoE)
|
||||||
|
attachment_kind: str | None = None # e.g. "scope", "stock", "magazine"
|
||||||
|
aimcone_reduction_degrees: float = 0.0 # attachment effect
|
||||||
|
magazine_capacity_bonus: int = 0 # attachment effect (e.g. an extended mag)
|
||||||
|
melee_knockback: float = 0.0 # melee weapons: base knockback tiles, scaled by attacker strength
|
||||||
|
grants_ability: str | None = None # e.g. "time_warp_sphere" - triggered on activation, see resources/logic/abilities.py
|
||||||
|
is_implant: bool = False # slotless: installed directly on the character, not held/worn
|
||||||
|
|
||||||
|
|
||||||
|
class ItemInstance:
|
||||||
|
def __init__(self, item_id: str, def_id: str, quantity: int = 1):
|
||||||
|
self.item_id = item_id
|
||||||
|
self.def_id = def_id
|
||||||
|
self.quantity = quantity
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from engine.tile import Tile
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_point(x: int, y: int, width: int, height: int, rotation: int) -> tuple[int, int]:
|
||||||
|
"""Rotates (x, y) within a width x height grid by `rotation` quarter turns (clockwise).
|
||||||
|
|
||||||
|
The result is expressed in the rotated bounding box's own coordinate space (still
|
||||||
|
starting at (0, 0)). Use rotated_size() for that box's new width/height.
|
||||||
|
"""
|
||||||
|
rotation %= 4
|
||||||
|
if rotation == 0:
|
||||||
|
return x, y
|
||||||
|
if rotation == 1:
|
||||||
|
return height - 1 - y, x
|
||||||
|
if rotation == 2:
|
||||||
|
return width - 1 - x, height - 1 - y
|
||||||
|
return y, width - 1 - x
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_vector(dx: int, dy: int, quarter_turns: int) -> tuple[int, int]:
|
||||||
|
"""Rotates a free direction vector (not bound to a grid) by quarter turns (clockwise).
|
||||||
|
|
||||||
|
Unlike rotate_point, there's no bounding box to stay within - this is for things like a
|
||||||
|
projectile's ricochet direction, not repositioning a point inside a map's own grid.
|
||||||
|
"""
|
||||||
|
quarter_turns %= 4
|
||||||
|
if quarter_turns == 0:
|
||||||
|
return dx, dy
|
||||||
|
if quarter_turns == 1:
|
||||||
|
return -dy, dx
|
||||||
|
if quarter_turns == 2:
|
||||||
|
return -dx, -dy
|
||||||
|
return dy, -dx
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_position(x: float, y: float, width: float, height: float, rotation: int) -> tuple[float, float]:
|
||||||
|
"""Continuous analogue of rotate_point, for entity positions (not tile indices) crossing a
|
||||||
|
rotated embedding boundary. Drops the "-1" rotate_point uses: that offset reflects a
|
||||||
|
*discrete* grid of {0, ..., width-1} cells, whereas a continuous position spans the
|
||||||
|
continuum [0, width], so the reflection point is width/height themselves, not width-1/height-1.
|
||||||
|
"""
|
||||||
|
rotation %= 4
|
||||||
|
if rotation == 0:
|
||||||
|
return x, y
|
||||||
|
if rotation == 1:
|
||||||
|
return height - y, x
|
||||||
|
if rotation == 2:
|
||||||
|
return width - x, height - y
|
||||||
|
return y, width - x
|
||||||
|
|
||||||
|
|
||||||
|
def rotated_size(width: int, height: int, rotation: int) -> tuple[int, int]:
|
||||||
|
return (height, width) if rotation % 2 == 1 else (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
class MapEmbedding:
|
||||||
|
"""Joins a child Map into a parent Map via an anchor + quarter-turn rotation.
|
||||||
|
|
||||||
|
Both anchor and rotation are plain mutable attributes (see move_to/rotate_to) so an
|
||||||
|
embedded map (e.g. a ship) can move and turn live. Entities already aboard keep their
|
||||||
|
position expressed in the child map's own local coordinates, so moving/turning the ship
|
||||||
|
never needs to touch them - only the transform used to cross its boundary changes.
|
||||||
|
Translating an out-of-range local coordinate correctly yields the parent-space cell just
|
||||||
|
outside the child's (rotated) footprint, which is what lets World.try_move cross the
|
||||||
|
boundary in both directions with one code path, at any anchor/rotation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent_map: "Map", child_map: "Map", anchor: tuple[int, int, int], rotation: int = 0):
|
||||||
|
self.parent_map = parent_map
|
||||||
|
self.child_map = child_map
|
||||||
|
self.anchor = anchor
|
||||||
|
self.rotation = rotation % 4
|
||||||
|
|
||||||
|
def footprint_size(self) -> tuple[int, int]:
|
||||||
|
return rotated_size(self.child_map.width, self.child_map.height, self.rotation)
|
||||||
|
|
||||||
|
def local_to_parent(self, x: int, y: int, z: int) -> tuple[int, int, int]:
|
||||||
|
rx, ry = rotate_point(x, y, self.child_map.width, self.child_map.height, self.rotation)
|
||||||
|
ax, ay, az = self.anchor
|
||||||
|
return (ax + rx, ay + ry, az + z)
|
||||||
|
|
||||||
|
def parent_to_local(self, px: int, py: int, pz: int) -> tuple[int, int, int] | None:
|
||||||
|
ax, ay, az = self.anchor
|
||||||
|
rx, ry, rz = px - ax, py - ay, pz - az
|
||||||
|
fw, fh = self.footprint_size()
|
||||||
|
if not (0 <= rx < fw and 0 <= ry < fh and 0 <= rz < self.child_map.depth):
|
||||||
|
return None
|
||||||
|
x, y = rotate_point(rx, ry, fw, fh, (4 - self.rotation) % 4)
|
||||||
|
return (x, y, rz)
|
||||||
|
|
||||||
|
def local_to_parent_pos(self, x: float, y: float, z: float) -> tuple[float, float, float]:
|
||||||
|
"""Continuous-position counterpart of local_to_parent, for entities (not tile lookups)."""
|
||||||
|
rx, ry = rotate_position(x, y, self.child_map.width, self.child_map.height, self.rotation)
|
||||||
|
ax, ay, az = self.anchor
|
||||||
|
return (ax + rx, ay + ry, az + z)
|
||||||
|
|
||||||
|
def parent_to_local_pos(self, px: float, py: float, pz: float) -> tuple[float, float, float]:
|
||||||
|
"""Continuous-position counterpart of parent_to_local. Unlike parent_to_local, this
|
||||||
|
doesn't bounds-check (the caller already knows the tile is inside the footprint via
|
||||||
|
embedding_at) - it just carries the fractional offset through the rotation correctly.
|
||||||
|
"""
|
||||||
|
ax, ay, az = self.anchor
|
||||||
|
rx, ry, rz = px - ax, py - ay, pz - az
|
||||||
|
fw, fh = self.footprint_size()
|
||||||
|
x, y = rotate_position(rx, ry, fw, fh, (4 - self.rotation) % 4)
|
||||||
|
return (x, y, rz)
|
||||||
|
|
||||||
|
def move_to(self, anchor: tuple[int, int, int]) -> None:
|
||||||
|
"""Live-relocate the embedded map (e.g. a ship sailing). Aboard entities need no update."""
|
||||||
|
self.anchor = anchor
|
||||||
|
|
||||||
|
def rotate_to(self, rotation: int) -> None:
|
||||||
|
"""Turn the embedded map to a new quarter-turn orientation (0-3, clockwise)."""
|
||||||
|
self.rotation = rotation % 4
|
||||||
|
|
||||||
|
|
||||||
|
class Map:
|
||||||
|
def __init__(self, map_id: str, width: int, height: int, depth: int, source_def: str | None = None):
|
||||||
|
self.map_id = map_id
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.depth = depth
|
||||||
|
self.source_def = source_def or f"{map_id}.json"
|
||||||
|
self._tiles: list[Tile] = [Tile() for _ in range(width * height * depth)]
|
||||||
|
self.embeddings: list[MapEmbedding] = []
|
||||||
|
self.parent_embedding: MapEmbedding | None = None
|
||||||
|
|
||||||
|
def _index(self, x: int, y: int, z: int) -> int:
|
||||||
|
return x + y * self.width + z * self.width * self.height
|
||||||
|
|
||||||
|
def in_bounds(self, x: float, y: float, z: float) -> bool:
|
||||||
|
"""Accepts continuous coordinates too - which tile a position falls in is floor(x), floor(y)."""
|
||||||
|
x, y, z = math.floor(x), math.floor(y), math.floor(z)
|
||||||
|
return 0 <= x < self.width and 0 <= y < self.height and 0 <= z < self.depth
|
||||||
|
|
||||||
|
def get_tile(self, x: float, y: float, z: float) -> Tile:
|
||||||
|
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
|
||||||
|
if not self.in_bounds(fx, fy, fz):
|
||||||
|
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
|
||||||
|
return self._tiles[self._index(fx, fy, fz)]
|
||||||
|
|
||||||
|
def set_tile(self, x: float, y: float, z: float, tile: Tile) -> None:
|
||||||
|
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
|
||||||
|
if not self.in_bounds(fx, fy, fz):
|
||||||
|
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
|
||||||
|
self._tiles[self._index(fx, fy, fz)] = tile
|
||||||
|
|
||||||
|
def embed(self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0) -> MapEmbedding:
|
||||||
|
embedding = MapEmbedding(self, child, anchor, rotation)
|
||||||
|
self.embeddings.append(embedding)
|
||||||
|
child.parent_embedding = embedding
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
def embedding_at(self, x: float, y: float, z: float) -> MapEmbedding | None:
|
||||||
|
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
|
||||||
|
for embedding in self.embeddings:
|
||||||
|
if embedding.parent_to_local(fx, fy, fz) is not None:
|
||||||
|
return embedding
|
||||||
|
return None
|
||||||
|
|
||||||
|
def embeddings_at_z(self, z: int) -> list[MapEmbedding]:
|
||||||
|
return [e for e in self.embeddings if e.anchor[2] <= z < e.anchor[2] + e.child_map.depth]
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import TILE_LAYERS, Tile, TileLayerInstance
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_instances(fields: dict) -> dict[str, TileLayerInstance | None]:
|
||||||
|
return {layer: (TileLayerInstance(def_id) if def_id else None) for layer, def_id in fields.items() if layer in TILE_LAYERS}
|
||||||
|
|
||||||
|
|
||||||
|
class MapLoader:
|
||||||
|
"""Builds Map graphs (including embedded sub-maps) from resources/defs/maps/*.json."""
|
||||||
|
|
||||||
|
def __init__(self, maps_dir: Path):
|
||||||
|
self.maps_dir = maps_dir
|
||||||
|
self._cache: dict[str, Map] = {}
|
||||||
|
|
||||||
|
def load(self, filename: str) -> Map:
|
||||||
|
data = json.loads((self.maps_dir / filename).read_text())
|
||||||
|
map_id = data["map_id"]
|
||||||
|
if map_id in self._cache:
|
||||||
|
return self._cache[map_id]
|
||||||
|
|
||||||
|
m = Map(map_id, data["width"], data["height"], data["depth"], source_def=filename)
|
||||||
|
self._cache[map_id] = m
|
||||||
|
|
||||||
|
default_tile = data.get("default_tile", {})
|
||||||
|
for z in range(m.depth):
|
||||||
|
for y in range(m.height):
|
||||||
|
for x in range(m.width):
|
||||||
|
m.set_tile(x, y, z, Tile(**_layer_instances(default_tile)))
|
||||||
|
|
||||||
|
for override in data.get("tiles", []):
|
||||||
|
x, y, z = override["x"], override["y"], override["z"]
|
||||||
|
fields = {k: v for k, v in override.items() if k not in ("x", "y", "z")}
|
||||||
|
tile = m.get_tile(x, y, z)
|
||||||
|
for layer, instance in _layer_instances(fields).items():
|
||||||
|
tile.set_layer(layer, instance)
|
||||||
|
|
||||||
|
for embedding in data.get("embeddings", []):
|
||||||
|
child = self.load(embedding["map_file"])
|
||||||
|
m.embed(child, tuple(embedding["anchor"]), embedding.get("rotation", 0))
|
||||||
|
|
||||||
|
return m
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from engine.network.protocol import DEFAULT_PORT, encode_message
|
||||||
|
|
||||||
|
|
||||||
|
class GameClient:
|
||||||
|
"""Minimal TCP client for GameServer: sends hello/move, receives broadcasts from other players."""
|
||||||
|
|
||||||
|
def __init__(self, entity_id: str, name: str, host: str = "127.0.0.1", port: int = DEFAULT_PORT):
|
||||||
|
self.entity_id = entity_id
|
||||||
|
self.name = name
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._reader: asyncio.StreamReader | None = None
|
||||||
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._reader, self._writer = await asyncio.open_connection(self.host, self.port)
|
||||||
|
await self.send({"type": "hello", "entity_id": self.entity_id, "name": self.name})
|
||||||
|
|
||||||
|
async def send(self, message: dict) -> None:
|
||||||
|
assert self._writer is not None
|
||||||
|
self._writer.write(encode_message(message))
|
||||||
|
await self._writer.drain()
|
||||||
|
|
||||||
|
async def send_move(self, map_id: str, x: int, y: int, z: int) -> None:
|
||||||
|
await self.send({"type": "move", "entity_id": self.entity_id, "map_id": map_id, "x": x, "y": y, "z": z})
|
||||||
|
|
||||||
|
async def receive(self) -> dict | None:
|
||||||
|
assert self._reader is not None
|
||||||
|
line = await self._reader.readline()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._writer is not None:
|
||||||
|
self._writer.close()
|
||||||
|
await self._writer.wait_closed()
|
||||||
|
self._writer = None
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
DEFAULT_PORT = 42069
|
||||||
|
|
||||||
|
|
||||||
|
def encode_message(message: dict) -> bytes:
|
||||||
|
return (json.dumps(message) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_message(line: bytes) -> dict:
|
||||||
|
return json.loads(line.decode("utf-8"))
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from engine.network.protocol import DEFAULT_PORT, encode_message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectedClient:
|
||||||
|
entity_id: str
|
||||||
|
writer: asyncio.StreamWriter
|
||||||
|
|
||||||
|
|
||||||
|
class GameServer:
|
||||||
|
"""Minimal authoritative-relay TCP server: broadcasts join/move/leave to every other client.
|
||||||
|
|
||||||
|
Wire format is JSON-lines (see engine/network/protocol.py). This is a foundation, not a
|
||||||
|
full sync engine - it relays messages verbatim rather than validating/simulating world
|
||||||
|
state server-side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.clients: dict[str, ConnectedClient] = {}
|
||||||
|
self._server: asyncio.AbstractServer | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._server = await asyncio.start_server(self._handle_client, self.host, self.port)
|
||||||
|
self.port = self._server.sockets[0].getsockname()[1]
|
||||||
|
|
||||||
|
async def serve_forever(self) -> None:
|
||||||
|
assert self._server is not None
|
||||||
|
async with self._server:
|
||||||
|
await self._server.serve_forever()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._server is not None:
|
||||||
|
self._server.close()
|
||||||
|
await self._server.wait_closed()
|
||||||
|
self._server = None
|
||||||
|
|
||||||
|
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
|
entity_id: str | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await reader.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
message = json.loads(line)
|
||||||
|
if message.get("type") == "hello":
|
||||||
|
entity_id = str(message["entity_id"])
|
||||||
|
self.clients[entity_id] = ConnectedClient(entity_id, writer)
|
||||||
|
await self._broadcast(
|
||||||
|
{"type": "join", "entity_id": entity_id, "name": message.get("name", "")}, exclude=entity_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._broadcast(message, exclude=entity_id)
|
||||||
|
finally:
|
||||||
|
if entity_id is not None:
|
||||||
|
self.clients.pop(entity_id, None)
|
||||||
|
await self._broadcast({"type": "leave", "entity_id": entity_id})
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
async def _broadcast(self, message: dict, exclude: str | None = None) -> None:
|
||||||
|
data = encode_message(message)
|
||||||
|
for client_id, client in list(self.clients.items()):
|
||||||
|
if client_id == exclude:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client.writer.write(data)
|
||||||
|
await client.writer.drain()
|
||||||
|
except ConnectionError:
|
||||||
|
self.clients.pop(client_id, None)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
TEXTURE_SIZE = 32
|
||||||
|
BORDER_SHADE = 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def _checker_image(color: tuple[int, int, int], size: int = TEXTURE_SIZE) -> Image.Image:
|
||||||
|
img = Image.new("RGBA", (size, size), (*color, 255))
|
||||||
|
pixels = img.load()
|
||||||
|
border = max(1, size // 16)
|
||||||
|
shaded = tuple(int(c * BORDER_SHADE) for c in color)
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
if x < border or y < border or x >= size - border or y >= size - border:
|
||||||
|
pixels[x, y] = (*shaded, 255)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_texture(textures_dir: Path, relative_path: str, color: tuple[int, int, int]) -> None:
|
||||||
|
path = textures_dir / relative_path
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_checker_image(color).save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_placeholder_textures(registry: DefRegistry, textures_dir: Path) -> None:
|
||||||
|
"""Writes a flat-color/checker PNG for every def with a texture path, if missing."""
|
||||||
|
for layer_def in registry.tile_layer_defs.values():
|
||||||
|
if layer_def.texture:
|
||||||
|
_ensure_texture(textures_dir, layer_def.texture, layer_def.color)
|
||||||
|
for item_def in registry.item_defs.values():
|
||||||
|
if item_def.texture:
|
||||||
|
_ensure_texture(textures_dir, item_def.texture, item_def.color)
|
||||||
|
for part_def in registry.body_part_defs.values():
|
||||||
|
if part_def.texture:
|
||||||
|
_ensure_texture(textures_dir, part_def.texture, part_def.color)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import wgpu
|
||||||
|
|
||||||
|
|
||||||
|
def request_device(canvas) -> tuple["wgpu.GPUAdapter", "wgpu.GPUDevice"]:
|
||||||
|
adapter = wgpu.gpu.request_adapter_sync(canvas=canvas, power_preference="high-performance")
|
||||||
|
device = adapter.request_device_sync()
|
||||||
|
return adapter, device
|
||||||
|
|
||||||
|
|
||||||
|
def configure_context(canvas, adapter, device):
|
||||||
|
context = canvas.get_context("wgpu")
|
||||||
|
texture_format = context.get_preferred_format(adapter)
|
||||||
|
context.configure(device=device, format=texture_format)
|
||||||
|
return context, texture_format
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import wgpu
|
||||||
|
|
||||||
|
SHADER_PATH = Path(__file__).parent / "shaders" / "textured_quad.wgsl"
|
||||||
|
|
||||||
|
# position(vec2) + uv(vec2), one unit quad shared by every instance
|
||||||
|
QUAD_VERTICES = np.array(
|
||||||
|
[
|
||||||
|
[0.0, 0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 1.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0, 1.0],
|
||||||
|
[1.0, 1.0, 1.0, 1.0],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
QUAD_INDICES = np.array([0, 1, 2, 2, 1, 3], dtype=np.uint16)
|
||||||
|
|
||||||
|
CAMERA_UNIFORM_SIZE = 16 # scale(vec2<f32>) + offset(vec2<f32>)
|
||||||
|
|
||||||
|
|
||||||
|
class QuadPipeline:
|
||||||
|
def __init__(self, device, texture_format: str):
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
shader = device.create_shader_module(code=SHADER_PATH.read_text())
|
||||||
|
|
||||||
|
self.camera_buffer = device.create_buffer(
|
||||||
|
size=CAMERA_UNIFORM_SIZE,
|
||||||
|
usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST,
|
||||||
|
)
|
||||||
|
camera_bind_group_layout = device.create_bind_group_layout(
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"binding": 0,
|
||||||
|
"visibility": wgpu.ShaderStage.VERTEX,
|
||||||
|
"buffer": {"type": wgpu.BufferBindingType.uniform},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.camera_bind_group = device.create_bind_group(
|
||||||
|
layout=camera_bind_group_layout,
|
||||||
|
entries=[{"binding": 0, "resource": {"buffer": self.camera_buffer, "offset": 0, "size": CAMERA_UNIFORM_SIZE}}],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.texture_bind_group_layout = device.create_bind_group_layout(
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"binding": 0,
|
||||||
|
"visibility": wgpu.ShaderStage.FRAGMENT,
|
||||||
|
"texture": {
|
||||||
|
"sample_type": wgpu.TextureSampleType.float,
|
||||||
|
"view_dimension": wgpu.TextureViewDimension.d2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": 1,
|
||||||
|
"visibility": wgpu.ShaderStage.FRAGMENT,
|
||||||
|
"sampler": {"type": wgpu.SamplerBindingType.filtering},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_layout = device.create_pipeline_layout(
|
||||||
|
bind_group_layouts=[camera_bind_group_layout, self.texture_bind_group_layout]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.pipeline = device.create_render_pipeline(
|
||||||
|
layout=pipeline_layout,
|
||||||
|
vertex={
|
||||||
|
"module": shader,
|
||||||
|
"entry_point": "vs_main",
|
||||||
|
"buffers": [
|
||||||
|
{
|
||||||
|
"array_stride": 4 * 4,
|
||||||
|
"step_mode": wgpu.VertexStepMode.vertex,
|
||||||
|
"attributes": [
|
||||||
|
{"format": wgpu.VertexFormat.float32x2, "offset": 0, "shader_location": 0},
|
||||||
|
{"format": wgpu.VertexFormat.float32x2, "offset": 8, "shader_location": 1},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"array_stride": 4 * 4,
|
||||||
|
"step_mode": wgpu.VertexStepMode.instance,
|
||||||
|
"attributes": [
|
||||||
|
{"format": wgpu.VertexFormat.float32x2, "offset": 0, "shader_location": 2},
|
||||||
|
{"format": wgpu.VertexFormat.float32x2, "offset": 8, "shader_location": 3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
primitive={"topology": wgpu.PrimitiveTopology.triangle_list, "cull_mode": wgpu.CullMode.none},
|
||||||
|
fragment={
|
||||||
|
"module": shader,
|
||||||
|
"entry_point": "fs_main",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"format": texture_format,
|
||||||
|
"blend": {
|
||||||
|
"color": {
|
||||||
|
"src_factor": wgpu.BlendFactor.src_alpha,
|
||||||
|
"dst_factor": wgpu.BlendFactor.one_minus_src_alpha,
|
||||||
|
"operation": wgpu.BlendOperation.add,
|
||||||
|
},
|
||||||
|
"alpha": {
|
||||||
|
"src_factor": wgpu.BlendFactor.one,
|
||||||
|
"dst_factor": wgpu.BlendFactor.one_minus_src_alpha,
|
||||||
|
"operation": wgpu.BlendOperation.add,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.quad_vertex_buffer = device.create_buffer(
|
||||||
|
size=QUAD_VERTICES.nbytes, usage=wgpu.BufferUsage.VERTEX | wgpu.BufferUsage.COPY_DST
|
||||||
|
)
|
||||||
|
device.queue.write_buffer(self.quad_vertex_buffer, 0, QUAD_VERTICES.tobytes())
|
||||||
|
|
||||||
|
self.quad_index_buffer = device.create_buffer(
|
||||||
|
size=QUAD_INDICES.nbytes, usage=wgpu.BufferUsage.INDEX | wgpu.BufferUsage.COPY_DST
|
||||||
|
)
|
||||||
|
device.queue.write_buffer(self.quad_index_buffer, 0, QUAD_INDICES.tobytes())
|
||||||
|
self.quad_index_count = len(QUAD_INDICES)
|
||||||
|
|
||||||
|
self.sampler = device.create_sampler(mag_filter=wgpu.FilterMode.nearest, min_filter=wgpu.FilterMode.nearest)
|
||||||
|
|
||||||
|
def make_texture_bind_group(self, texture_view):
|
||||||
|
return self.device.create_bind_group(
|
||||||
|
layout=self.texture_bind_group_layout,
|
||||||
|
entries=[
|
||||||
|
{"binding": 0, "resource": texture_view},
|
||||||
|
{"binding": 1, "resource": self.sampler},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_camera(self, scale: tuple[float, float], offset: tuple[float, float]) -> None:
|
||||||
|
data = np.array([scale[0], scale[1], offset[0], offset[1]], dtype=np.float32)
|
||||||
|
self.device.queue.write_buffer(self.camera_buffer, 0, data.tobytes())
|
||||||
|
|
||||||
|
def make_instance_buffer(self, instances: np.ndarray):
|
||||||
|
buffer = self.device.create_buffer(
|
||||||
|
size=instances.nbytes, usage=wgpu.BufferUsage.VERTEX | wgpu.BufferUsage.COPY_DST
|
||||||
|
)
|
||||||
|
self.device.queue.write_buffer(buffer, 0, instances.tobytes())
|
||||||
|
return buffer
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import wgpu
|
||||||
|
|
||||||
|
from engine.camera import OrthoCamera
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.map import Map, rotate_point
|
||||||
|
from engine.render.pipeline import QuadPipeline
|
||||||
|
from engine.render.texture import TextureCache
|
||||||
|
from engine.tile import TILE_LAYERS
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
CLEAR_COLOR = {"r": 0.05, "g": 0.05, "b": 0.08, "a": 1.0}
|
||||||
|
CHARACTER_SLOT_ORDER = ("head", "torso", "left_arm", "right_arm", "left_leg", "right_leg")
|
||||||
|
|
||||||
|
|
||||||
|
class Renderer:
|
||||||
|
def __init__(self, device, pipeline: QuadPipeline, texture_cache: TextureCache, registry: DefRegistry):
|
||||||
|
self.device = device
|
||||||
|
self.pipeline = pipeline
|
||||||
|
self.texture_cache = texture_cache
|
||||||
|
self.registry = registry
|
||||||
|
self._texture_bind_groups: dict[str, object] = {}
|
||||||
|
|
||||||
|
def _bind_group_for(self, relative_path: str):
|
||||||
|
if relative_path not in self._texture_bind_groups:
|
||||||
|
view = self.texture_cache.get_view(relative_path)
|
||||||
|
self._texture_bind_groups[relative_path] = self.pipeline.make_texture_bind_group(view)
|
||||||
|
return self._texture_bind_groups[relative_path]
|
||||||
|
|
||||||
|
def render_frame(self, context, world: World, camera: OrthoCamera) -> None:
|
||||||
|
scale, offset = camera.scale_offset()
|
||||||
|
self.pipeline.update_camera(scale, offset)
|
||||||
|
|
||||||
|
buckets: dict[str, list[float]] = defaultdict(list)
|
||||||
|
if world.player is not None and world.player.position is not None:
|
||||||
|
active_map = world.maps[world.player.position.map_id]
|
||||||
|
self._draw_map(active_map, (0, 0, 0), 0, world, buckets)
|
||||||
|
|
||||||
|
texture = context.get_current_texture()
|
||||||
|
view = texture.create_view()
|
||||||
|
encoder = self.device.create_command_encoder()
|
||||||
|
pass_encoder = encoder.begin_render_pass(
|
||||||
|
color_attachments=[
|
||||||
|
{
|
||||||
|
"view": view,
|
||||||
|
"clear_value": CLEAR_COLOR,
|
||||||
|
"load_op": wgpu.LoadOp.clear,
|
||||||
|
"store_op": wgpu.StoreOp.store,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pass_encoder.set_pipeline(self.pipeline.pipeline)
|
||||||
|
pass_encoder.set_bind_group(0, self.pipeline.camera_bind_group)
|
||||||
|
pass_encoder.set_vertex_buffer(0, self.pipeline.quad_vertex_buffer)
|
||||||
|
pass_encoder.set_index_buffer(self.pipeline.quad_index_buffer, wgpu.IndexFormat.uint16)
|
||||||
|
|
||||||
|
for relative_path, flat_instances in buckets.items():
|
||||||
|
instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4)
|
||||||
|
instance_buffer = self.pipeline.make_instance_buffer(instances)
|
||||||
|
pass_encoder.set_bind_group(1, self._bind_group_for(relative_path))
|
||||||
|
pass_encoder.set_vertex_buffer(1, instance_buffer)
|
||||||
|
pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances))
|
||||||
|
|
||||||
|
pass_encoder.end()
|
||||||
|
self.device.queue.submit([encoder.finish()])
|
||||||
|
|
||||||
|
def _draw_map(
|
||||||
|
self, m: Map, offset: tuple[int, int, int], rotation: int, world: World, buckets: dict[str, list[float]]
|
||||||
|
) -> None:
|
||||||
|
for z in range(m.depth):
|
||||||
|
for layer in TILE_LAYERS:
|
||||||
|
for y in range(m.height):
|
||||||
|
for x in range(m.width):
|
||||||
|
tile = m.get_tile(x, y, z)
|
||||||
|
def_id = tile.layer_id(layer)
|
||||||
|
if def_id is None:
|
||||||
|
continue
|
||||||
|
layer_def = self.registry.get_tile_layer_def(layer, def_id)
|
||||||
|
if layer_def is None or not layer_def.texture:
|
||||||
|
continue
|
||||||
|
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
|
||||||
|
buckets[layer_def.texture].extend(
|
||||||
|
(float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
for entity in world.entities_on_map(m.map_id):
|
||||||
|
if isinstance(entity, Character):
|
||||||
|
self._draw_character(entity, offset, rotation, m.width, m.height, buckets)
|
||||||
|
|
||||||
|
for embedding in m.embeddings:
|
||||||
|
arx, ary = rotate_point(embedding.anchor[0], embedding.anchor[1], m.width, m.height, rotation)
|
||||||
|
child_offset = (offset[0] + arx, offset[1] + ary, offset[2] + embedding.anchor[2])
|
||||||
|
child_rotation = (rotation + embedding.rotation) % 4
|
||||||
|
self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets)
|
||||||
|
|
||||||
|
def _draw_character(
|
||||||
|
self,
|
||||||
|
character: Character,
|
||||||
|
offset: tuple[int, int, int],
|
||||||
|
rotation: int,
|
||||||
|
map_width: int,
|
||||||
|
map_height: int,
|
||||||
|
buckets: dict[str, list[float]],
|
||||||
|
) -> None:
|
||||||
|
assert character.position is not None
|
||||||
|
rx, ry = rotate_point(character.position.x, character.position.y, map_width, map_height, rotation)
|
||||||
|
base_x = offset[0] + rx
|
||||||
|
base_y = offset[1] + ry
|
||||||
|
resolved = character.resolved_body_parts()
|
||||||
|
row_h = 1.0 / len(CHARACTER_SLOT_ORDER)
|
||||||
|
for i, slot in enumerate(CHARACTER_SLOT_ORDER):
|
||||||
|
part = resolved.get(slot)
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
part_def = self.registry.get_body_part_def(part.part_def_id)
|
||||||
|
if not part_def.texture:
|
||||||
|
continue
|
||||||
|
buckets[part_def.texture].extend(
|
||||||
|
(base_x + 0.1, base_y + i * row_h + row_h * 0.05, 0.8, row_h * 0.9)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
struct CameraUniform {
|
||||||
|
scale: vec2<f32>,
|
||||||
|
offset: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> camera: CameraUniform;
|
||||||
|
@group(1) @binding(0) var quad_texture: texture_2d<f32>;
|
||||||
|
@group(1) @binding(1) var quad_sampler: sampler;
|
||||||
|
|
||||||
|
struct VertexIn {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct InstanceIn {
|
||||||
|
@location(2) world_pos: vec2<f32>,
|
||||||
|
@location(3) size: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VertexOut {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(vin: VertexIn, iin: InstanceIn) -> VertexOut {
|
||||||
|
var out: VertexOut;
|
||||||
|
let world = iin.world_pos + vin.position * iin.size;
|
||||||
|
let ndc = world * camera.scale + camera.offset;
|
||||||
|
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||||
|
out.uv = vin.uv;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
|
||||||
|
let color = textureSample(quad_texture, quad_sampler, in.uv);
|
||||||
|
if (color.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import wgpu
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
class TextureCache:
|
||||||
|
def __init__(self, device, textures_dir: Path):
|
||||||
|
self.device = device
|
||||||
|
self.textures_dir = textures_dir
|
||||||
|
self._views: dict[str, "wgpu.GPUTextureView"] = {}
|
||||||
|
|
||||||
|
def get_view(self, relative_path: str) -> "wgpu.GPUTextureView":
|
||||||
|
if relative_path not in self._views:
|
||||||
|
self._views[relative_path] = self._load(relative_path)
|
||||||
|
return self._views[relative_path]
|
||||||
|
|
||||||
|
def _load(self, relative_path: str) -> "wgpu.GPUTextureView":
|
||||||
|
image = Image.open(self.textures_dir / relative_path).convert("RGBA")
|
||||||
|
data = np.asarray(image, dtype=np.uint8)
|
||||||
|
width, height = image.width, image.height
|
||||||
|
|
||||||
|
texture = self.device.create_texture(
|
||||||
|
size=(width, height, 1),
|
||||||
|
format=wgpu.TextureFormat.rgba8unorm,
|
||||||
|
usage=wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST,
|
||||||
|
)
|
||||||
|
self.device.queue.write_texture(
|
||||||
|
{"texture": texture},
|
||||||
|
data.tobytes(),
|
||||||
|
{"bytes_per_row": width * 4, "rows_per_image": height},
|
||||||
|
(width, height, 1),
|
||||||
|
)
|
||||||
|
return texture.create_view()
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glfw
|
||||||
|
from rendercanvas.glfw import GlfwRenderCanvas, loop
|
||||||
|
|
||||||
|
|
||||||
|
class Window:
|
||||||
|
def __init__(self, width: int = 960, height: int = 720, title: str = "Mi2d RPG Engine"):
|
||||||
|
if hasattr(glfw, "PLATFORM") and hasattr(glfw, "PLATFORM_X11"):
|
||||||
|
glfw.init_hint(glfw.PLATFORM, glfw.PLATFORM_X11)
|
||||||
|
self.canvas = GlfwRenderCanvas(size=(width, height), title=title, update_mode="continuous")
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
loop.run()
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Classic D&D-style skill placeholders. Values are skill levels/modifiers, meaning TBD by game logic.
|
||||||
|
DND_SKILLS = (
|
||||||
|
"acrobatics",
|
||||||
|
"animal_handling",
|
||||||
|
"arcana",
|
||||||
|
"athletics",
|
||||||
|
"deception",
|
||||||
|
"history",
|
||||||
|
"insight",
|
||||||
|
"intimidation",
|
||||||
|
"investigation",
|
||||||
|
"medicine",
|
||||||
|
"nature",
|
||||||
|
"perception",
|
||||||
|
"performance",
|
||||||
|
"persuasion",
|
||||||
|
"religion",
|
||||||
|
"sleight_of_hand",
|
||||||
|
"stealth",
|
||||||
|
"survival",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_bio() -> dict[str, int]:
|
||||||
|
return {skill: 0 for skill in DND_SKILLS}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.character import BodyPart
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AbilityDef:
|
||||||
|
"""An ability a species can perform, gated on the body parts it needs to work."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
required_slots: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SpeciesDef:
|
||||||
|
"""A species/preset a Character is based off: default anatomy + blood-loss thresholds."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
genders: list[str] = field(default_factory=list) # open marker: species defines its own valid gender values
|
||||||
|
default_body_parts: list[BodyPart] = field(default_factory=list)
|
||||||
|
default_organs: list[str] = field(default_factory=list) # organ_def ids; placed by each organ's own host_slot
|
||||||
|
body_type: str = "biped" # coarse anatomical shape (e.g. "biped", "quadruped") - gates which
|
||||||
|
# clothing fits at all, regardless of arm count; see ItemDef.compatible_body_types
|
||||||
|
abilities: list[AbilityDef] = field(default_factory=list)
|
||||||
|
blood_danger_threshold: float = 50.0 # % blood remaining
|
||||||
|
blood_critical_threshold: float = 25.0
|
||||||
|
health_item_compatibility: list[str] = field(default_factory=list) # placeholder for future health-item tagging
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.character_library import CharacterLibrary
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StartScreen:
|
||||||
|
"""Pure interaction state for the pre-game start screen: pick one of the current account's
|
||||||
|
persistent characters (see engine/character_library.py::CharacterLibrary) to join a session
|
||||||
|
with - single-player continuation or joining someone else's multiplayer world with a
|
||||||
|
character built up over time.
|
||||||
|
|
||||||
|
No rendering here - same boundary as LayerPickerMenu/AbilityBar/CharacterMenu in
|
||||||
|
engine/ui.py: listing characters as clickable rows and drawing the screen itself is a
|
||||||
|
follow-up UI-layer task. This is the state/logic side: which characters exist for this
|
||||||
|
account, which one is currently selected, and handing off to join_selected_character()
|
||||||
|
once confirmed. Creating a brand new character is a separate flow - see
|
||||||
|
engine/character_creator.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
account_id: str
|
||||||
|
characters: list[tuple[str, str, str | None]] = field(default_factory=list) # (character_id, name, species_id)
|
||||||
|
selected_index: int | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_for_account(cls, account_id: str, library: CharacterLibrary) -> "StartScreen":
|
||||||
|
return cls(account_id=account_id, characters=library.list_characters(account_id))
|
||||||
|
|
||||||
|
def refresh(self, library: CharacterLibrary) -> None:
|
||||||
|
"""Re-reads the character list (e.g. after creating or deleting one) and clears selection."""
|
||||||
|
self.characters = library.list_characters(self.account_id)
|
||||||
|
self.selected_index = None
|
||||||
|
|
||||||
|
def select(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self.characters):
|
||||||
|
self.selected_index = index
|
||||||
|
|
||||||
|
def selected_character_id(self) -> str | None:
|
||||||
|
if self.selected_index is None:
|
||||||
|
return None
|
||||||
|
return self.characters[self.selected_index][0]
|
||||||
|
|
||||||
|
|
||||||
|
def join_world_with_character(
|
||||||
|
world: World,
|
||||||
|
library: CharacterLibrary,
|
||||||
|
account_id: str,
|
||||||
|
character_id: str,
|
||||||
|
registry: DefRegistry,
|
||||||
|
spawn_position: EntityPosition,
|
||||||
|
) -> Character | None:
|
||||||
|
"""Loads a persistent character from the library and drops it into `world` at
|
||||||
|
`spawn_position` - the "confirm join" action once a StartScreen selection is made. Returns
|
||||||
|
None (no-op on `world`) if no such character exists for this account.
|
||||||
|
"""
|
||||||
|
character = library.load_character(account_id, character_id, registry)
|
||||||
|
if character is None:
|
||||||
|
return None
|
||||||
|
character.position = spawn_position
|
||||||
|
world.add_entity(character)
|
||||||
|
return character
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
TILE_LAYERS = ("subfloor", "flooring", "room", "roof")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TileLayerDef:
|
||||||
|
id: str
|
||||||
|
layer: str
|
||||||
|
texture: str | None = None
|
||||||
|
color: tuple[int, int, int] = (255, 0, 255)
|
||||||
|
walkable: bool = True
|
||||||
|
blocks_los: bool = False
|
||||||
|
deconstruct_item_id: str | None = None # item yielded by a careful (right-click) deconstruction
|
||||||
|
is_helm: bool = False # standing here lets a character steer the embedded map it's part of
|
||||||
|
staircase_direction: str | None = None # "up" or "down" - see Tile.staircase_direction
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TileLayerInstance:
|
||||||
|
"""A layer's actual per-cell state (as opposed to its static def)."""
|
||||||
|
|
||||||
|
def_id: str
|
||||||
|
integrity: float = 100.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tile:
|
||||||
|
subfloor: TileLayerInstance | None = None
|
||||||
|
flooring: TileLayerInstance | None = None
|
||||||
|
room: TileLayerInstance | None = None
|
||||||
|
roof: TileLayerInstance | None = None
|
||||||
|
|
||||||
|
def get_layer(self, layer: str) -> TileLayerInstance | None:
|
||||||
|
return getattr(self, layer)
|
||||||
|
|
||||||
|
def set_layer(self, layer: str, instance: TileLayerInstance | None) -> None:
|
||||||
|
setattr(self, layer, instance)
|
||||||
|
|
||||||
|
def layer_id(self, layer: str) -> str | None:
|
||||||
|
instance = self.get_layer(layer)
|
||||||
|
return instance.def_id if instance is not None else None
|
||||||
|
|
||||||
|
def is_walkable(self, registry) -> bool:
|
||||||
|
for layer in ("room", "flooring"):
|
||||||
|
layer_def = registry.get_tile_layer_def(layer, self.layer_id(layer))
|
||||||
|
if layer_def is not None and not layer_def.walkable:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def staircase_direction(self, registry) -> str | None:
|
||||||
|
"""'up'/'down' if either layer here is a staircase/ladder, else None."""
|
||||||
|
for layer in ("room", "flooring"):
|
||||||
|
layer_def = registry.get_tile_layer_def(layer, self.layer_id(layer))
|
||||||
|
if layer_def is not None and layer_def.staircase_direction is not None:
|
||||||
|
return layer_def.staircase_direction
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import itertools
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class GameClock:
|
||||||
|
"""Converts real elapsed time into discrete game ticks at a fixed rate."""
|
||||||
|
|
||||||
|
def __init__(self, tick_interval: float = 1.0):
|
||||||
|
self.tick_interval = tick_interval
|
||||||
|
self.total_time = 0.0
|
||||||
|
self.tick_count = 0
|
||||||
|
self._accumulator = 0.0
|
||||||
|
|
||||||
|
def advance(self, dt: float) -> int:
|
||||||
|
"""Advance by dt real seconds; returns how many whole ticks just elapsed."""
|
||||||
|
self.total_time += dt
|
||||||
|
self._accumulator += dt
|
||||||
|
ticks = int(self._accumulator // self.tick_interval)
|
||||||
|
if ticks:
|
||||||
|
self._accumulator -= ticks * self.tick_interval
|
||||||
|
self.tick_count += ticks
|
||||||
|
return ticks
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(order=True)
|
||||||
|
class _ScheduledEvent:
|
||||||
|
run_at: float
|
||||||
|
seq: int
|
||||||
|
event_id: str = field(compare=False)
|
||||||
|
callback: Callable[[], None] = field(compare=False)
|
||||||
|
interval: float | None = field(default=None, compare=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
"""Min-heap of one-off or repeating callbacks, run against a game-time value you drive (e.g. GameClock.total_time)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._heap: list[_ScheduledEvent] = []
|
||||||
|
self._seq = itertools.count()
|
||||||
|
|
||||||
|
def schedule_at(self, run_at: float, event_id: str, callback: Callable[[], None], interval: float | None = None) -> None:
|
||||||
|
heapq.heappush(self._heap, _ScheduledEvent(run_at, next(self._seq), event_id, callback, interval))
|
||||||
|
|
||||||
|
def schedule_in(self, now: float, delay: float, event_id: str, callback: Callable[[], None], interval: float | None = None) -> None:
|
||||||
|
self.schedule_at(now + delay, event_id, callback, interval)
|
||||||
|
|
||||||
|
def run_due(self, now: float) -> list[str]:
|
||||||
|
"""Runs (and pops) every event due at or before `now`; repeating events are rescheduled. Returns the ids run."""
|
||||||
|
ran: list[str] = []
|
||||||
|
while self._heap and self._heap[0].run_at <= now:
|
||||||
|
event = heapq.heappop(self._heap)
|
||||||
|
event.callback()
|
||||||
|
ran.append(event.event_id)
|
||||||
|
if event.interval is not None:
|
||||||
|
self.schedule_at(event.run_at + event.interval, event.event_id, event.callback, event.interval)
|
||||||
|
return ran
|
||||||
|
|
||||||
|
def pending_count(self) -> int:
|
||||||
|
return len(self._heap)
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LayerPickerMenu:
|
||||||
|
"""Pure interaction state for choosing which of a tile's occupied layers a tool targets.
|
||||||
|
|
||||||
|
No rendering here - this project has no text-rendering system yet, so drawing an actual
|
||||||
|
on-screen popup box is a follow-up UI-layer task. This is the game-logic side of it: which
|
||||||
|
option is highlighted, and what layer name comes back once the player confirms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
map_id: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
z: int
|
||||||
|
options: list[str] = field(default_factory=list)
|
||||||
|
selected_index: int = 0
|
||||||
|
|
||||||
|
def cycle(self, delta: int = 1) -> None:
|
||||||
|
if self.options:
|
||||||
|
self.selected_index = (self.selected_index + delta) % len(self.options)
|
||||||
|
|
||||||
|
def confirm(self) -> str | None:
|
||||||
|
if not self.options:
|
||||||
|
return None
|
||||||
|
return self.options[self.selected_index]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AbilityBarSlot:
|
||||||
|
"""One assigned slot on the ability bar: where to find the ability-granting thing (a
|
||||||
|
wielded item in a hand, or an installed implant) when this slot gets triggered - see
|
||||||
|
resources/logic/actions.py::activate_ability_bar_slot for the dispatch on `kind`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: str # "hand" or "implant"
|
||||||
|
source_id: str # hand_slot name (e.g. "right_hand") or implant item_def_id
|
||||||
|
ability_id: str # the ability granted, for display/bookkeeping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AbilityBar:
|
||||||
|
"""Pure interaction state for the bottom-left ability bar: up to 10 assignable slots
|
||||||
|
(numkeys 1-9 and 0), selectable by mouse click or hotkey - both just call select().
|
||||||
|
|
||||||
|
No rendering here - same boundary as LayerPickerMenu above: this project has no
|
||||||
|
text/icon-rendering system yet, so drawing the actual bar and highlighting the
|
||||||
|
selected slot on-screen is a follow-up UI-layer task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slots: list[AbilityBarSlot | None] = field(default_factory=lambda: [None] * 10)
|
||||||
|
selected_index: int | None = None
|
||||||
|
|
||||||
|
def assign(self, index: int, slot: AbilityBarSlot | None) -> None:
|
||||||
|
self.slots[index] = slot
|
||||||
|
|
||||||
|
def select(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self.slots):
|
||||||
|
self.selected_index = index
|
||||||
|
|
||||||
|
def selected_slot(self) -> AbilityBarSlot | None:
|
||||||
|
if self.selected_index is None:
|
||||||
|
return None
|
||||||
|
return self.slots[self.selected_index]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CharacterMenu:
|
||||||
|
"""Pure interaction state for the bottom-left tabbed character menu: a "bio" tab (skills/
|
||||||
|
mental stats/health - all already on the Character model) and an "equipment" tab (worn
|
||||||
|
clothing + held items). Right-clicking an equipped slot that grants an inventory (e.g. a
|
||||||
|
worn backpack) opens a popup submenu above the menu showing that slot's contents.
|
||||||
|
|
||||||
|
No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio and
|
||||||
|
equipment data already lives on the Character/DefRegistry models this engine built over the
|
||||||
|
whole session, so this class only tracks which tab is active and which backpack popup (if
|
||||||
|
any) is open - drawing the tabs, labels, and popup box is a follow-up UI-layer task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TABS = ("bio", "equipment")
|
||||||
|
|
||||||
|
active_tab: str = "bio"
|
||||||
|
open_backpack_slot: str | None = None # clothing slot whose granted-inventory popup is open
|
||||||
|
|
||||||
|
def select_tab(self, tab: str) -> None:
|
||||||
|
if tab in self.TABS:
|
||||||
|
self.active_tab = tab
|
||||||
|
self.open_backpack_slot = None # leaving equipment closes any open popup
|
||||||
|
|
||||||
|
def right_click_equipment_slot(self, slot: str, character, registry) -> bool:
|
||||||
|
"""Toggles the backpack popup for a worn item that grants an inventory (e.g. a
|
||||||
|
backpack). No-op (returns False, nothing opens) for an empty slot or a worn item with
|
||||||
|
no granted inventory - e.g. a plain jacket has nothing to pop open.
|
||||||
|
"""
|
||||||
|
piece = character.get_clothing(slot)
|
||||||
|
if piece is None:
|
||||||
|
return False
|
||||||
|
item_def = registry.get_item_def(piece.item_def_id)
|
||||||
|
if item_def.grants_inventory_size is None:
|
||||||
|
return False
|
||||||
|
if self.open_backpack_slot == slot:
|
||||||
|
self.open_backpack_slot = None
|
||||||
|
return False
|
||||||
|
self.open_backpack_slot = slot
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close_backpack_popup(self) -> None:
|
||||||
|
self.open_backpack_slot = None
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
|
||||||
|
LEAP_DISTANCE = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeWarpZone:
|
||||||
|
"""A timed spherical zone that slows everything inside it except a whitelist of entities.
|
||||||
|
|
||||||
|
Generic on purpose: size (radius), speed_factor, whitelist (immune_entity_ids), and time
|
||||||
|
(expires_at, an absolute GameClock.total_time) are all just constructor args - nothing here
|
||||||
|
assumes it was cast by a character ability vs. triggered by an item (see
|
||||||
|
resources/logic/abilities.py's cast_time_warp_sphere for both attachment points).
|
||||||
|
"""
|
||||||
|
|
||||||
|
map_id: str
|
||||||
|
center: tuple[float, float, float]
|
||||||
|
radius: float
|
||||||
|
speed_factor: float
|
||||||
|
expires_at: float
|
||||||
|
immune_entity_ids: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def contains(self, map_id: str, x: float, y: float, z: float) -> bool:
|
||||||
|
if map_id != self.map_id or math.floor(z) != math.floor(self.center[2]):
|
||||||
|
return False
|
||||||
|
dx, dy = x - self.center[0], y - self.center[1]
|
||||||
|
return math.hypot(dx, dy) <= self.radius
|
||||||
|
|
||||||
|
def affects(self, entity_id: str) -> bool:
|
||||||
|
return entity_id not in self.immune_entity_ids
|
||||||
|
|
||||||
|
|
||||||
|
class World:
|
||||||
|
"""Owns the map graph, all entities, and the def registry; drives movement."""
|
||||||
|
|
||||||
|
def __init__(self, registry=None):
|
||||||
|
self.registry = registry
|
||||||
|
self.maps: dict[str, Map] = {}
|
||||||
|
self.entities: dict[str, Entity] = {}
|
||||||
|
self.player: Entity | None = None
|
||||||
|
self.time_warp_zones: list[TimeWarpZone] = []
|
||||||
|
|
||||||
|
def speed_multiplier_at(self, entity_id: str, map_id: str, x: float, y: float, z: float, now: float) -> float:
|
||||||
|
"""Combined slow from every active, non-expired zone that affects this entity here."""
|
||||||
|
multiplier = 1.0
|
||||||
|
for zone in self.time_warp_zones:
|
||||||
|
if zone.expires_at > now and zone.affects(entity_id) and zone.contains(map_id, x, y, z):
|
||||||
|
multiplier *= zone.speed_factor
|
||||||
|
return multiplier
|
||||||
|
|
||||||
|
def expire_time_warp_zones(self, now: float) -> None:
|
||||||
|
self.time_warp_zones = [z for z in self.time_warp_zones if z.expires_at > now]
|
||||||
|
|
||||||
|
def add_map(self, map_: Map) -> None:
|
||||||
|
self.maps[map_.map_id] = map_
|
||||||
|
|
||||||
|
def add_entity(self, entity: Entity) -> None:
|
||||||
|
self.entities[entity.entity_id] = entity
|
||||||
|
|
||||||
|
def entities_on_map(self, map_id: str) -> list[Entity]:
|
||||||
|
return [e for e in self.entities.values() if e.position is not None and e.position.map_id == map_id]
|
||||||
|
|
||||||
|
def entity_at(self, map_id: str, x: int, y: int, z: int) -> Entity | None:
|
||||||
|
"""Tile-membership lookup: matches any entity whose (possibly continuous) position
|
||||||
|
falls in tile (x, y, z), i.e. floor(pos.x) == x etc. Floor of an int is itself, so this
|
||||||
|
is unchanged for entities that happen to sit exactly on a tile.
|
||||||
|
"""
|
||||||
|
for entity in self.entities.values():
|
||||||
|
pos = entity.position
|
||||||
|
if pos is None or pos.map_id != map_id:
|
||||||
|
continue
|
||||||
|
if (math.floor(pos.x), math.floor(pos.y), math.floor(pos.z)) == (x, y, z):
|
||||||
|
return entity
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_move(self, entity: Entity, dx: int, dy: int, dz: int) -> bool:
|
||||||
|
assert entity.position is not None
|
||||||
|
cur_map = self.maps[entity.position.map_id]
|
||||||
|
tx = entity.position.x + dx
|
||||||
|
ty = entity.position.y + dy
|
||||||
|
tz = entity.position.z + dz
|
||||||
|
moved = self._move_within(entity, cur_map, tx, ty, tz)
|
||||||
|
if moved:
|
||||||
|
self._apply_staircase(entity)
|
||||||
|
return moved
|
||||||
|
|
||||||
|
def try_leap(self, entity: Entity, dx: int, dy: int, dz: int, distance: int = LEAP_DISTANCE) -> bool:
|
||||||
|
"""A leap in the given unit direction, e.g. to clear a gap and land aboard a ship.
|
||||||
|
|
||||||
|
Reuses try_move's own semantics unchanged: it only ever validates the landing tile,
|
||||||
|
never anything in between, so scaling the delta is all a "jump over a gap" needs.
|
||||||
|
|
||||||
|
If the entity's species defines a "leap" ability (e.g. requiring both legs), it's
|
||||||
|
gated on that; species/entities with no such ability defined are left unrestricted.
|
||||||
|
"""
|
||||||
|
if isinstance(entity, Character) and entity.species_id is not None and self.registry is not None:
|
||||||
|
species = self.registry.get_species_def(entity.species_id)
|
||||||
|
if any(a.id == "leap" for a in species.abilities) and not entity.can_perform_ability("leap", self.registry):
|
||||||
|
return False
|
||||||
|
return self.try_move(entity, dx * distance, dy * distance, dz * distance)
|
||||||
|
|
||||||
|
def _move_within(self, entity: Entity, map_: Map, x: int, y: int, z: int) -> bool:
|
||||||
|
if map_.in_bounds(x, y, z):
|
||||||
|
embedding = map_.embedding_at(x, y, z)
|
||||||
|
if embedding is not None:
|
||||||
|
lx, ly, lz = embedding.parent_to_local(x, y, z)
|
||||||
|
if not embedding.child_map.get_tile(lx, ly, lz).is_walkable(self.registry):
|
||||||
|
return False
|
||||||
|
entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz)
|
||||||
|
return True
|
||||||
|
if not map_.get_tile(x, y, z).is_walkable(self.registry):
|
||||||
|
return False
|
||||||
|
entity.position = EntityPosition(map_.map_id, x, y, z)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if map_.parent_embedding is not None:
|
||||||
|
embedding = map_.parent_embedding
|
||||||
|
px, py, pz = embedding.local_to_parent(x, y, z)
|
||||||
|
return self._move_within(entity, embedding.parent_map, px, py, pz)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def try_move_continuous(self, entity: Entity, dx: float, dy: float, dz: float, distance: float) -> bool:
|
||||||
|
"""True stepless movement: moves `entity` by `distance` along the (dx, dy, dz) direction
|
||||||
|
(need not be a unit vector - it's normalized here), landing at a fractional position
|
||||||
|
rather than snapping to a tile. Collision is checked against whichever tile the
|
||||||
|
destination falls in (floor(x), floor(y)) - same walkability/embedding rules as
|
||||||
|
try_move, just point-based rather than a full sweep, and the path between old and new
|
||||||
|
position isn't swept for obstacles (a fast-enough mover could tunnel a thin wall in one
|
||||||
|
frame - a known simplification, not a concern at ordinary per-frame speeds/dt).
|
||||||
|
"""
|
||||||
|
assert entity.position is not None
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
if length == 0:
|
||||||
|
return False
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
nx = entity.position.x + ux * distance
|
||||||
|
ny = entity.position.y + uy * distance
|
||||||
|
nz = entity.position.z
|
||||||
|
moved = self._move_within_continuous(entity, self.maps[entity.position.map_id], nx, ny, nz)
|
||||||
|
if moved:
|
||||||
|
self._apply_staircase(entity)
|
||||||
|
return moved
|
||||||
|
|
||||||
|
def _apply_staircase(self, entity: Entity) -> None:
|
||||||
|
"""A single z-shift if the entity just landed on a staircase/ladder tile, provided the
|
||||||
|
target floor is in bounds and walkable at the same (x, y). Not recursive - a stack of
|
||||||
|
staircases each carries you up/down exactly one level per step onto them.
|
||||||
|
"""
|
||||||
|
assert entity.position is not None
|
||||||
|
map_ = self.maps[entity.position.map_id]
|
||||||
|
x, y, z = entity.position.x, entity.position.y, entity.position.z
|
||||||
|
direction = map_.get_tile(x, y, z).staircase_direction(self.registry)
|
||||||
|
if direction is None:
|
||||||
|
return
|
||||||
|
nz = math.floor(z) + (1 if direction == "up" else -1)
|
||||||
|
if not map_.in_bounds(x, y, nz):
|
||||||
|
return
|
||||||
|
if not map_.get_tile(x, y, nz).is_walkable(self.registry):
|
||||||
|
return
|
||||||
|
entity.position = EntityPosition(map_.map_id, x, y, nz)
|
||||||
|
|
||||||
|
def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool:
|
||||||
|
tile_x, tile_y, tile_z = math.floor(x), math.floor(y), math.floor(z)
|
||||||
|
if map_.in_bounds(tile_x, tile_y, tile_z):
|
||||||
|
embedding = map_.embedding_at(tile_x, tile_y, tile_z)
|
||||||
|
if embedding is not None:
|
||||||
|
local_tile = embedding.parent_to_local(tile_x, tile_y, tile_z)
|
||||||
|
assert local_tile is not None
|
||||||
|
if not embedding.child_map.get_tile(*local_tile).is_walkable(self.registry):
|
||||||
|
return False
|
||||||
|
lx, ly, lz = embedding.parent_to_local_pos(x, y, z)
|
||||||
|
entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz)
|
||||||
|
return True
|
||||||
|
if not map_.get_tile(tile_x, tile_y, tile_z).is_walkable(self.registry):
|
||||||
|
return False
|
||||||
|
entity.position = EntityPosition(map_.map_id, x, y, z)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if map_.parent_embedding is not None:
|
||||||
|
embedding = map_.parent_embedding
|
||||||
|
px, py, pz = embedding.local_to_parent_pos(x, y, z)
|
||||||
|
return self._move_within_continuous(entity, embedding.parent_map, px, py, pz)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import glfw
|
||||||
|
|
||||||
|
from engine.aim import snap_to_8way
|
||||||
|
from engine.camera import OrthoCamera
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.config import KeyBindings
|
||||||
|
from engine.db import WorldRepository
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.gamepad import GamepadBindings, GamepadState
|
||||||
|
from engine.input import InputState
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
from engine.placeholder_textures import generate_placeholder_textures
|
||||||
|
from engine.render.device import configure_context, request_device
|
||||||
|
from engine.render.pipeline import QuadPipeline
|
||||||
|
from engine.render.renderer import Renderer
|
||||||
|
from engine.render.texture import TextureCache
|
||||||
|
from engine.render.window import Window
|
||||||
|
from engine.timing import GameClock
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import activate_hand
|
||||||
|
from resources.logic.health_ticks import apply_bleeding_tick
|
||||||
|
from resources.logic.organs import apply_organ_interactions_tick
|
||||||
|
from resources.logic.steering import at_helm, try_steer_ship
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent
|
||||||
|
DEFS_DIR = ROOT_DIR / "resources" / "defs"
|
||||||
|
TEXTURES_DIR = ROOT_DIR / "resources" / "textures"
|
||||||
|
DB_PATH = ROOT_DIR / "data" / "world.sqlite3"
|
||||||
|
KEYBINDINGS_PATH = ROOT_DIR / "config" / "keybindings.conf"
|
||||||
|
CONTROLLER_BINDINGS_PATH = ROOT_DIR / "config" / "controller_bindings.conf"
|
||||||
|
BASE_MOVE_SPEED = 4.0 # tiles/second at full (two healthy legs') speed factor
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_player(registry: DefRegistry) -> Character:
|
||||||
|
entity_def = registry.get_entity_def("player")
|
||||||
|
assert entity_def.species_id is not None
|
||||||
|
|
||||||
|
player = Character(
|
||||||
|
entity_id="player-1",
|
||||||
|
name=entity_def.name,
|
||||||
|
def_id=entity_def.id,
|
||||||
|
position=EntityPosition("demo_world", 4, 4, 0),
|
||||||
|
species_id=entity_def.species_id,
|
||||||
|
gender="nonbinary",
|
||||||
|
default_body_parts=registry.new_default_body_parts(entity_def.species_id),
|
||||||
|
default_clothing=registry.new_default_clothing(entity_def.id),
|
||||||
|
)
|
||||||
|
player.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
|
||||||
|
if entity_def.inventory_size is not None:
|
||||||
|
player.inventory = Inventory(*entity_def.inventory_size)
|
||||||
|
player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0)
|
||||||
|
player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0)
|
||||||
|
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def build_fresh_world(registry: DefRegistry, map_loader: MapLoader) -> World:
|
||||||
|
world = World(registry=registry)
|
||||||
|
demo_world = map_loader.load("demo_world.json")
|
||||||
|
world.add_map(demo_world)
|
||||||
|
for embedding in demo_world.embeddings:
|
||||||
|
world.add_map(embedding.child_map)
|
||||||
|
|
||||||
|
player = spawn_player(registry)
|
||||||
|
world.add_entity(player)
|
||||||
|
world.player = player
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
registry = DefRegistry.load(DEFS_DIR)
|
||||||
|
generate_placeholder_textures(registry, TEXTURES_DIR)
|
||||||
|
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
repo = WorldRepository.open(DB_PATH)
|
||||||
|
map_loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
|
||||||
|
world = repo.load_world(registry, map_loader)
|
||||||
|
if world is None:
|
||||||
|
world = build_fresh_world(registry, map_loader)
|
||||||
|
print("No save found; started a fresh world.")
|
||||||
|
else:
|
||||||
|
print(f"Loaded save: player at {world.player.position}.")
|
||||||
|
|
||||||
|
window = Window()
|
||||||
|
adapter, device = request_device(window.canvas)
|
||||||
|
context, texture_format = configure_context(window.canvas, adapter, device)
|
||||||
|
|
||||||
|
pipeline = QuadPipeline(device, texture_format)
|
||||||
|
texture_cache = TextureCache(device, TEXTURES_DIR)
|
||||||
|
renderer = Renderer(device, pipeline, texture_cache, registry)
|
||||||
|
camera = OrthoCamera(window.canvas.get_physical_size()[0], window.canvas.get_physical_size()[1])
|
||||||
|
keybindings = KeyBindings.load(KEYBINDINGS_PATH)
|
||||||
|
gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH)
|
||||||
|
input_state = InputState(keybindings)
|
||||||
|
clock = GameClock(tick_interval=1.0)
|
||||||
|
last_frame_time = time.perf_counter()
|
||||||
|
rng = random.Random()
|
||||||
|
|
||||||
|
def on_event(event: dict) -> None:
|
||||||
|
if event.get("event_type") == "close":
|
||||||
|
repo.save_world(world)
|
||||||
|
print("Saved world on close.")
|
||||||
|
else:
|
||||||
|
input_state.handle_event(event)
|
||||||
|
|
||||||
|
window.canvas.add_event_handler(on_event, "key_down", "key_up", "pointer_down", "pointer_move", "close")
|
||||||
|
|
||||||
|
def current_aim_direction(player: Character) -> tuple[int, int, int]:
|
||||||
|
assert player.position is not None
|
||||||
|
if input_state.pointer_pos is None:
|
||||||
|
return input_state.facing
|
||||||
|
wx, wy = camera.screen_to_world(*input_state.pointer_pos)
|
||||||
|
dx, dy = snap_to_8way(wx - (player.position.x + 0.5), wy - (player.position.y + 0.5))
|
||||||
|
return dx, dy, 0
|
||||||
|
|
||||||
|
def poll_gamepad() -> None:
|
||||||
|
# A gamepad is polled (not event-driven like key_down/key_up), so this just feeds
|
||||||
|
# whatever's plugged into joystick slot 1 into the same action dispatch as keyboard/
|
||||||
|
# mouse each frame - see InputState.apply_gamepad_state for the edge detection.
|
||||||
|
if glfw.joystick_present(glfw.JOYSTICK_1) and glfw.joystick_is_gamepad(glfw.JOYSTICK_1):
|
||||||
|
raw = glfw.get_gamepad_state(glfw.JOYSTICK_1)
|
||||||
|
if raw is not None:
|
||||||
|
input_state.apply_gamepad_state(GamepadState.from_glfw(raw), gamepad_bindings)
|
||||||
|
|
||||||
|
def draw_frame() -> None:
|
||||||
|
nonlocal last_frame_time
|
||||||
|
wall_now = time.perf_counter()
|
||||||
|
dt = wall_now - last_frame_time
|
||||||
|
last_frame_time = wall_now
|
||||||
|
|
||||||
|
poll_gamepad()
|
||||||
|
|
||||||
|
ticks = clock.advance(dt)
|
||||||
|
world.expire_time_warp_zones(clock.total_time)
|
||||||
|
if ticks:
|
||||||
|
for entity in world.entities.values():
|
||||||
|
if isinstance(entity, Character):
|
||||||
|
apply_bleeding_tick(entity, registry, ticks)
|
||||||
|
apply_organ_interactions_tick(entity, registry, ticks)
|
||||||
|
|
||||||
|
player = world.player
|
||||||
|
if isinstance(player, Character) and player.position is not None:
|
||||||
|
player.is_blocking = input_state.is_blocking
|
||||||
|
|
||||||
|
move_dx, move_dy, _ = input_state.current_move_vector()
|
||||||
|
if move_dx or move_dy:
|
||||||
|
if at_helm(player, world) is not None:
|
||||||
|
# steering a ship stays a discrete, one-press-per-step affair - the ship's
|
||||||
|
# own grid structure doesn't need continuous sub-tile positioning
|
||||||
|
move = input_state.consume_move()
|
||||||
|
if move is not None:
|
||||||
|
try_steer_ship(player, world, *move)
|
||||||
|
else:
|
||||||
|
speed = player.effective_speed(BASE_MOVE_SPEED, registry)
|
||||||
|
slow = world.speed_multiplier_at(
|
||||||
|
player.entity_id, player.position.map_id, player.position.x, player.position.y,
|
||||||
|
player.position.z, clock.total_time,
|
||||||
|
)
|
||||||
|
if speed > 0:
|
||||||
|
world.try_move_continuous(player, move_dx, move_dy, 0, distance=speed * slow * dt)
|
||||||
|
|
||||||
|
leap = input_state.consume_leap()
|
||||||
|
if leap is not None:
|
||||||
|
world.try_leap(player, *leap)
|
||||||
|
|
||||||
|
hand = input_state.consume_hand_activation()
|
||||||
|
if hand is not None:
|
||||||
|
activate_hand(player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time)
|
||||||
|
|
||||||
|
if isinstance(player, Character) and player.position is not None:
|
||||||
|
camera.set_target(player.position.x + 0.5, player.position.y + 0.5)
|
||||||
|
renderer.render_frame(context, world, camera)
|
||||||
|
|
||||||
|
window.canvas.request_draw(draw_frame)
|
||||||
|
window.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"venvPath": ".",
|
||||||
|
"venv": ".venv",
|
||||||
|
"include": ["engine", "resources/logic", "tests", "main.py"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
wgpu
|
||||||
|
glfw
|
||||||
|
numpy
|
||||||
|
Pillow
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
{
|
||||||
|
"human_head": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/human_head.png",
|
||||||
|
"color": [235, 200, 170],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 4.0,
|
||||||
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
|
},
|
||||||
|
"human_head_hat": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/human_head_hat.png",
|
||||||
|
"color": [90, 50, 40],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 4.0,
|
||||||
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
|
},
|
||||||
|
"human_torso": {
|
||||||
|
"slot": "torso",
|
||||||
|
"texture": "characters/human_torso.png",
|
||||||
|
"color": [70, 100, 160],
|
||||||
|
"bleeding_weight": 0.35,
|
||||||
|
"bleeding_speed": 6.0,
|
||||||
|
"skill_weights": { "athletics": 0.5 }
|
||||||
|
},
|
||||||
|
"human_left_arm": {
|
||||||
|
"slot": "left_arm",
|
||||||
|
"texture": "characters/human_left_arm.png",
|
||||||
|
"color": [235, 200, 170],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 2.0,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"human_right_arm": {
|
||||||
|
"slot": "right_arm",
|
||||||
|
"texture": "characters/human_right_arm.png",
|
||||||
|
"color": [235, 200, 170],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 2.0,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"human_left_leg": {
|
||||||
|
"slot": "left_leg",
|
||||||
|
"texture": "characters/human_left_leg.png",
|
||||||
|
"color": [60, 60, 90],
|
||||||
|
"bleeding_weight": 0.125,
|
||||||
|
"bleeding_speed": 3.0,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
"human_right_leg": {
|
||||||
|
"slot": "right_leg",
|
||||||
|
"texture": "characters/human_right_leg.png",
|
||||||
|
"color": [60, 60, 90],
|
||||||
|
"bleeding_weight": 0.125,
|
||||||
|
"bleeding_speed": 3.0,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
"bionic_leg": {
|
||||||
|
"slot": "left_leg",
|
||||||
|
"texture": "characters/bionic_leg.png",
|
||||||
|
"color": [140, 150, 160],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 1.2
|
||||||
|
},
|
||||||
|
|
||||||
|
"reptilian_head": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/reptilian_head.png",
|
||||||
|
"color": [70, 140, 70],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 3.0,
|
||||||
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
|
},
|
||||||
|
"reptilian_torso": {
|
||||||
|
"slot": "torso",
|
||||||
|
"texture": "characters/reptilian_torso.png",
|
||||||
|
"color": [50, 110, 50],
|
||||||
|
"bleeding_weight": 0.35,
|
||||||
|
"bleeding_speed": 5.0,
|
||||||
|
"skill_weights": { "athletics": 0.5 }
|
||||||
|
},
|
||||||
|
"reptilian_left_arm": {
|
||||||
|
"slot": "left_arm",
|
||||||
|
"texture": "characters/reptilian_left_arm.png",
|
||||||
|
"color": [70, 140, 70],
|
||||||
|
"bleeding_weight": 0.06,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"reptilian_right_arm": {
|
||||||
|
"slot": "right_arm",
|
||||||
|
"texture": "characters/reptilian_right_arm.png",
|
||||||
|
"color": [70, 140, 70],
|
||||||
|
"bleeding_weight": 0.06,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"reptilian_left_arm_2": {
|
||||||
|
"slot": "left_arm_2",
|
||||||
|
"texture": "characters/reptilian_left_arm_2.png",
|
||||||
|
"color": [70, 140, 70],
|
||||||
|
"bleeding_weight": 0.06,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"reptilian_right_arm_2": {
|
||||||
|
"slot": "right_arm_2",
|
||||||
|
"texture": "characters/reptilian_right_arm_2.png",
|
||||||
|
"color": [70, 140, 70],
|
||||||
|
"bleeding_weight": 0.06,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"reptilian_left_leg": {
|
||||||
|
"slot": "left_leg",
|
||||||
|
"texture": "characters/reptilian_left_leg.png",
|
||||||
|
"color": [40, 90, 40],
|
||||||
|
"bleeding_weight": 0.125,
|
||||||
|
"bleeding_speed": 2.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
"reptilian_right_leg": {
|
||||||
|
"slot": "right_leg",
|
||||||
|
"texture": "characters/reptilian_right_leg.png",
|
||||||
|
"color": [40, 90, 40],
|
||||||
|
"bleeding_weight": 0.125,
|
||||||
|
"bleeding_speed": 2.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
|
||||||
|
"alien_head": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/alien_head.png",
|
||||||
|
"color": [180, 140, 220],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 3.5,
|
||||||
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
|
},
|
||||||
|
"alien_torso": {
|
||||||
|
"slot": "torso",
|
||||||
|
"texture": "characters/alien_torso.png",
|
||||||
|
"color": [140, 100, 190],
|
||||||
|
"bleeding_weight": 0.30,
|
||||||
|
"bleeding_speed": 5.0,
|
||||||
|
"skill_weights": { "athletics": 0.5 }
|
||||||
|
},
|
||||||
|
"alien_left_arm": {
|
||||||
|
"slot": "left_arm",
|
||||||
|
"texture": "characters/alien_left_arm.png",
|
||||||
|
"color": [180, 140, 220],
|
||||||
|
"bleeding_weight": 0.08,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"alien_right_arm": {
|
||||||
|
"slot": "right_arm",
|
||||||
|
"texture": "characters/alien_right_arm.png",
|
||||||
|
"color": [180, 140, 220],
|
||||||
|
"bleeding_weight": 0.08,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"alien_left_leg": {
|
||||||
|
"slot": "left_leg",
|
||||||
|
"texture": "characters/alien_left_leg.png",
|
||||||
|
"color": [110, 80, 150],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 2.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.55
|
||||||
|
},
|
||||||
|
"alien_right_leg": {
|
||||||
|
"slot": "right_leg",
|
||||||
|
"texture": "characters/alien_right_leg.png",
|
||||||
|
"color": [110, 80, 150],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 2.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.55
|
||||||
|
},
|
||||||
|
|
||||||
|
"bot_head": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/bot_head.png",
|
||||||
|
"color": [150, 150, 160],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
|
},
|
||||||
|
"bot_torso": {
|
||||||
|
"slot": "torso",
|
||||||
|
"texture": "characters/bot_torso.png",
|
||||||
|
"color": [110, 110, 120],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "athletics": 0.5 }
|
||||||
|
},
|
||||||
|
"bot_left_arm": {
|
||||||
|
"slot": "left_arm",
|
||||||
|
"texture": "characters/bot_left_arm.png",
|
||||||
|
"color": [150, 150, 160],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"bot_right_arm": {
|
||||||
|
"slot": "right_arm",
|
||||||
|
"texture": "characters/bot_right_arm.png",
|
||||||
|
"color": [150, 150, 160],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
|
},
|
||||||
|
"bot_left_leg": {
|
||||||
|
"slot": "left_leg",
|
||||||
|
"texture": "characters/bot_left_leg.png",
|
||||||
|
"color": [90, 90, 100],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
"bot_right_leg": {
|
||||||
|
"slot": "right_leg",
|
||||||
|
"texture": "characters/bot_right_leg.png",
|
||||||
|
"color": [90, 90, 100],
|
||||||
|
"bleeding_weight": 0.0,
|
||||||
|
"bleeding_speed": 0.0,
|
||||||
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
"speed_factor": 0.5
|
||||||
|
},
|
||||||
|
|
||||||
|
"dog_head": {
|
||||||
|
"slot": "head",
|
||||||
|
"texture": "characters/dog_head.png",
|
||||||
|
"color": [150, 110, 70],
|
||||||
|
"bleeding_weight": 0.10,
|
||||||
|
"bleeding_speed": 3.0,
|
||||||
|
"skill_weights": { "perception": 0.7, "insight": 0.3 }
|
||||||
|
},
|
||||||
|
"dog_torso": {
|
||||||
|
"slot": "torso",
|
||||||
|
"texture": "characters/dog_torso.png",
|
||||||
|
"color": [130, 95, 60],
|
||||||
|
"bleeding_weight": 0.30,
|
||||||
|
"bleeding_speed": 5.0,
|
||||||
|
"skill_weights": { "athletics": 0.5 }
|
||||||
|
},
|
||||||
|
"dog_front_left_leg": {
|
||||||
|
"slot": "front_left_leg",
|
||||||
|
"texture": "characters/dog_front_left_leg.png",
|
||||||
|
"color": [110, 80, 50],
|
||||||
|
"bleeding_weight": 0.07,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
||||||
|
"speed_factor": 0.25
|
||||||
|
},
|
||||||
|
"dog_front_right_leg": {
|
||||||
|
"slot": "front_right_leg",
|
||||||
|
"texture": "characters/dog_front_right_leg.png",
|
||||||
|
"color": [110, 80, 50],
|
||||||
|
"bleeding_weight": 0.07,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
||||||
|
"speed_factor": 0.25
|
||||||
|
},
|
||||||
|
"dog_back_left_leg": {
|
||||||
|
"slot": "back_left_leg",
|
||||||
|
"texture": "characters/dog_back_left_leg.png",
|
||||||
|
"color": [110, 80, 50],
|
||||||
|
"bleeding_weight": 0.07,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
||||||
|
"speed_factor": 0.25
|
||||||
|
},
|
||||||
|
"dog_back_right_leg": {
|
||||||
|
"slot": "back_right_leg",
|
||||||
|
"texture": "characters/dog_back_right_leg.png",
|
||||||
|
"color": [110, 80, 50],
|
||||||
|
"bleeding_weight": 0.07,
|
||||||
|
"bleeding_speed": 1.5,
|
||||||
|
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
||||||
|
"speed_factor": 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"player": {
|
||||||
|
"name": "Player",
|
||||||
|
"species_id": "human",
|
||||||
|
"default_clothing": ["backpack_basic"],
|
||||||
|
"inventory_size": [6, 5]
|
||||||
|
},
|
||||||
|
"npc_reptilian": {
|
||||||
|
"name": "Reptilian",
|
||||||
|
"species_id": "reptilian_quad",
|
||||||
|
"default_clothing": [],
|
||||||
|
"inventory_size": [6, 5]
|
||||||
|
},
|
||||||
|
"npc_alien": {
|
||||||
|
"name": "Zenari",
|
||||||
|
"species_id": "alien_tri",
|
||||||
|
"default_clothing": [],
|
||||||
|
"inventory_size": [6, 5]
|
||||||
|
},
|
||||||
|
"npc_bot": {
|
||||||
|
"name": "Bot",
|
||||||
|
"species_id": "bot",
|
||||||
|
"default_clothing": [],
|
||||||
|
"inventory_size": [6, 5]
|
||||||
|
},
|
||||||
|
"npc_dog": {
|
||||||
|
"name": "Dog",
|
||||||
|
"species_id": "dog",
|
||||||
|
"default_clothing": [],
|
||||||
|
"inventory_size": [4, 4]
|
||||||
|
},
|
||||||
|
"companion_dog": {
|
||||||
|
"name": "Companion Dog",
|
||||||
|
"species_id": "dog",
|
||||||
|
"default_clothing": ["dog_harness", "dog_saddlebag"],
|
||||||
|
"inventory_size": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
{
|
||||||
|
"staff_oak": {
|
||||||
|
"name": "Oak Staff",
|
||||||
|
"width": 1,
|
||||||
|
"height": 5,
|
||||||
|
"texture": "items/staff_oak.png",
|
||||||
|
"color": [150, 100, 50],
|
||||||
|
"weapon_skill": "athletics",
|
||||||
|
"weapon_damage": 6.0,
|
||||||
|
"weapon_range": 1,
|
||||||
|
"hands_required": 1,
|
||||||
|
"melee_knockback": 1.5
|
||||||
|
},
|
||||||
|
"sandwich": {
|
||||||
|
"name": "Sandwich",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/sandwich.png",
|
||||||
|
"color": [230, 190, 100],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 4
|
||||||
|
},
|
||||||
|
"leather_jacket": {
|
||||||
|
"name": "Leather Jacket",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/leather_jacket.png",
|
||||||
|
"color": [90, 60, 40],
|
||||||
|
"clothing_slot": "torso",
|
||||||
|
"armor_reduction": 3.0,
|
||||||
|
"arm_slots_required": 2,
|
||||||
|
"compatible_body_types": ["biped"]
|
||||||
|
},
|
||||||
|
"reptilian_jacket": {
|
||||||
|
"name": "Reptilian Jacket",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/reptilian_jacket.png",
|
||||||
|
"color": [60, 100, 60],
|
||||||
|
"clothing_slot": "torso",
|
||||||
|
"armor_reduction": 3.0,
|
||||||
|
"arm_slots_required": 4,
|
||||||
|
"compatible_body_types": ["biped"]
|
||||||
|
},
|
||||||
|
"backpack_basic": {
|
||||||
|
"name": "Basic Backpack",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/backpack_basic.png",
|
||||||
|
"color": [110, 90, 60],
|
||||||
|
"clothing_slot": "back",
|
||||||
|
"grants_inventory_size": [4, 4],
|
||||||
|
"compatible_body_types": ["biped"]
|
||||||
|
},
|
||||||
|
"dog_harness": {
|
||||||
|
"name": "Dog Harness",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/dog_harness.png",
|
||||||
|
"color": [150, 60, 50],
|
||||||
|
"clothing_slot": "torso",
|
||||||
|
"armor_reduction": 2.0,
|
||||||
|
"compatible_body_types": ["quadruped"]
|
||||||
|
},
|
||||||
|
"dog_saddlebag": {
|
||||||
|
"name": "Dog Saddlebag",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/dog_saddlebag.png",
|
||||||
|
"color": [120, 90, 60],
|
||||||
|
"clothing_slot": "back",
|
||||||
|
"grants_inventory_size": [3, 3],
|
||||||
|
"compatible_body_types": ["quadruped"]
|
||||||
|
},
|
||||||
|
"wood_planks": {
|
||||||
|
"name": "Wood Planks",
|
||||||
|
"width": 2,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/wood_planks.png",
|
||||||
|
"color": [140, 100, 60],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 10,
|
||||||
|
"builds_tile_layer": "room",
|
||||||
|
"builds_tile_layer_def_id": "wood_wall"
|
||||||
|
},
|
||||||
|
"multi_deconstructor": {
|
||||||
|
"name": "Multi-Deconstructor",
|
||||||
|
"width": 1,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/multi_deconstructor.png",
|
||||||
|
"color": [200, 60, 60],
|
||||||
|
"tool_kind": "deconstructor",
|
||||||
|
"hands_required": 2
|
||||||
|
},
|
||||||
|
"constructor_tool": {
|
||||||
|
"name": "Constructor",
|
||||||
|
"width": 1,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/constructor_tool.png",
|
||||||
|
"color": [60, 160, 60],
|
||||||
|
"tool_kind": "constructor",
|
||||||
|
"hands_required": 2
|
||||||
|
},
|
||||||
|
"pistol_basic": {
|
||||||
|
"name": "Basic Pistol",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/pistol_basic.png",
|
||||||
|
"color": [70, 70, 75],
|
||||||
|
"weapon_skill": "sleight_of_hand",
|
||||||
|
"weapon_damage": 8.0,
|
||||||
|
"weapon_range": 6,
|
||||||
|
"projectile_type": "bullet",
|
||||||
|
"hands_required": 1
|
||||||
|
},
|
||||||
|
"bow_basic": {
|
||||||
|
"name": "Basic Bow",
|
||||||
|
"width": 1,
|
||||||
|
"height": 3,
|
||||||
|
"texture": "items/bow_basic.png",
|
||||||
|
"color": [130, 90, 50],
|
||||||
|
"weapon_skill": "sleight_of_hand",
|
||||||
|
"weapon_damage": 5.0,
|
||||||
|
"weapon_range": 6,
|
||||||
|
"projectile_type": "arrow",
|
||||||
|
"hands_required": 2
|
||||||
|
},
|
||||||
|
"wooden_shield": {
|
||||||
|
"name": "Wooden Shield",
|
||||||
|
"width": 2,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/wooden_shield.png",
|
||||||
|
"color": [120, 80, 40],
|
||||||
|
"hands_required": 1,
|
||||||
|
"shield_block_bonus": 4.0,
|
||||||
|
"blockable_projectile_types": ["arrow"]
|
||||||
|
},
|
||||||
|
"heavy_shield": {
|
||||||
|
"name": "Heavy Shield",
|
||||||
|
"width": 2,
|
||||||
|
"height": 3,
|
||||||
|
"texture": "items/heavy_shield.png",
|
||||||
|
"color": [90, 90, 100],
|
||||||
|
"hands_required": 1,
|
||||||
|
"shield_block_bonus": 6.0,
|
||||||
|
"blockable_projectile_types": ["heavy_caliber"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"ammo_heavy_caliber": {
|
||||||
|
"name": "Heavy Caliber Rounds",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/ammo_heavy_caliber.png",
|
||||||
|
"color": [80, 70, 40],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 20,
|
||||||
|
"ammo_type": "heavy_caliber",
|
||||||
|
"ranged_knockback": 3.0
|
||||||
|
},
|
||||||
|
"ammo_shotgun_shell": {
|
||||||
|
"name": "Shotgun Shells",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/ammo_shotgun_shell.png",
|
||||||
|
"color": [180, 40, 40],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 20,
|
||||||
|
"ammo_type": "shotgun_shell",
|
||||||
|
"ranged_knockback": 4.0
|
||||||
|
},
|
||||||
|
"ammo_shotgun_slug": {
|
||||||
|
"name": "Shotgun Slugs",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/ammo_shotgun_slug.png",
|
||||||
|
"color": [140, 30, 30],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 20,
|
||||||
|
"ammo_type": "shotgun_slug",
|
||||||
|
"ranged_knockback": 4.0
|
||||||
|
},
|
||||||
|
"ammo_rocket": {
|
||||||
|
"name": "Rockets",
|
||||||
|
"width": 1,
|
||||||
|
"height": 2,
|
||||||
|
"texture": "items/ammo_rocket.png",
|
||||||
|
"color": [60, 90, 60],
|
||||||
|
"stackable": true,
|
||||||
|
"max_stack": 5,
|
||||||
|
"ammo_type": "rocket",
|
||||||
|
"ranged_knockback": 5.0
|
||||||
|
},
|
||||||
|
|
||||||
|
"scope_basic": {
|
||||||
|
"name": "Basic Scope",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/scope_basic.png",
|
||||||
|
"color": [40, 40, 40],
|
||||||
|
"attachment_kind": "scope",
|
||||||
|
"aimcone_reduction_degrees": 3.0
|
||||||
|
},
|
||||||
|
"stock_basic": {
|
||||||
|
"name": "Basic Stock",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/stock_basic.png",
|
||||||
|
"color": [90, 60, 40],
|
||||||
|
"attachment_kind": "stock",
|
||||||
|
"aimcone_reduction_degrees": 2.0
|
||||||
|
},
|
||||||
|
"extended_mag": {
|
||||||
|
"name": "Extended Magazine",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/extended_mag.png",
|
||||||
|
"color": [60, 60, 65],
|
||||||
|
"attachment_kind": "magazine",
|
||||||
|
"magazine_capacity_bonus": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"rifle_heavy": {
|
||||||
|
"name": "Heavy Rifle",
|
||||||
|
"width": 1,
|
||||||
|
"height": 4,
|
||||||
|
"texture": "items/rifle_heavy.png",
|
||||||
|
"color": [50, 50, 55],
|
||||||
|
"weapon_skill": "sleight_of_hand",
|
||||||
|
"weapon_damage": 14.0,
|
||||||
|
"weapon_range": 8,
|
||||||
|
"hands_required": 2,
|
||||||
|
"compatible_ammo_types": ["heavy_caliber"],
|
||||||
|
"magazine_size": 5,
|
||||||
|
"aim_cone_degrees": 6.0
|
||||||
|
},
|
||||||
|
"shotgun_basic": {
|
||||||
|
"name": "Basic Shotgun",
|
||||||
|
"width": 1,
|
||||||
|
"height": 4,
|
||||||
|
"texture": "items/shotgun_basic.png",
|
||||||
|
"color": [80, 55, 35],
|
||||||
|
"weapon_skill": "sleight_of_hand",
|
||||||
|
"weapon_damage": 4.0,
|
||||||
|
"weapon_range": 5,
|
||||||
|
"hands_required": 2,
|
||||||
|
"compatible_ammo_types": ["shotgun_shell", "shotgun_slug"],
|
||||||
|
"magazine_size": 2,
|
||||||
|
"aim_cone_degrees": 45.0,
|
||||||
|
"pellet_count": 6
|
||||||
|
},
|
||||||
|
"rocket_launcher": {
|
||||||
|
"name": "Rocket Launcher",
|
||||||
|
"width": 2,
|
||||||
|
"height": 4,
|
||||||
|
"texture": "items/rocket_launcher.png",
|
||||||
|
"color": [50, 65, 50],
|
||||||
|
"weapon_skill": "sleight_of_hand",
|
||||||
|
"weapon_damage": 25.0,
|
||||||
|
"weapon_range": 8,
|
||||||
|
"hands_required": 2,
|
||||||
|
"compatible_ammo_types": ["rocket"],
|
||||||
|
"magazine_size": 1,
|
||||||
|
"aim_cone_degrees": 5.0,
|
||||||
|
"blast_radius": 2
|
||||||
|
},
|
||||||
|
"chronowand": {
|
||||||
|
"name": "Chronowand",
|
||||||
|
"width": 1,
|
||||||
|
"height": 3,
|
||||||
|
"texture": "items/chronowand.png",
|
||||||
|
"color": [120, 90, 200],
|
||||||
|
"hands_required": 1,
|
||||||
|
"grants_ability": "time_warp_sphere"
|
||||||
|
},
|
||||||
|
"chronoimplant": {
|
||||||
|
"name": "Chronoimplant",
|
||||||
|
"width": 1,
|
||||||
|
"height": 1,
|
||||||
|
"texture": "items/chronoimplant.png",
|
||||||
|
"color": [150, 110, 220],
|
||||||
|
"is_implant": true,
|
||||||
|
"grants_ability": "time_warp_sphere"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"map_id": "demo_world",
|
||||||
|
"width": 10,
|
||||||
|
"height": 10,
|
||||||
|
"depth": 1,
|
||||||
|
"default_tile": { "subfloor": "dirt", "flooring": "grass" },
|
||||||
|
"tiles": [],
|
||||||
|
"embeddings": [
|
||||||
|
{ "map_file": "ship_small.json", "anchor": [6, 6, 0] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"map_id": "harbor_world",
|
||||||
|
"width": 14,
|
||||||
|
"height": 14,
|
||||||
|
"depth": 1,
|
||||||
|
"default_tile": { "subfloor": "dirt", "flooring": "grass" },
|
||||||
|
"tiles": [],
|
||||||
|
"embeddings": [
|
||||||
|
{ "map_file": "ship_test.json", "anchor": [4, 4, 0], "rotation": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"map_id": "ship_small",
|
||||||
|
"width": 4,
|
||||||
|
"height": 3,
|
||||||
|
"depth": 1,
|
||||||
|
"default_tile": { "subfloor": "dirt", "flooring": "wood_deck" },
|
||||||
|
"tiles": [
|
||||||
|
{ "x": 0, "y": 0, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 1, "y": 0, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 3, "y": 0, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 0, "y": 1, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 3, "y": 1, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 0, "y": 2, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 1, "y": 2, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 2, "y": 2, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 3, "y": 2, "z": 0, "room": "wood_wall" },
|
||||||
|
{ "x": 2, "y": 1, "z": 0, "room": "ship_helm" }
|
||||||
|
],
|
||||||
|
"embeddings": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
{
|
||||||
|
"map_id": "ship_test",
|
||||||
|
"width": 5,
|
||||||
|
"height": 5,
|
||||||
|
"depth": 2,
|
||||||
|
"default_tile": {
|
||||||
|
"subfloor": "dirt",
|
||||||
|
"flooring": "wood_deck"
|
||||||
|
},
|
||||||
|
"tiles": [
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 4,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 4,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 4,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 4,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 1,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 1,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 2,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 2,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 3,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 3,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 1,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 2,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 3,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 1,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 2,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 3,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 1,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 2,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 3,
|
||||||
|
"z": 0,
|
||||||
|
"roof": "shingles",
|
||||||
|
"room": "stairs_up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 4,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 0,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 4,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 0,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 4,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 0,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 4,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 0,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 4,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 1,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 1,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 2,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 2,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0,
|
||||||
|
"y": 3,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 3,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1,
|
||||||
|
"y": 1,
|
||||||
|
"z": 1,
|
||||||
|
"room": "ship_helm",
|
||||||
|
"roof": "shingles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2,
|
||||||
|
"y": 1,
|
||||||
|
"z": 1,
|
||||||
|
"room": "wood_wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3,
|
||||||
|
"y": 3,
|
||||||
|
"z": 1,
|
||||||
|
"room": "stairs_down"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"embeddings": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"map_id": "storm_field",
|
||||||
|
"width": 10,
|
||||||
|
"height": 10,
|
||||||
|
"depth": 1,
|
||||||
|
"default_tile": { "subfloor": "dirt", "flooring": "grass" },
|
||||||
|
"tiles": [
|
||||||
|
{ "x": 7, "y": 7, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 8, "y": 7, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 9, "y": 7, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 7, "y": 8, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 8, "y": 8, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 9, "y": 8, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 7, "y": 9, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 8, "y": 9, "z": 0, "roof": "shingles" },
|
||||||
|
{ "x": 9, "y": 9, "z": 0, "roof": "shingles" }
|
||||||
|
],
|
||||||
|
"embeddings": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"human_heart": {
|
||||||
|
"name": "Heart",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
||||||
|
"affects_organs": { "human_kidneys": 0.05 }
|
||||||
|
},
|
||||||
|
"human_lungs": {
|
||||||
|
"name": "Lungs",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
"human_liver": {
|
||||||
|
"name": "Liver",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.7, "poison_resistance": 0.7 }
|
||||||
|
},
|
||||||
|
"human_kidneys": {
|
||||||
|
"name": "Kidneys",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.3, "poison_resistance": 0.3 }
|
||||||
|
},
|
||||||
|
"human_brain": {
|
||||||
|
"name": "Brain",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"reptilian_heart": {
|
||||||
|
"name": "Heart",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
||||||
|
"affects_organs": { "reptilian_venom_gland": 0.05 }
|
||||||
|
},
|
||||||
|
"reptilian_lung": {
|
||||||
|
"name": "Lung",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
"reptilian_liver": {
|
||||||
|
"name": "Liver",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.5, "poison_resistance": 0.4 }
|
||||||
|
},
|
||||||
|
"reptilian_venom_gland": {
|
||||||
|
"name": "Venom Gland",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "poison_resistance": 0.3, "intimidation": 0.2 }
|
||||||
|
},
|
||||||
|
"reptilian_brain": {
|
||||||
|
"name": "Brain",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"zenari_cardial_node": {
|
||||||
|
"name": "Cardial Node",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
||||||
|
"affects_organs": { "zenari_filtration_organ": 0.05 }
|
||||||
|
},
|
||||||
|
"zenari_respirator": {
|
||||||
|
"name": "Respirator",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
"zenari_metabolizer": {
|
||||||
|
"name": "Metabolizer",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.7, "poison_resistance": 0.7 }
|
||||||
|
},
|
||||||
|
"zenari_filtration_organ": {
|
||||||
|
"name": "Filtration Organ",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.3, "poison_resistance": 0.3 }
|
||||||
|
},
|
||||||
|
"zenari_cortex": {
|
||||||
|
"name": "Cortex",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"bot_cpu": {
|
||||||
|
"name": "CPU",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
|
},
|
||||||
|
"bot_npu": {
|
||||||
|
"name": "NPU",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "sleight_of_hand": 0.4, "perception": 0.2 }
|
||||||
|
},
|
||||||
|
"bot_power_cell": {
|
||||||
|
"name": "Power Cell",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
||||||
|
"affects_organs": { "bot_cpu": 0.05, "bot_npu": 0.05, "bot_hydraulic_fluid_bladder": 0.03 }
|
||||||
|
},
|
||||||
|
"bot_hydraulic_fluid_bladder": {
|
||||||
|
"name": "Hydraulic Fluid Bladder",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
"bot_cooling_fan": {
|
||||||
|
"name": "Cooling Fan",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"dog_heart": {
|
||||||
|
"name": "Heart",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
||||||
|
"affects_organs": { "dog_kidneys": 0.05 }
|
||||||
|
},
|
||||||
|
"dog_lungs": {
|
||||||
|
"name": "Lungs",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
||||||
|
},
|
||||||
|
"dog_liver": {
|
||||||
|
"name": "Liver",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.7, "poison_resistance": 0.7 }
|
||||||
|
},
|
||||||
|
"dog_kidneys": {
|
||||||
|
"name": "Kidneys",
|
||||||
|
"host_slot": "torso",
|
||||||
|
"check_weights": { "constitution": 0.3, "poison_resistance": 0.3 }
|
||||||
|
},
|
||||||
|
"dog_brain": {
|
||||||
|
"name": "Brain",
|
||||||
|
"host_slot": "head",
|
||||||
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
{
|
||||||
|
"human": {
|
||||||
|
"name": "Human",
|
||||||
|
"genders": ["male", "female", "nonbinary"],
|
||||||
|
"default_body_parts": [
|
||||||
|
"human_head",
|
||||||
|
"human_torso",
|
||||||
|
"human_left_arm",
|
||||||
|
"human_right_arm",
|
||||||
|
"human_left_leg",
|
||||||
|
"human_right_leg"
|
||||||
|
],
|
||||||
|
"default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"],
|
||||||
|
"body_type": "biped",
|
||||||
|
"abilities": [
|
||||||
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "time_warp_sphere", "name": "Time Warp Sphere", "required_slots": ["head"] }
|
||||||
|
],
|
||||||
|
"blood_danger_threshold": 50.0,
|
||||||
|
"blood_critical_threshold": 25.0,
|
||||||
|
"health_item_compatibility": []
|
||||||
|
},
|
||||||
|
"reptilian_quad": {
|
||||||
|
"name": "Reptilian",
|
||||||
|
"genders": ["none"],
|
||||||
|
"default_body_parts": [
|
||||||
|
"reptilian_head",
|
||||||
|
"reptilian_torso",
|
||||||
|
"reptilian_left_arm",
|
||||||
|
"reptilian_right_arm",
|
||||||
|
"reptilian_left_arm_2",
|
||||||
|
"reptilian_right_arm_2",
|
||||||
|
"reptilian_left_leg",
|
||||||
|
"reptilian_right_leg"
|
||||||
|
],
|
||||||
|
"default_organs": [
|
||||||
|
"reptilian_heart",
|
||||||
|
"reptilian_lung",
|
||||||
|
"reptilian_liver",
|
||||||
|
"reptilian_venom_gland",
|
||||||
|
"reptilian_brain"
|
||||||
|
],
|
||||||
|
"body_type": "biped",
|
||||||
|
"abilities": [
|
||||||
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] }
|
||||||
|
],
|
||||||
|
"blood_danger_threshold": 45.0,
|
||||||
|
"blood_critical_threshold": 20.0,
|
||||||
|
"health_item_compatibility": []
|
||||||
|
},
|
||||||
|
"alien_tri": {
|
||||||
|
"name": "Zenari",
|
||||||
|
"genders": ["alpha", "beta", "gamma"],
|
||||||
|
"default_body_parts": [
|
||||||
|
"alien_head",
|
||||||
|
"alien_torso",
|
||||||
|
"alien_left_arm",
|
||||||
|
"alien_right_arm",
|
||||||
|
"alien_left_leg",
|
||||||
|
"alien_right_leg"
|
||||||
|
],
|
||||||
|
"default_organs": [
|
||||||
|
"zenari_cardial_node",
|
||||||
|
"zenari_respirator",
|
||||||
|
"zenari_metabolizer",
|
||||||
|
"zenari_filtration_organ",
|
||||||
|
"zenari_cortex"
|
||||||
|
],
|
||||||
|
"body_type": "biped",
|
||||||
|
"abilities": [
|
||||||
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "time_warp_sphere", "name": "Time Warp Sphere", "required_slots": ["head"] }
|
||||||
|
],
|
||||||
|
"blood_danger_threshold": 55.0,
|
||||||
|
"blood_critical_threshold": 30.0,
|
||||||
|
"health_item_compatibility": []
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"name": "Bot",
|
||||||
|
"genders": ["none"],
|
||||||
|
"default_body_parts": [
|
||||||
|
"bot_head",
|
||||||
|
"bot_torso",
|
||||||
|
"bot_left_arm",
|
||||||
|
"bot_right_arm",
|
||||||
|
"bot_left_leg",
|
||||||
|
"bot_right_leg"
|
||||||
|
],
|
||||||
|
"default_organs": [
|
||||||
|
"bot_cpu",
|
||||||
|
"bot_npu",
|
||||||
|
"bot_power_cell",
|
||||||
|
"bot_hydraulic_fluid_bladder",
|
||||||
|
"bot_cooling_fan"
|
||||||
|
],
|
||||||
|
"body_type": "biped",
|
||||||
|
"abilities": [
|
||||||
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
{ "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] }
|
||||||
|
],
|
||||||
|
"blood_danger_threshold": 0.0,
|
||||||
|
"blood_critical_threshold": 0.0,
|
||||||
|
"health_item_compatibility": []
|
||||||
|
},
|
||||||
|
"dog": {
|
||||||
|
"name": "Dog",
|
||||||
|
"genders": ["male", "female"],
|
||||||
|
"default_body_parts": [
|
||||||
|
"dog_head",
|
||||||
|
"dog_torso",
|
||||||
|
"dog_front_left_leg",
|
||||||
|
"dog_front_right_leg",
|
||||||
|
"dog_back_left_leg",
|
||||||
|
"dog_back_right_leg"
|
||||||
|
],
|
||||||
|
"default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain"],
|
||||||
|
"body_type": "quadruped",
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"id": "leap",
|
||||||
|
"name": "Leap",
|
||||||
|
"required_slots": ["front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"]
|
||||||
|
},
|
||||||
|
{ "id": "melee_attack", "name": "Bite", "required_slots": ["head"] }
|
||||||
|
],
|
||||||
|
"blood_danger_threshold": 50.0,
|
||||||
|
"blood_critical_threshold": 25.0,
|
||||||
|
"health_item_compatibility": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"subfloor": {
|
||||||
|
"dirt": { "color": [92, 64, 51], "walkable": true }
|
||||||
|
},
|
||||||
|
"flooring": {
|
||||||
|
"grass": { "color": [86, 156, 70], "walkable": true },
|
||||||
|
"wood_deck": { "color": [166, 124, 82], "walkable": true }
|
||||||
|
},
|
||||||
|
"room": {
|
||||||
|
"wood_wall": { "color": [110, 78, 48], "walkable": false, "blocks_los": true, "deconstruct_item_id": "wood_planks" },
|
||||||
|
"stone_wall": { "color": [120, 120, 120], "walkable": false, "blocks_los": true },
|
||||||
|
"ship_helm": { "color": [200, 170, 60], "walkable": true, "is_helm": true },
|
||||||
|
"stairs_up": { "color": [160, 140, 100], "walkable": true, "staircase_direction": "up" },
|
||||||
|
"stairs_down": { "color": [100, 90, 80], "walkable": true, "staircase_direction": "down" },
|
||||||
|
"ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" }
|
||||||
|
},
|
||||||
|
"roof": {
|
||||||
|
"shingles": { "color": [140, 60, 50], "walkable": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.world import TimeWarpZone, World
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.ui import AbilityBarSlot
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.abilities import cast_time_warp_sphere
|
||||||
|
from resources.logic.combat import DiceRoller, perform_attack, perform_shoot
|
||||||
|
from resources.logic.construction import interact_with_tile
|
||||||
|
from resources.logic.knockback import resolve_melee_knockback
|
||||||
|
from resources.logic.ranged_combat import fire_held_weapon
|
||||||
|
|
||||||
|
ABILITY_CASTERS = {
|
||||||
|
"time_warp_sphere": cast_time_warp_sphere,
|
||||||
|
}
|
||||||
|
|
||||||
|
ABILITY_COOLDOWNS = {
|
||||||
|
"time_warp_sphere": 8.0, # seconds; longer than the zone's own default 5s duration
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cast_ability(character: Character, world: World, ability_id: str, now: float):
|
||||||
|
"""Shared by the item-in-hand and implant ability paths: casts `ability_id` if it's off
|
||||||
|
cooldown, then starts its cooldown (see ABILITY_COOLDOWNS) - a no-op returning None while
|
||||||
|
still on cooldown, or if no caster is registered for it.
|
||||||
|
"""
|
||||||
|
caster = ABILITY_CASTERS.get(ability_id)
|
||||||
|
if caster is None or not character.is_ability_ready(ability_id, now):
|
||||||
|
return None
|
||||||
|
result = caster(character, world, now)
|
||||||
|
character.start_ability_cooldown(ability_id, now, ABILITY_COOLDOWNS.get(ability_id, 0.0))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _first_buildable_layer(character: Character, registry: DefRegistry) -> str | None:
|
||||||
|
if character.inventory is None:
|
||||||
|
return None
|
||||||
|
for placed in character.inventory.items():
|
||||||
|
material_def = registry.get_item_def(placed.item.def_id)
|
||||||
|
if material_def.builds_tile_layer is not None:
|
||||||
|
return material_def.builds_tile_layer
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def activate_hand(
|
||||||
|
character: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
hand_slot: str,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
rng: DiceRoller | None = None,
|
||||||
|
now: float = 0.0,
|
||||||
|
):
|
||||||
|
"""Resolves whatever's held in `hand_slot` against `aim_direction`: a tool interacts with
|
||||||
|
the tile immediately ahead, an ability-granting item casts its ability (`now` is the game
|
||||||
|
clock time, needed for timed effects like time_warp_sphere), an ammo-fed ranged weapon
|
||||||
|
fires (see fire_held_weapon), a simple non-ammo ranged weapon shoots directly, and melee
|
||||||
|
(weapon or bare hand) attacks whoever's standing there - with knockback on a successful
|
||||||
|
hit. Returns whatever the underlying action returned, or None if the hand is empty or
|
||||||
|
nothing applicable is in range.
|
||||||
|
"""
|
||||||
|
assert character.position is not None
|
||||||
|
held = character.get_held_item(hand_slot)
|
||||||
|
dx, dy, dz = aim_direction
|
||||||
|
ahead = (character.position.x + dx, character.position.y + dy, character.position.z + dz)
|
||||||
|
|
||||||
|
if held is None:
|
||||||
|
target = world.entity_at(character.position.map_id, *ahead)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
result = perform_attack(character, target, registry, weapon_def=None, rng=rng)
|
||||||
|
resolve_melee_knockback(world, character, target, None, result.hit, registry)
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
item_def = registry.get_item_def(held.item_def_id)
|
||||||
|
|
||||||
|
if item_def.grants_ability is not None:
|
||||||
|
return _cast_ability(character, world, item_def.grants_ability, now)
|
||||||
|
|
||||||
|
if item_def.tool_kind is not None:
|
||||||
|
map_ = world.maps[character.position.map_id]
|
||||||
|
if not map_.in_bounds(*ahead):
|
||||||
|
return None
|
||||||
|
button = "left" if hand_slot in ("right_hand", "right_hand_2") else "right"
|
||||||
|
|
||||||
|
if item_def.tool_kind == "constructor":
|
||||||
|
layer = _first_buildable_layer(character, registry)
|
||||||
|
else:
|
||||||
|
tile = map_.get_tile(*ahead)
|
||||||
|
layer = next((layer for layer in ("room", "flooring", "subfloor", "roof") if tile.get_layer(layer)), None)
|
||||||
|
if layer is None:
|
||||||
|
return None
|
||||||
|
return interact_with_tile(character, world, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, button)
|
||||||
|
|
||||||
|
if item_def.magazine_size > 0:
|
||||||
|
return fire_held_weapon(character, world, registry, hand_slot, aim_direction, rng=rng)
|
||||||
|
|
||||||
|
if item_def.weapon_range > 1:
|
||||||
|
return perform_shoot(character, world, registry, item_def, aim_direction, rng=rng)
|
||||||
|
|
||||||
|
target = world.entity_at(character.position.map_id, *ahead)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
result = perform_attack(character, target, registry, weapon_def=item_def, rng=rng)
|
||||||
|
resolve_melee_knockback(world, character, target, item_def, result.hit, registry)
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def activate_implant(
|
||||||
|
character: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
implant_item_def_id: str,
|
||||||
|
now: float = 0.0,
|
||||||
|
):
|
||||||
|
"""Casts the ability granted by an installed slotless implant (e.g. a chronoimplant
|
||||||
|
granting time_warp_sphere) - mirrors the item-in-hand ability path in activate_hand,
|
||||||
|
but implants have no hand slot and are checked against character.implants instead.
|
||||||
|
"""
|
||||||
|
if implant_item_def_id not in character.implants:
|
||||||
|
return None
|
||||||
|
item_def = registry.get_item_def(implant_item_def_id)
|
||||||
|
if item_def.grants_ability is None:
|
||||||
|
return None
|
||||||
|
return _cast_ability(character, world, item_def.grants_ability, now)
|
||||||
|
|
||||||
|
|
||||||
|
def activate_ability_bar_slot(
|
||||||
|
character: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
slot: AbilityBarSlot,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
rng: DiceRoller | None = None,
|
||||||
|
now: float = 0.0,
|
||||||
|
):
|
||||||
|
"""Triggers whatever an ability-bar slot points at, whether that's a wielded hand
|
||||||
|
(dispatches through activate_hand, so a bar slot pointed at a hand behaves identically to
|
||||||
|
clicking that hand directly) or an installed implant (dispatches through activate_implant).
|
||||||
|
"""
|
||||||
|
if slot.kind == "hand":
|
||||||
|
return activate_hand(character, world, registry, slot.source_id, aim_direction, rng=rng, now=now)
|
||||||
|
if slot.kind == "implant":
|
||||||
|
return activate_implant(character, world, registry, slot.source_id, now=now)
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, Sequence, TypeVar
|
||||||
|
|
||||||
|
from engine.aim import apply_cone_deviation
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.item import ItemDef
|
||||||
|
from engine.map import rotate_vector
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
SKILL_CHECK_DIE = 20
|
||||||
|
BASE_DIFFICULTY = 10
|
||||||
|
UNARMED_SKILL = "athletics"
|
||||||
|
BLOCK_SKILL = "athletics"
|
||||||
|
|
||||||
|
# Ordered easiest/biggest -> hardest/smallest target. A precise (high-rolling) attacker reaches
|
||||||
|
# further down this list; a scattershot hit lands on whatever's first (torso - biggest target).
|
||||||
|
BODY_PART_DIFFICULTY_ORDER = ("torso", "left_arm", "right_arm", "left_leg", "right_leg", "head")
|
||||||
|
PRECISION_PER_SLOT = 4 # attacker_check points needed to reach one slot further down the list
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
class DiceRoller(Protocol):
|
||||||
|
"""What combat needs from a random source - satisfied by random.Random and fakes alike."""
|
||||||
|
|
||||||
|
def randint(self, a: int, b: int) -> int: ...
|
||||||
|
def choice(self, seq: Sequence[_T]) -> _T: ...
|
||||||
|
def uniform(self, a: float, b: float) -> float: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AttackResult:
|
||||||
|
hit: bool
|
||||||
|
roll: int
|
||||||
|
effective_skill: float
|
||||||
|
weapon_bonus: float
|
||||||
|
damage: float
|
||||||
|
skill_id: str
|
||||||
|
target_slot: str | None
|
||||||
|
blocked_check: bool # True if this was resolved as an opposed check against a blocking defender
|
||||||
|
armor_reduction: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShotResult:
|
||||||
|
"""Outcome of a ranged shot: either it lands as an AttackResult, is blocked, or misses entirely."""
|
||||||
|
|
||||||
|
hit: bool
|
||||||
|
blocked: bool
|
||||||
|
ricocheted: bool
|
||||||
|
attack: AttackResult | None
|
||||||
|
impact: tuple[int, int, int] | None
|
||||||
|
target_entity_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_body_part_damage(character: Character, slot: str, amount: float) -> BodyPart | None:
|
||||||
|
"""Damages a body part in place, creating an override from the default if none exists yet.
|
||||||
|
|
||||||
|
Integrity hitting zero marks the part removed (severed) - the same 'removed' flag the
|
||||||
|
bleeding tick and skill_penalty already understand, so combat plugs directly into both.
|
||||||
|
"""
|
||||||
|
override = character.get_or_create_body_part_override(slot)
|
||||||
|
if override is None:
|
||||||
|
return None
|
||||||
|
override.integrity = max(0.0, override.integrity - amount)
|
||||||
|
if override.integrity <= 0.0:
|
||||||
|
override.removed = True
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
def roll_skill_check(
|
||||||
|
character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller
|
||||||
|
) -> tuple[int, float]:
|
||||||
|
"""1d20 + the character's skill level, reduced by damage to relevant body parts and organs."""
|
||||||
|
base_skill = character.bio.get(skill_id, 0)
|
||||||
|
effective_skill = base_skill * (1.0 - character.total_check_penalty(skill_id, registry))
|
||||||
|
roll = rng.randint(1, SKILL_CHECK_DIE)
|
||||||
|
return roll, effective_skill
|
||||||
|
|
||||||
|
|
||||||
|
def choose_hit_slot(defender: Character, precision_check: float) -> str | None:
|
||||||
|
"""Which body part gets hit is itself driven by a skill check: `precision_check` (the
|
||||||
|
attacker's roll + effective skill from the main hit) selects how far down the
|
||||||
|
easiest-to-hardest target list the attacker was precise enough to reach.
|
||||||
|
"""
|
||||||
|
candidates = [slot for slot in BODY_PART_DIFFICULTY_ORDER if defender.get_body_part(slot) is not None]
|
||||||
|
extra_slots = [p.slot for p in defender.default_body_parts if p.slot not in BODY_PART_DIFFICULTY_ORDER]
|
||||||
|
candidates += [slot for slot in extra_slots if defender.get_body_part(slot) is not None]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
index = min(len(candidates) - 1, max(0, int(precision_check // PRECISION_PER_SLOT)))
|
||||||
|
return candidates[index]
|
||||||
|
|
||||||
|
|
||||||
|
def defender_armor(defender: Character, registry: DefRegistry) -> float:
|
||||||
|
"""Flat damage reduction summed across all currently-worn clothing."""
|
||||||
|
total = 0.0
|
||||||
|
for piece in defender.resolved_clothing().values():
|
||||||
|
if piece is None:
|
||||||
|
continue
|
||||||
|
total += registry.get_item_def(piece.item_def_id).armor_reduction
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hit(
|
||||||
|
attacker: Character,
|
||||||
|
defender: Character,
|
||||||
|
registry: DefRegistry,
|
||||||
|
weapon_def: ItemDef | None,
|
||||||
|
target_slot: str | None,
|
||||||
|
rng: DiceRoller,
|
||||||
|
) -> AttackResult:
|
||||||
|
skill_id = (weapon_def.weapon_skill if weapon_def else None) or UNARMED_SKILL
|
||||||
|
roll, effective_skill = roll_skill_check(attacker, skill_id, registry, rng)
|
||||||
|
weapon_bonus = weapon_def.weapon_damage if weapon_def else 0.0
|
||||||
|
attacker_check = roll + effective_skill
|
||||||
|
|
||||||
|
blocked_check = defender.is_blocking
|
||||||
|
if blocked_check:
|
||||||
|
_block_roll, block_effective = roll_skill_check(defender, BLOCK_SKILL, registry, rng)
|
||||||
|
shield = defender.wielded_shield(registry)
|
||||||
|
block_bonus = shield.shield_block_bonus if shield else 0.0
|
||||||
|
difficulty = _block_roll + block_effective + block_bonus
|
||||||
|
else:
|
||||||
|
difficulty = BASE_DIFFICULTY
|
||||||
|
|
||||||
|
margin = attacker_check - difficulty
|
||||||
|
hit = margin > 0
|
||||||
|
armor = defender_armor(registry=registry, defender=defender)
|
||||||
|
damage = max(0.0, margin + weapon_bonus - armor) if hit else 0.0
|
||||||
|
|
||||||
|
hit_slot = None
|
||||||
|
if hit:
|
||||||
|
hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check)
|
||||||
|
if hit_slot is not None:
|
||||||
|
apply_body_part_damage(defender, hit_slot, damage)
|
||||||
|
|
||||||
|
return AttackResult(
|
||||||
|
hit=hit,
|
||||||
|
roll=roll,
|
||||||
|
effective_skill=effective_skill,
|
||||||
|
weapon_bonus=weapon_bonus,
|
||||||
|
damage=damage,
|
||||||
|
skill_id=skill_id,
|
||||||
|
target_slot=hit_slot,
|
||||||
|
blocked_check=blocked_check,
|
||||||
|
armor_reduction=armor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_attack(
|
||||||
|
attacker: Character,
|
||||||
|
defender: Character,
|
||||||
|
registry: DefRegistry,
|
||||||
|
weapon_def: ItemDef | None = None,
|
||||||
|
target_slot: str | None = None,
|
||||||
|
rng: DiceRoller | None = None,
|
||||||
|
) -> AttackResult:
|
||||||
|
"""A live melee attack: skill check (weapon's governing skill, or unarmed) + weapon bonus,
|
||||||
|
minus the defender's armor, vs either a flat difficulty or - if the defender is blocking -
|
||||||
|
an opposed check (boosted by a wielded shield). On a hit, damage lands on a body part
|
||||||
|
(random if target_slot is unset).
|
||||||
|
"""
|
||||||
|
return resolve_hit(attacker, defender, registry, weapon_def, target_slot, rng or random.Random())
|
||||||
|
|
||||||
|
|
||||||
|
def find_ranged_target(
|
||||||
|
world: World, map_id: str, x: int, y: int, z: int, dx: int, dy: int, dz: int, max_range: int
|
||||||
|
) -> tuple[Character | None, tuple[int, int, int], int]:
|
||||||
|
"""Walks a straight line up to max_range tiles; stops at the first entity, wall, or map edge.
|
||||||
|
|
||||||
|
Returns (target_or_None, impact_position, steps_traveled).
|
||||||
|
"""
|
||||||
|
map_ = world.maps[map_id]
|
||||||
|
cx, cy, cz = x, y, z
|
||||||
|
steps = 0
|
||||||
|
for step in range(1, max_range + 1):
|
||||||
|
nx, ny, nz = cx + dx, cy + dy, cz + dz
|
||||||
|
if not map_.in_bounds(nx, ny, nz):
|
||||||
|
return None, (cx, cy, cz), steps
|
||||||
|
cx, cy, cz = nx, ny, nz
|
||||||
|
steps = step
|
||||||
|
target = world.entity_at(map_id, cx, cy, cz)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
return target, (cx, cy, cz), steps
|
||||||
|
if not map_.get_tile(cx, cy, cz).is_walkable(world.registry):
|
||||||
|
return None, (cx, cy, cz), steps
|
||||||
|
return None, (cx, cy, cz), steps
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_by_shield(target: Character, projectile_type: str | None, registry: DefRegistry) -> bool:
|
||||||
|
if not target.is_blocking:
|
||||||
|
return False
|
||||||
|
shield = target.wielded_shield(registry)
|
||||||
|
return shield is not None and projectile_type is not None and projectile_type in shield.blockable_projectile_types
|
||||||
|
|
||||||
|
|
||||||
|
def perform_shoot(
|
||||||
|
shooter: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
weapon_def: ItemDef,
|
||||||
|
direction: tuple[int, int, int],
|
||||||
|
rng: DiceRoller | None = None,
|
||||||
|
cone_degrees: float = 0.0,
|
||||||
|
projectile_type: str | None = None,
|
||||||
|
) -> ShotResult:
|
||||||
|
"""A live ranged shot along `direction` (unit vector) up to the weapon's range.
|
||||||
|
|
||||||
|
If `cone_degrees` > 0, the actual direction is randomized within that cone first (see
|
||||||
|
engine/aim.py) - callers determine the cone from the weapon's base spread minus skill and
|
||||||
|
attachment reductions (see resources/logic/ranged_combat.py's effective_aim_cone). If it
|
||||||
|
reaches a blocking defender whose wielded shield covers `projectile_type` (defaults to the
|
||||||
|
weapon's own projectile_type, e.g. for simple non-ammo weapons; ammo-based weapons should
|
||||||
|
pass the loaded ammo's ammo_type here instead), the shot is blocked and ricochets off at a
|
||||||
|
+/-90 degree deflection for whatever range is left; anything else just breaks through and
|
||||||
|
resolves as a normal hit. A ricocheted shot that gets blocked again simply stops (one bounce).
|
||||||
|
"""
|
||||||
|
assert shooter.position is not None
|
||||||
|
rng = rng or random.Random()
|
||||||
|
effective_projectile_type = projectile_type if projectile_type is not None else weapon_def.projectile_type
|
||||||
|
dx, dy, dz = direction
|
||||||
|
if cone_degrees > 0:
|
||||||
|
dx, dy = apply_cone_deviation(dx, dy, cone_degrees, rng)
|
||||||
|
|
||||||
|
target, impact, steps = find_ranged_target(
|
||||||
|
world, shooter.position.map_id, shooter.position.x, shooter.position.y, shooter.position.z,
|
||||||
|
dx, dy, dz, weapon_def.weapon_range,
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
return ShotResult(hit=False, blocked=False, ricocheted=False, attack=None, impact=impact, target_entity_id=None)
|
||||||
|
|
||||||
|
if not _is_blocked_by_shield(target, effective_projectile_type, registry):
|
||||||
|
attack = resolve_hit(shooter, target, registry, weapon_def, None, rng)
|
||||||
|
return ShotResult(
|
||||||
|
hit=attack.hit, blocked=False, ricocheted=False, attack=attack, impact=impact, target_entity_id=target.entity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining_range = weapon_def.weapon_range - steps
|
||||||
|
if remaining_range <= 0:
|
||||||
|
return ShotResult(
|
||||||
|
hit=False, blocked=True, ricocheted=True, attack=None, impact=impact, target_entity_id=target.entity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
bounce_dx, bounce_dy = rotate_vector(dx, dy, rng.choice([1, 3]))
|
||||||
|
bounced_target, bounced_impact, _steps = find_ranged_target(
|
||||||
|
world, target.position.map_id, impact[0], impact[1], impact[2], bounce_dx, bounce_dy, 0, remaining_range
|
||||||
|
)
|
||||||
|
if bounced_target is None:
|
||||||
|
return ShotResult(hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=None)
|
||||||
|
|
||||||
|
if _is_blocked_by_shield(bounced_target, effective_projectile_type, registry):
|
||||||
|
return ShotResult(
|
||||||
|
hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=bounced_target.entity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
attack = resolve_hit(shooter, bounced_target, registry, weapon_def, None, rng)
|
||||||
|
return ShotResult(
|
||||||
|
hit=attack.hit,
|
||||||
|
blocked=True,
|
||||||
|
ricocheted=True,
|
||||||
|
attack=attack,
|
||||||
|
impact=bounced_impact,
|
||||||
|
target_entity_id=bounced_target.entity_id,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
def open_companion_inventory(
|
||||||
|
actor: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
slot: str,
|
||||||
|
) -> Inventory | None:
|
||||||
|
"""Opens the equipped inventory (e.g. a saddlebag) worn by an adjacent companion NPC - a
|
||||||
|
companion dog wearing a dog_saddlebag being the sample case. Returns None if there's no
|
||||||
|
Character adjacent in that direction, the target is the actor themself, or nothing worn at
|
||||||
|
`slot` grants an inventory (get_equipped_inventory already covers that last case).
|
||||||
|
|
||||||
|
This is a targeted variant of Character.get_equipped_inventory - that method reads a
|
||||||
|
character's own gear, this one reads a companion's, gated on being next to them.
|
||||||
|
"""
|
||||||
|
assert actor.position is not None
|
||||||
|
dx, dy, dz = aim_direction
|
||||||
|
ahead = (actor.position.x + dx, actor.position.y + dy, actor.position.z + dz)
|
||||||
|
target = world.entity_at(actor.position.map_id, *ahead)
|
||||||
|
if not isinstance(target, Character) or target is actor:
|
||||||
|
return None
|
||||||
|
return target.get_equipped_inventory(slot, registry)
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemDef, ItemInstance
|
||||||
|
from engine.tile import TILE_LAYERS, Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
def present_layers(tile: Tile) -> list[str]:
|
||||||
|
"""Which of the tile's 4 layers are currently occupied, in stack order - the candidate
|
||||||
|
list for a 'pick a layer' popup when a tool targets a tile with more than one.
|
||||||
|
"""
|
||||||
|
return [layer for layer in TILE_LAYERS if tile.get_layer(layer) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def damage_tile_layer(tile: Tile, layer: str, amount: float) -> bool:
|
||||||
|
"""Reduces a layer's integrity; clears it once it hits zero. Returns whether it was destroyed."""
|
||||||
|
instance = tile.get_layer(layer)
|
||||||
|
if instance is None:
|
||||||
|
return False
|
||||||
|
instance.integrity = max(0.0, instance.integrity - amount)
|
||||||
|
if instance.integrity <= 0.0:
|
||||||
|
tile.set_layer(layer, None)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def destroy_tile_layer(tile: Tile, layer: str) -> bool:
|
||||||
|
"""The multi-deconstructor's left-click action: instantly clears a layer, no item gained."""
|
||||||
|
if tile.get_layer(layer) is None:
|
||||||
|
return False
|
||||||
|
tile.set_layer(layer, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def deconstruct_tile_layer(tile: Tile, layer: str, registry: DefRegistry) -> ItemInstance | None:
|
||||||
|
"""The multi-deconstructor's right-click action: clears a layer and yields its item, if any."""
|
||||||
|
instance = tile.get_layer(layer)
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
layer_def = registry.get_tile_layer_def(layer, instance.def_id)
|
||||||
|
tile.set_layer(layer, None)
|
||||||
|
if layer_def is None or layer_def.deconstruct_item_id is None:
|
||||||
|
return None
|
||||||
|
return ItemInstance(item_id=f"deconstructed-{layer}-{instance.def_id}", def_id=layer_def.deconstruct_item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_tile_layer(
|
||||||
|
tile: Tile,
|
||||||
|
layer: str,
|
||||||
|
item_def: ItemDef,
|
||||||
|
inventory: Inventory,
|
||||||
|
inventory_item_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""The constructor tool's action: consumes a matching material item to place a new layer.
|
||||||
|
|
||||||
|
Fails as a no-op if the item doesn't build this layer, or the layer is already occupied
|
||||||
|
(build on the bare slot first - deconstruct/destroy whatever's there, then construct).
|
||||||
|
"""
|
||||||
|
if item_def.builds_tile_layer != layer or item_def.builds_tile_layer_def_id is None:
|
||||||
|
return False
|
||||||
|
if tile.get_layer(layer) is not None:
|
||||||
|
return False
|
||||||
|
if inventory.remove(inventory_item_id, item_def) is None:
|
||||||
|
return False
|
||||||
|
tile.set_layer(layer, TileLayerInstance(item_def.builds_tile_layer_def_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def wielded_tool(character: Character, registry: DefRegistry) -> ItemDef | None:
|
||||||
|
for held in character.held_items:
|
||||||
|
item_def = registry.get_item_def(held.item_def_id)
|
||||||
|
if item_def.tool_kind is not None:
|
||||||
|
return item_def
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def interact_with_tile(
|
||||||
|
character: Character, world: World, map_id: str, x: int, y: int, z: int, layer: str, button: str
|
||||||
|
) -> ItemInstance | bool | None:
|
||||||
|
"""Dispatches a tool interaction against one layer of a tile, based on what's wielded.
|
||||||
|
|
||||||
|
A deconstructor: left-click destroys the layer outright, right-click deconstructs it and
|
||||||
|
(if there's room) drops the yielded item straight into the character's inventory. A
|
||||||
|
constructor: consumes the first matching material found in the character's inventory to
|
||||||
|
build that layer. Returns whatever the underlying action returned, or None if nothing
|
||||||
|
wieldable/applicable was found.
|
||||||
|
"""
|
||||||
|
tool = wielded_tool(character, registry := world.registry)
|
||||||
|
if tool is None:
|
||||||
|
return None
|
||||||
|
tile = world.maps[map_id].get_tile(x, y, z)
|
||||||
|
|
||||||
|
if tool.tool_kind == "deconstructor":
|
||||||
|
if button == "left":
|
||||||
|
return destroy_tile_layer(tile, layer)
|
||||||
|
if button == "right":
|
||||||
|
item = deconstruct_tile_layer(tile, layer, registry)
|
||||||
|
if item is not None and character.inventory is not None:
|
||||||
|
item_def = registry.get_item_def(item.def_id)
|
||||||
|
slot = character.inventory.find_first_fit(item_def)
|
||||||
|
if slot is not None:
|
||||||
|
character.inventory.place(item, item_def, *slot)
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tool.tool_kind == "constructor":
|
||||||
|
if character.inventory is None:
|
||||||
|
return False
|
||||||
|
for placed in character.inventory.items():
|
||||||
|
material_def = registry.get_item_def(placed.item.def_id)
|
||||||
|
if material_def.builds_tile_layer == layer:
|
||||||
|
return construct_tile_layer(tile, layer, material_def, character.inventory, placed.item.item_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def apply_bleeding_tick(character: Character, registry: DefRegistry, ticks: int) -> None:
|
||||||
|
"""Every body part that's been lost (removed=True) bleeds at its def's bleeding_speed per tick."""
|
||||||
|
if ticks <= 0:
|
||||||
|
return
|
||||||
|
total_loss = 0.0
|
||||||
|
for part in character.body_parts:
|
||||||
|
if not part.removed:
|
||||||
|
continue
|
||||||
|
part_def = registry.get_body_part_def(part.part_def_id)
|
||||||
|
total_loss += part_def.bleeding_speed * ticks
|
||||||
|
if total_loss:
|
||||||
|
character.blood_percentage = max(0.0, character.blood_percentage - total_loss)
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.item import ItemDef
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
UNARMED_MELEE_KNOCKBACK = 0.3 # tiles, at baseline strength
|
||||||
|
RECOIL_FACTOR = 0.3 # fraction of dealt knockback the shooter/attacker feels in return
|
||||||
|
STRENGTH_BASELINE = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(n: float) -> int:
|
||||||
|
return (n > 0) - (n < 0)
|
||||||
|
|
||||||
|
|
||||||
|
def direction_between(a: EntityPosition, b: EntityPosition) -> tuple[int, int, int]:
|
||||||
|
"""Unit-ish step direction from a toward b (each axis independently signed)."""
|
||||||
|
return (_sign(b.x - a.x), _sign(b.y - a.y), _sign(b.z - a.z))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_knockback(world: World, entity: Entity, direction: tuple[int, int, int], distance: int) -> int:
|
||||||
|
"""Pushes `entity` up to `distance` tiles via normal movement, stopping early at an obstacle.
|
||||||
|
|
||||||
|
Returns how many tiles it actually moved (may be less than `distance` if blocked).
|
||||||
|
"""
|
||||||
|
dx, dy, dz = direction
|
||||||
|
moved = 0
|
||||||
|
for _ in range(distance):
|
||||||
|
if not world.try_move(entity, dx, dy, dz):
|
||||||
|
break
|
||||||
|
moved += 1
|
||||||
|
return moved
|
||||||
|
|
||||||
|
|
||||||
|
def melee_knockback_distance(attacker: Character, weapon_def: ItemDef | None, registry) -> int:
|
||||||
|
"""Melee knockback scales with the attacker's effective strength (organ damage - e.g. a
|
||||||
|
weakened heart - reduces it), at baseline STRENGTH_BASELINE = 1x.
|
||||||
|
"""
|
||||||
|
base = weapon_def.melee_knockback if weapon_def is not None else UNARMED_MELEE_KNOCKBACK
|
||||||
|
return round(base * (attacker.effective_strength(registry) / STRENGTH_BASELINE))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recoil(
|
||||||
|
world: World, shooter: Character, forward_direction: tuple[int, int, int], knockback_dealt: int, is_rocket: bool
|
||||||
|
) -> int:
|
||||||
|
"""Pushes the attacker/shooter backward, proportional to the knockback they just dealt.
|
||||||
|
Skipped entirely for rocket launchers (their AoE knockback isn't a single directed impulse
|
||||||
|
the shooter would feel the same way).
|
||||||
|
"""
|
||||||
|
if is_rocket or knockback_dealt <= 0:
|
||||||
|
return 0
|
||||||
|
recoil_distance = round(knockback_dealt * RECOIL_FACTOR)
|
||||||
|
if recoil_distance <= 0:
|
||||||
|
return 0
|
||||||
|
backward = (-forward_direction[0], -forward_direction[1], -forward_direction[2])
|
||||||
|
return apply_knockback(world, shooter, backward, recoil_distance)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_melee_knockback(
|
||||||
|
world: World, attacker: Character, defender: Character, weapon_def: ItemDef | None, hit: bool, registry
|
||||||
|
) -> int:
|
||||||
|
"""Applies melee knockback (+ the attacker's recoil) on a successful hit only."""
|
||||||
|
if not hit:
|
||||||
|
return 0
|
||||||
|
assert attacker.position is not None and defender.position is not None
|
||||||
|
distance = melee_knockback_distance(attacker, weapon_def, registry)
|
||||||
|
if distance <= 0:
|
||||||
|
return 0
|
||||||
|
direction = direction_between(attacker.position, defender.position)
|
||||||
|
moved = apply_knockback(world, defender, direction, distance)
|
||||||
|
apply_recoil(world, attacker, direction, moved, is_rocket=False)
|
||||||
|
return moved
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ranged_knockback(
|
||||||
|
world: World,
|
||||||
|
shooter: Character,
|
||||||
|
target: Character,
|
||||||
|
ammo_def: ItemDef,
|
||||||
|
shot_direction: tuple[int, int, int],
|
||||||
|
is_rocket: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Applies ranged knockback from the ammo's own stat, regardless of hit/miss/blocked-check
|
||||||
|
outcome: the impact's kinetic force lands as long as the shot physically reached the target
|
||||||
|
(a shield hard-blocking/ricocheting it away is the one case this is never called for).
|
||||||
|
"""
|
||||||
|
distance = round(ammo_def.ranged_knockback)
|
||||||
|
if distance <= 0:
|
||||||
|
return 0
|
||||||
|
moved = apply_knockback(world, target, shot_direction, distance)
|
||||||
|
apply_recoil(world, shooter, shot_direction, moved, is_rocket=is_rocket)
|
||||||
|
return moved
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_explosion_knockback(world: World, ammo_def: ItemDef, center: tuple[int, int, int], hit_entities) -> None:
|
||||||
|
"""Radial knockback for each entity caught in a blast, pushed directly away from the
|
||||||
|
impact point rather than along the shot's original travel direction.
|
||||||
|
"""
|
||||||
|
distance = round(ammo_def.ranged_knockback)
|
||||||
|
if distance <= 0:
|
||||||
|
return
|
||||||
|
for entity, _damage in hit_entities:
|
||||||
|
assert entity.position is not None
|
||||||
|
direction = (_sign(entity.position.x - center[0]), _sign(entity.position.y - center[1]), 0)
|
||||||
|
if direction == (0, 0, 0):
|
||||||
|
continue
|
||||||
|
apply_knockback(world, entity, direction, distance)
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def apply_organ_interactions_tick(character: Character, registry: DefRegistry, ticks: int) -> None:
|
||||||
|
"""Damaged organs stress the organs they affect (OrganDef.affects_organs), e.g. a weakened
|
||||||
|
heart gradually strains the kidneys. Damage per tick is proportional to both the source
|
||||||
|
organ's own damage fraction and the elapsed ticks. Sources are snapshotted before any
|
||||||
|
damage is applied, so effects don't cascade within the same tick (a heart hit doesn't also
|
||||||
|
immediately hit whatever the newly-damaged kidneys affect, this call).
|
||||||
|
"""
|
||||||
|
if ticks <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
sources: list[tuple[str, float]] = []
|
||||||
|
for part in character.resolved_body_parts().values():
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
for organ in part.organs:
|
||||||
|
if organ.removed:
|
||||||
|
continue
|
||||||
|
organ_def = registry.get_organ_def(organ.organ_def_id)
|
||||||
|
if not organ_def.affects_organs:
|
||||||
|
continue
|
||||||
|
damage_fraction = 1.0 - (organ.integrity / 100.0)
|
||||||
|
if damage_fraction <= 0.0:
|
||||||
|
continue
|
||||||
|
sources.append((organ_def.id, damage_fraction))
|
||||||
|
|
||||||
|
for source_id, damage_fraction in sources:
|
||||||
|
source_def = registry.get_organ_def(source_id)
|
||||||
|
for target_organ_id, rate in source_def.affects_organs.items():
|
||||||
|
_damage_organ(character, target_organ_id, rate * damage_fraction * ticks)
|
||||||
|
|
||||||
|
|
||||||
|
def _damage_organ(character: Character, organ_def_id: str, amount: float) -> None:
|
||||||
|
if amount <= 0:
|
||||||
|
return
|
||||||
|
for slot, part in character.resolved_body_parts().items():
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
if not any(o.organ_def_id == organ_def_id for o in part.organs):
|
||||||
|
continue
|
||||||
|
override = character.get_or_create_body_part_override(slot)
|
||||||
|
assert override is not None
|
||||||
|
organ = next(o for o in override.organs if o.organ_def_id == organ_def_id)
|
||||||
|
organ.integrity = max(0.0, organ.integrity - amount)
|
||||||
|
if organ.integrity <= 0.0:
|
||||||
|
organ.removed = True
|
||||||
|
return
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from engine.aim import apply_cone_deviation
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.item import ItemDef
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.combat import (
|
||||||
|
DiceRoller,
|
||||||
|
ShotResult,
|
||||||
|
UNARMED_SKILL,
|
||||||
|
apply_body_part_damage,
|
||||||
|
choose_hit_slot,
|
||||||
|
find_ranged_target,
|
||||||
|
perform_shoot,
|
||||||
|
)
|
||||||
|
from resources.logic.construction import damage_tile_layer
|
||||||
|
from resources.logic.knockback import resolve_explosion_knockback, resolve_ranged_knockback
|
||||||
|
|
||||||
|
DEGREES_PER_SKILL_POINT = 0.3 # how much a skill point narrows the aim cone
|
||||||
|
MIN_CONE_DEGREES = 1.0
|
||||||
|
PELLET_SPREAD_FRACTION = 0.5 # "blast fills about half the cone"
|
||||||
|
SLUG_CONE_FRACTION = 0.8 # slugshot: "slightly thinner aimcone" than the weapon's base cone
|
||||||
|
|
||||||
|
|
||||||
|
def effective_aim_cone(character: Character, hand_slot: str, registry: DefRegistry) -> float:
|
||||||
|
"""The actual cone a shot is randomized within: base spread minus skill and mod reductions."""
|
||||||
|
held = character.get_held_item(hand_slot)
|
||||||
|
if held is None:
|
||||||
|
return 0.0
|
||||||
|
weapon_def = registry.get_item_def(held.item_def_id)
|
||||||
|
if weapon_def.aim_cone_degrees <= 0:
|
||||||
|
return 0.0
|
||||||
|
skill_id = weapon_def.weapon_skill or UNARMED_SKILL
|
||||||
|
effective_skill = character.bio.get(skill_id, 0) * (1.0 - character.skill_penalty(skill_id, registry))
|
||||||
|
mod_reduction = sum(registry.get_item_def(mod_id).aimcone_reduction_degrees for mod_id in held.attachments)
|
||||||
|
cone = weapon_def.aim_cone_degrees - effective_skill * DEGREES_PER_SKILL_POINT - mod_reduction
|
||||||
|
return max(MIN_CONE_DEGREES, cone)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_ammo_bonus(weapon_def: ItemDef, ammo_def: ItemDef) -> ItemDef:
|
||||||
|
if ammo_def.ammo_damage_bonus == 0.0:
|
||||||
|
return weapon_def
|
||||||
|
return replace(weapon_def, weapon_damage=weapon_def.weapon_damage + ammo_def.ammo_damage_bonus)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExplosionResult:
|
||||||
|
impact: tuple[int, int, int]
|
||||||
|
hit_entities: list[tuple[Character, float]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BlastResult:
|
||||||
|
"""A shotgun's independent pellets, each its own ShotResult."""
|
||||||
|
|
||||||
|
pellets: list[ShotResult]
|
||||||
|
|
||||||
|
|
||||||
|
def perform_explosion(
|
||||||
|
world: World,
|
||||||
|
map_id: str,
|
||||||
|
center: tuple[int, int, int],
|
||||||
|
radius: int,
|
||||||
|
base_damage: float,
|
||||||
|
) -> ExplosionResult:
|
||||||
|
"""AoE: damages every entity and every 'room' tile layer within `radius` (Chebyshev
|
||||||
|
distance), with linear falloff from the impact point. Ties into the same body-part-damage
|
||||||
|
and tile-integrity mechanisms combat/deconstruction already use.
|
||||||
|
"""
|
||||||
|
map_ = world.maps[map_id]
|
||||||
|
cx, cy, cz = center
|
||||||
|
hit_entities: list[tuple[Character, float]] = []
|
||||||
|
for dx in range(-radius, radius + 1):
|
||||||
|
for dy in range(-radius, radius + 1):
|
||||||
|
distance = max(abs(dx), abs(dy))
|
||||||
|
if distance > radius:
|
||||||
|
continue
|
||||||
|
x, y, z = cx + dx, cy + dy, cz
|
||||||
|
if not map_.in_bounds(x, y, z):
|
||||||
|
continue
|
||||||
|
falloff = 1.0 - (distance / (radius + 1))
|
||||||
|
damage = base_damage * falloff
|
||||||
|
|
||||||
|
tile = map_.get_tile(x, y, z)
|
||||||
|
if tile.room is not None:
|
||||||
|
damage_tile_layer(tile, "room", damage)
|
||||||
|
|
||||||
|
target = world.entity_at(map_id, x, y, z)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
slot = choose_hit_slot(target, precision_check=damage)
|
||||||
|
if slot is not None:
|
||||||
|
apply_body_part_damage(target, slot, damage)
|
||||||
|
hit_entities.append((target, damage))
|
||||||
|
|
||||||
|
return ExplosionResult(impact=center, hit_entities=hit_entities)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_rocket_shot(
|
||||||
|
shooter: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
weapon_def: ItemDef,
|
||||||
|
ammo_def: ItemDef,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
cone_degrees: float,
|
||||||
|
rng: DiceRoller,
|
||||||
|
) -> ExplosionResult:
|
||||||
|
"""Travels until it hits something (entity or wall) or runs out of range, then explodes.
|
||||||
|
Knockback radiates outward from the impact point (no recoil - see resolve_explosion_knockback).
|
||||||
|
"""
|
||||||
|
assert shooter.position is not None
|
||||||
|
dx, dy, dz = aim_direction
|
||||||
|
if cone_degrees > 0:
|
||||||
|
dx, dy = apply_cone_deviation(dx, dy, cone_degrees, rng)
|
||||||
|
_target, impact, _steps = find_ranged_target(
|
||||||
|
world, shooter.position.map_id, shooter.position.x, shooter.position.y, shooter.position.z,
|
||||||
|
dx, dy, dz, weapon_def.weapon_range,
|
||||||
|
)
|
||||||
|
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
|
||||||
|
explosion = perform_explosion(world, shooter.position.map_id, impact, weapon_def.blast_radius, effective_weapon_def.weapon_damage)
|
||||||
|
resolve_explosion_knockback(world, ammo_def, impact, explosion.hit_entities)
|
||||||
|
return explosion
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_shot_knockback(
|
||||||
|
world: World, shooter: Character, shot_result: ShotResult, ammo_def: ItemDef, aim_direction: tuple[int, int, int]
|
||||||
|
) -> None:
|
||||||
|
"""Knockback lands on whoever the shot ultimately engages (direct hit, or a ricochet's
|
||||||
|
eventual target), regardless of the hit/miss/opposed-check outcome - see
|
||||||
|
resolve_ranged_knockback. Uses the original aim direction as an approximation of the
|
||||||
|
shot's travel direction (pellets/cone deviation aren't tracked precisely enough to recover
|
||||||
|
their exact path after the fact, which is a fine approximation for "basic" knockback feel).
|
||||||
|
"""
|
||||||
|
if shot_result.target_entity_id is None:
|
||||||
|
return
|
||||||
|
target = world.entities.get(shot_result.target_entity_id)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
resolve_ranged_knockback(world, shooter, target, ammo_def, aim_direction)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_shotgun_blast(
|
||||||
|
shooter: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
weapon_def: ItemDef,
|
||||||
|
ammo_def: ItemDef,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
cone_degrees: float,
|
||||||
|
rng: DiceRoller,
|
||||||
|
) -> BlastResult:
|
||||||
|
"""Shell: pellet_count independent pellets spread across ~half the weapon's cone. Slug:
|
||||||
|
a single, higher-damage projectile in a slightly-thinner-than-base cone instead.
|
||||||
|
"""
|
||||||
|
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
|
||||||
|
is_slug = ammo_def.ammo_type == "shotgun_slug"
|
||||||
|
|
||||||
|
if is_slug:
|
||||||
|
slug_cone = cone_degrees * SLUG_CONE_FRACTION
|
||||||
|
pellets = [
|
||||||
|
perform_shoot(
|
||||||
|
shooter, world, registry, effective_weapon_def, aim_direction, rng=rng,
|
||||||
|
cone_degrees=slug_cone, projectile_type=ammo_def.ammo_type,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
pellet_cone = cone_degrees * PELLET_SPREAD_FRACTION
|
||||||
|
pellets = [
|
||||||
|
perform_shoot(
|
||||||
|
shooter, world, registry, effective_weapon_def, aim_direction, rng=rng,
|
||||||
|
cone_degrees=pellet_cone, projectile_type=ammo_def.ammo_type,
|
||||||
|
)
|
||||||
|
for _ in range(weapon_def.pellet_count)
|
||||||
|
]
|
||||||
|
|
||||||
|
first_hit = next((p for p in pellets if p.target_entity_id is not None), None)
|
||||||
|
if first_hit is not None:
|
||||||
|
_apply_shot_knockback(world, shooter, first_hit, ammo_def, aim_direction)
|
||||||
|
|
||||||
|
return BlastResult(pellets=pellets)
|
||||||
|
|
||||||
|
|
||||||
|
def fire_held_weapon(
|
||||||
|
character: Character,
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
hand_slot: str,
|
||||||
|
aim_direction: tuple[int, int, int],
|
||||||
|
rng: DiceRoller | None = None,
|
||||||
|
):
|
||||||
|
"""The ammo-aware firing path: requires a loaded, compatible ammo-fed weapon (see
|
||||||
|
Character.reload). Routes to a rocket AoE, a shotgun blast, or a normal aimed shot,
|
||||||
|
consuming one round either way. Returns None if there's nothing to fire (unarmed, not
|
||||||
|
ammo-fed, or empty - use perform_shoot/perform_attack directly for simple weapons).
|
||||||
|
"""
|
||||||
|
rng = rng or random.Random()
|
||||||
|
held = character.get_held_item(hand_slot)
|
||||||
|
if held is None:
|
||||||
|
return None
|
||||||
|
weapon_def = registry.get_item_def(held.item_def_id)
|
||||||
|
if weapon_def.magazine_size <= 0 or held.loaded_ammo_count <= 0 or held.loaded_ammo_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# look up the actual ammo ItemDef by its ammo_type (the loaded type), not by item id -
|
||||||
|
# any item whose ammo_type matches is an equally valid stand-in for "what's loaded"
|
||||||
|
ammo_def = _find_ammo_def(registry, held.loaded_ammo_type)
|
||||||
|
if ammo_def is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
held.loaded_ammo_count -= 1
|
||||||
|
cone = effective_aim_cone(character, hand_slot, registry)
|
||||||
|
|
||||||
|
if weapon_def.blast_radius > 0:
|
||||||
|
return perform_rocket_shot(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng)
|
||||||
|
if weapon_def.pellet_count > 1 or ammo_def.ammo_type == "shotgun_slug":
|
||||||
|
return perform_shotgun_blast(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng)
|
||||||
|
|
||||||
|
effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def)
|
||||||
|
result = perform_shoot(
|
||||||
|
character, world, registry, effective_weapon_def, aim_direction, rng=rng,
|
||||||
|
cone_degrees=cone, projectile_type=ammo_def.ammo_type,
|
||||||
|
)
|
||||||
|
_apply_shot_knockback(world, character, result, ammo_def, aim_direction)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ammo_def(registry: DefRegistry, ammo_type: str) -> ItemDef | None:
|
||||||
|
for item_def in registry.item_defs.values():
|
||||||
|
if item_def.ammo_type == ammo_type:
|
||||||
|
return item_def
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.map import MapEmbedding, rotated_size
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
def at_helm(character: Character, world: World) -> MapEmbedding | None:
|
||||||
|
"""Returns the embedding a character can steer, if they're standing on a helm tile of an
|
||||||
|
embedded map (e.g. a ship's wheel) - None otherwise.
|
||||||
|
"""
|
||||||
|
if character.position is None:
|
||||||
|
return None
|
||||||
|
map_ = world.maps[character.position.map_id]
|
||||||
|
embedding = map_.parent_embedding
|
||||||
|
if embedding is None:
|
||||||
|
return None
|
||||||
|
tile = map_.get_tile(character.position.x, character.position.y, character.position.z)
|
||||||
|
room_def = world.registry.get_tile_layer_def("room", tile.layer_id("room"))
|
||||||
|
if room_def is None or not room_def.is_helm:
|
||||||
|
return None
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
|
||||||
|
def _footprint_is_clear(world: World, embedding: MapEmbedding, anchor: tuple[int, int, int], rotation: int) -> bool:
|
||||||
|
parent = embedding.parent_map
|
||||||
|
width, height = rotated_size(embedding.child_map.width, embedding.child_map.height, rotation)
|
||||||
|
for ly in range(height):
|
||||||
|
for lx in range(width):
|
||||||
|
px, py, pz = anchor[0] + lx, anchor[1] + ly, anchor[2]
|
||||||
|
if not parent.in_bounds(px, py, pz):
|
||||||
|
return False
|
||||||
|
if not parent.get_tile(px, py, pz).is_walkable(world.registry):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def try_steer_ship(character: Character, world: World, dx: int, dy: int, dz: int = 0) -> bool:
|
||||||
|
"""Moves the embedded map the character is helming by one step, if the destination footprint
|
||||||
|
is clear in the parent map. Entities aboard need no updates - only the anchor changes.
|
||||||
|
"""
|
||||||
|
embedding = at_helm(character, world)
|
||||||
|
if embedding is None:
|
||||||
|
return False
|
||||||
|
new_anchor = (embedding.anchor[0] + dx, embedding.anchor[1] + dy, embedding.anchor[2] + dz)
|
||||||
|
if not _footprint_is_clear(world, embedding, new_anchor, embedding.rotation):
|
||||||
|
return False
|
||||||
|
embedding.move_to(new_anchor)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def try_turn_ship(character: Character, world: World, quarter_turns: int) -> bool:
|
||||||
|
"""Turns the embedded map the character is helming, if the rotated footprint is clear."""
|
||||||
|
embedding = at_helm(character, world)
|
||||||
|
if embedding is None:
|
||||||
|
return False
|
||||||
|
new_rotation = (embedding.rotation + quarter_turns) % 4
|
||||||
|
if not _footprint_is_clear(world, embedding, embedding.anchor, new_rotation):
|
||||||
|
return False
|
||||||
|
embedding.rotate_to(new_rotation)
|
||||||
|
return True
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from engine.character import Ailment, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
HEATSTROKE_SLOT = "torso"
|
||||||
|
HEATSTROKE_PROGRESS_PER_TICK = 5.0
|
||||||
|
HEATSTROKE_RECOVERY_PER_TICK = 3.0 # while sheltered
|
||||||
|
|
||||||
|
LIGHTNING_BURN_FRACTION = 0.5 # "about half" the body parts
|
||||||
|
LIGHTNING_INTEGRITY_DAMAGE = 60.0 # "large amounts"
|
||||||
|
LIGHTNING_BURN_SEVERITY = 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def is_exposed(map_: Map, x: float, y: float, z: float, registry: DefRegistry) -> bool:
|
||||||
|
"""True if the tile has no roof layer over it - open to the sky/weather."""
|
||||||
|
return map_.get_tile(x, y, z).roof is None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_heatstroke_tick(character: Character, exposed: bool, ticks: int) -> None:
|
||||||
|
"""Progresses (while exposed) or recovers (while sheltered) a heatstroke ailment with a
|
||||||
|
0-100 progress meter, attached to the torso (core body temperature). Fully recovering
|
||||||
|
removes the ailment; it's otherwise created on first exposure.
|
||||||
|
"""
|
||||||
|
if ticks <= 0:
|
||||||
|
return
|
||||||
|
part = character.get_or_create_body_part_override(HEATSTROKE_SLOT)
|
||||||
|
if part is None:
|
||||||
|
return
|
||||||
|
ailment = next((a for a in part.ailments if a.id == "heatstroke"), None)
|
||||||
|
|
||||||
|
if exposed:
|
||||||
|
if ailment is None:
|
||||||
|
ailment = Ailment(id="heatstroke", name="Heatstroke")
|
||||||
|
part.ailments.append(ailment)
|
||||||
|
ailment.progress = min(100.0, ailment.progress + HEATSTROKE_PROGRESS_PER_TICK * ticks)
|
||||||
|
ailment.severity = ailment.progress / 100.0
|
||||||
|
elif ailment is not None:
|
||||||
|
ailment.progress = max(0.0, ailment.progress - HEATSTROKE_RECOVERY_PER_TICK * ticks)
|
||||||
|
if ailment.progress <= 0.0:
|
||||||
|
part.ailments.remove(ailment)
|
||||||
|
else:
|
||||||
|
ailment.severity = ailment.progress / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_random_exposed_position(
|
||||||
|
map_: Map, registry: DefRegistry, rng: random.Random, z: int = 0
|
||||||
|
) -> tuple[int, int, int] | None:
|
||||||
|
"""A random walkable, unroofed tile on the map's surface (z) layer - None if there isn't one."""
|
||||||
|
candidates = [
|
||||||
|
(x, y, z)
|
||||||
|
for y in range(map_.height)
|
||||||
|
for x in range(map_.width)
|
||||||
|
if is_exposed(map_, x, y, z, registry) and map_.get_tile(x, y, z).is_walkable(registry)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return rng.choice(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def strike_lightning(
|
||||||
|
world: World, map_id: str, registry: DefRegistry, rng: random.Random | None = None
|
||||||
|
) -> tuple[int, int, int] | None:
|
||||||
|
"""Strikes a random unroofed surface tile; if a character is standing there, burns about
|
||||||
|
half their body parts badly (large integrity damage + a severe burn ailment on each).
|
||||||
|
Returns the struck position, or None if the map has no exposed tile to strike.
|
||||||
|
"""
|
||||||
|
rng = rng or random.Random()
|
||||||
|
map_ = world.maps[map_id]
|
||||||
|
position = find_random_exposed_position(map_, registry, rng)
|
||||||
|
if position is None:
|
||||||
|
return None
|
||||||
|
target = world.entity_at(map_id, *position)
|
||||||
|
if isinstance(target, Character):
|
||||||
|
_burn_random_body_parts(target, rng)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
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_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_INTEGRITY_DAMAGE)
|
||||||
|
if part.integrity <= 0.0:
|
||||||
|
part.removed = True
|
||||||
|
part.ailments.append(Ailment(id="burn", name="Burn", severity=LIGHTNING_BURN_SEVERITY, progress=100.0))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_weather_tick(
|
||||||
|
world: World,
|
||||||
|
registry: DefRegistry,
|
||||||
|
map_id: str,
|
||||||
|
ticks: int,
|
||||||
|
rng: random.Random | None = None,
|
||||||
|
lightning_chance_per_tick: float = 0.3,
|
||||||
|
) -> tuple[int, int, int] | None:
|
||||||
|
"""Call once per game tick for a given map: progresses heatstroke for everyone there based
|
||||||
|
on roof exposure, and rolls a chance of a lightning strike per elapsed tick. Returns the
|
||||||
|
position of the last lightning strike this call, if any.
|
||||||
|
"""
|
||||||
|
rng = rng or random.Random()
|
||||||
|
map_ = world.maps[map_id]
|
||||||
|
for entity in world.entities_on_map(map_id):
|
||||||
|
if not isinstance(entity, Character) or entity.position is None:
|
||||||
|
continue
|
||||||
|
exposed = is_exposed(map_, entity.position.x, entity.position.y, entity.position.z, registry)
|
||||||
|
apply_heatstroke_tick(entity, exposed, ticks)
|
||||||
|
|
||||||
|
struck = None
|
||||||
|
for _ in range(ticks):
|
||||||
|
if rng.random() < lightning_chance_per_tick:
|
||||||
|
struck = strike_lightning(world, map_id, registry, rng) or struck
|
||||||
|
return struck
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from engine.network.protocol import DEFAULT_PORT
|
||||||
|
from engine.network.server import GameServer
|
||||||
|
|
||||||
|
|
||||||
|
async def run(host: str, port: int) -> None:
|
||||||
|
server = GameServer(host=host, port=port)
|
||||||
|
await server.start()
|
||||||
|
print(f"Listening on {host}:{server.port}")
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Dedicated multiplayer server.")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(run(args.host, args.port))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_species_def_carries_sample_abilities(registry):
|
||||||
|
species = registry.get_species_def("human")
|
||||||
|
ability_ids = {a.id for a in species.abilities}
|
||||||
|
assert ability_ids == {"leap", "melee_attack", "ranged_attack", "time_warp_sphere"}
|
||||||
|
leap = next(a for a in species.abilities if a.id == "leap")
|
||||||
|
assert leap.required_slots == ("left_leg", "right_leg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_perform_ability_true_when_required_parts_present(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.can_perform_ability("leap", registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_perform_ability_false_when_a_required_part_is_missing(registry):
|
||||||
|
character = make_human(registry, body_parts=[BodyPart("left_leg", "human_left_leg", removed=True)])
|
||||||
|
assert character.can_perform_ability("leap", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_perform_ability_false_for_unknown_ability(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.can_perform_ability("fly", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_perform_ability_false_without_species(registry):
|
||||||
|
character = Character()
|
||||||
|
assert character.can_perform_ability("leap", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_try_leap_gated_by_missing_legs(registry):
|
||||||
|
field = Map("field", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
|
||||||
|
legless = make_human(
|
||||||
|
registry,
|
||||||
|
position=EntityPosition("field", 2, 2, 0),
|
||||||
|
body_parts=[BodyPart("left_leg", "human_left_leg", removed=True)],
|
||||||
|
)
|
||||||
|
assert world.try_leap(legless, 1, 0, 0) is False
|
||||||
|
assert (legless.position.x, legless.position.y) == (2, 2) # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_world_try_leap_allowed_with_both_legs(registry):
|
||||||
|
field = Map("field", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
|
||||||
|
healthy = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
assert world.try_leap(healthy, 1, 0, 0) is True
|
||||||
|
assert (healthy.position.x, healthy.position.y) == (4, 2)
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.ui import AbilityBar, AbilityBarSlot
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import activate_ability_bar_slot
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 15, 15, 1)
|
||||||
|
for x in range(15):
|
||||||
|
for y in range(15):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
# --- AbilityBar state ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ability_bar_starts_with_ten_empty_slots():
|
||||||
|
bar = AbilityBar()
|
||||||
|
assert len(bar.slots) == 10
|
||||||
|
assert all(slot is None for slot in bar.slots)
|
||||||
|
assert bar.selected_index is None
|
||||||
|
assert bar.selected_slot() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_places_a_slot_at_the_given_index():
|
||||||
|
bar = AbilityBar()
|
||||||
|
slot = AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere")
|
||||||
|
bar.assign(2, slot)
|
||||||
|
assert bar.slots[2] is slot
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_sets_selected_index_and_selected_slot_reads_it_back():
|
||||||
|
bar = AbilityBar()
|
||||||
|
slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere")
|
||||||
|
bar.assign(5, slot)
|
||||||
|
bar.select(5)
|
||||||
|
assert bar.selected_index == 5
|
||||||
|
assert bar.selected_slot() is slot
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_out_of_range_is_ignored():
|
||||||
|
bar = AbilityBar()
|
||||||
|
bar.select(0)
|
||||||
|
bar.select(99)
|
||||||
|
assert bar.selected_index == 0 # unchanged by the invalid select
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_click_and_hotkey_both_just_call_select():
|
||||||
|
# No separate "click" concept - a mouse click on bar position i and pressing numkey i+1
|
||||||
|
# both resolve to the same select(i) call once input is wired to a bar index.
|
||||||
|
bar = AbilityBar()
|
||||||
|
slot = AbilityBarSlot(kind="hand", source_id="left_hand", ability_id="time_warp_sphere")
|
||||||
|
bar.assign(0, slot)
|
||||||
|
bar.select(0)
|
||||||
|
assert bar.selected_slot() is slot
|
||||||
|
|
||||||
|
|
||||||
|
# --- activate_ability_bar_slot resolver -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_ability_bar_slot_dispatches_to_hand(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
caster.wield_item("right_hand", "chronowand", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
slot = AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere")
|
||||||
|
result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0)
|
||||||
|
assert result is not None
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_ability_bar_slot_dispatches_to_implant(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere")
|
||||||
|
result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0)
|
||||||
|
assert result is not None
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_ability_bar_slot_unknown_kind_returns_none(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
slot = AbilityBarSlot(kind="bogus", source_id="right_hand", ability_id="time_warp_sphere")
|
||||||
|
assert activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0) is None
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.ui import AbilityBarSlot
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import ABILITY_COOLDOWNS, activate_ability_bar_slot, activate_hand, activate_implant
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
# --- Character.is_ability_ready / start_ability_cooldown ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ability_ready_by_default(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.is_ability_ready("time_warp_sphere", now=0.0) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_ability_cooldown_blocks_until_it_expires(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.start_ability_cooldown("time_warp_sphere", now=10.0, duration=5.0)
|
||||||
|
assert character.is_ability_ready("time_warp_sphere", now=12.0) is False
|
||||||
|
assert character.is_ability_ready("time_warp_sphere", now=15.0) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_duration_cooldown_is_a_no_op(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.start_ability_cooldown("time_warp_sphere", now=10.0, duration=0.0)
|
||||||
|
assert character.is_ability_ready("time_warp_sphere", now=10.0) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- activate_hand (chronowand) respects cooldown --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_chronowand_cast_starts_a_cooldown(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.wield_item("right_hand", "chronowand", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0)
|
||||||
|
assert caster.is_ability_ready("time_warp_sphere", now=0.0) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chronowand_cannot_recast_while_on_cooldown(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.wield_item("right_hand", "chronowand", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0)
|
||||||
|
result = activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=1.0)
|
||||||
|
assert result is None
|
||||||
|
assert len(world.time_warp_zones) == 1 # still just the first cast
|
||||||
|
|
||||||
|
|
||||||
|
def test_chronowand_can_recast_after_cooldown_expires(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.wield_item("right_hand", "chronowand", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0)
|
||||||
|
cooldown = ABILITY_COOLDOWNS["time_warp_sphere"]
|
||||||
|
result = activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=cooldown + 0.1)
|
||||||
|
assert result is not None
|
||||||
|
assert len(world.time_warp_zones) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- activate_implant respects cooldown -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_implant_cannot_recast_while_on_cooldown(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
activate_implant(caster, world, registry, "chronoimplant", now=0.0)
|
||||||
|
result = activate_implant(caster, world, registry, "chronoimplant", now=1.0)
|
||||||
|
assert result is None
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- cooldown is shared across activation paths (hand, implant, ability bar) ------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cooldown_is_shared_between_hand_and_implant_paths(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.wield_item("right_hand", "chronowand", registry)
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0)
|
||||||
|
# same underlying ability (time_warp_sphere), so the implant is also on cooldown now
|
||||||
|
result = activate_implant(caster, world, registry, "chronoimplant", now=1.0)
|
||||||
|
assert result is None
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ability_bar_slot_respects_cooldown_too(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere")
|
||||||
|
|
||||||
|
activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=0.0)
|
||||||
|
result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=1.0)
|
||||||
|
assert result is None
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import activate_hand
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world, field
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
def __init__(self, roll=15, choice_value="torso"):
|
||||||
|
self._roll = roll
|
||||||
|
self._choice_value = choice_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_hand_attacks_unarmed(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
world.add_entity(defender)
|
||||||
|
|
||||||
|
result = activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng())
|
||||||
|
assert result is not None
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.weapon_bonus == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_hand_with_no_target_ahead_is_a_no_op(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
assert activate_hand(attacker, world, registry, "right_hand", (1, 0, 0)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wielded_melee_weapon_attacks(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
attacker.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
world.add_entity(defender)
|
||||||
|
|
||||||
|
result = activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng())
|
||||||
|
assert result.weapon_bonus == pytest.approx(6.0) # staff_oak
|
||||||
|
|
||||||
|
|
||||||
|
def test_wielded_gun_shoots(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "pistol_basic", registry)
|
||||||
|
target = make_human(registry, position=EntityPosition("field", 3, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
world.add_entity(target)
|
||||||
|
|
||||||
|
result = activate_hand(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng())
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.target_entity_id == target.entity_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_wielded_deconstructor_interacts_with_tile_ahead(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
world.add_entity(character)
|
||||||
|
|
||||||
|
result = activate_hand(character, world, registry, "right_hand", (1, 0, 0))
|
||||||
|
assert result is True
|
||||||
|
assert field.get_tile(6, 5, 0).room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_left_hand_deconstructor_uses_right_click_semantics(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
character.wield_item("left_hand", "multi_deconstructor", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
world.add_entity(character)
|
||||||
|
|
||||||
|
result = activate_hand(character, world, registry, "left_hand", (1, 0, 0))
|
||||||
|
assert result is not None
|
||||||
|
assert result.def_id == "wood_planks" # right-click behavior: yields an item
|
||||||
|
assert field.get_tile(6, 5, 0).room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wielded_constructor_builds_on_bare_tile_ahead(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
character.wield_item("right_hand", "constructor_tool", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
planks_def = registry.get_item_def("wood_planks")
|
||||||
|
character.inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0)
|
||||||
|
world.add_entity(character)
|
||||||
|
|
||||||
|
result = activate_hand(character, world, registry, "right_hand", (1, 0, 0))
|
||||||
|
assert result is True
|
||||||
|
assert field.get_tile(6, 5, 0).room.def_id == "wood_wall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstructor_out_of_bounds_target_is_a_no_op(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 0, 0, 0))
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
world.add_entity(character)
|
||||||
|
|
||||||
|
assert activate_hand(character, world, registry, "right_hand", (-1, 0, 0)) is None
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.aim import apply_cone_deviation, snap_to_8way
|
||||||
|
from engine.camera import OrthoCamera
|
||||||
|
|
||||||
|
|
||||||
|
class FixedUniformRng:
|
||||||
|
def __init__(self, value: float):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def uniform(self, _a, _b):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dx,dy,expected",
|
||||||
|
[
|
||||||
|
(5, 0, (1, 0)),
|
||||||
|
(0, 5, (0, 1)),
|
||||||
|
(-5, 0, (-1, 0)),
|
||||||
|
(0, -5, (0, -1)),
|
||||||
|
(5, 5, (1, 1)),
|
||||||
|
(-5, 5, (-1, 1)),
|
||||||
|
(-5, -5, (-1, -1)),
|
||||||
|
(5, -5, (1, -1)),
|
||||||
|
(0, 0, (0, 1)),
|
||||||
|
(5, 0.1, (1, 0)), # nearly east, snaps to east not northeast
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_snap_to_8way(dx, dy, expected):
|
||||||
|
assert snap_to_8way(dx, dy) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_snap_to_8way_covers_full_circle():
|
||||||
|
for degrees in range(0, 360, 5):
|
||||||
|
radians = math.radians(degrees)
|
||||||
|
dx, dy = math.cos(radians), math.sin(radians)
|
||||||
|
dirx, diry = snap_to_8way(dx, dy)
|
||||||
|
assert (dirx, diry) != (0, 0)
|
||||||
|
assert abs(dirx) <= 1 and abs(diry) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cone_deviation_with_zero_cone_is_a_no_op():
|
||||||
|
assert apply_cone_deviation(1, 0, 0.0, FixedUniformRng(50.0)) == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cone_deviation_with_zero_deviation_keeps_direction():
|
||||||
|
assert apply_cone_deviation(1, 0, 30.0, FixedUniformRng(0.0)) == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cone_deviation_can_shift_to_an_adjacent_direction():
|
||||||
|
# east (1,0) deviated by +40 degrees should snap to northeast (1,1) -> y-down world,
|
||||||
|
# so a positive angle rotates toward (0,1)/south side
|
||||||
|
result = apply_cone_deviation(1, 0, 90.0, FixedUniformRng(40.0))
|
||||||
|
assert result != (1, 0)
|
||||||
|
assert abs(result[0]) <= 1 and abs(result[1]) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_cone_deviation_stays_within_grid_directions():
|
||||||
|
rng = FixedUniformRng(179.0)
|
||||||
|
for base_dx, base_dy in [(1, 0), (0, 1), (-1, 0), (0, -1)]:
|
||||||
|
dirx, diry = apply_cone_deviation(base_dx, base_dy, 20.0, rng)
|
||||||
|
assert (dirx, diry) != (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_to_world_center_of_viewport_is_camera_target():
|
||||||
|
camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0)
|
||||||
|
camera.set_target(5.0, 5.0)
|
||||||
|
wx, wy = camera.screen_to_world(400, 300)
|
||||||
|
assert wx == pytest.approx(5.0, abs=0.05)
|
||||||
|
assert wy == pytest.approx(5.0, abs=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_to_world_top_left_is_up_and_left_of_target():
|
||||||
|
camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0)
|
||||||
|
camera.set_target(5.0, 5.0)
|
||||||
|
wx, wy = camera.screen_to_world(0, 0)
|
||||||
|
assert wx < 5.0
|
||||||
|
assert wy < 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_to_world_round_trips_with_scale_offset():
|
||||||
|
camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0)
|
||||||
|
camera.set_target(3.0, 7.0)
|
||||||
|
(scale_x, scale_y), (offset_x, offset_y) = camera.scale_offset()
|
||||||
|
|
||||||
|
wx, wy = camera.screen_to_world(200, 450)
|
||||||
|
ndc_x = wx * scale_x + offset_x
|
||||||
|
ndc_y = wy * scale_y + offset_y
|
||||||
|
|
||||||
|
# (200,450) in an 800x600 viewport -> ndc (-0.5, -0.5) in the y-up NDC space
|
||||||
|
assert ndc_x == pytest.approx(-0.5, abs=1e-6)
|
||||||
|
assert ndc_y == pytest.approx(-0.5, abs=1e-6)
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import ClothingPiece, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.combat import defender_armor, perform_attack
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
def __init__(self, roll: int, choice_value=None):
|
||||||
|
self._roll = roll
|
||||||
|
self._choice_value = choice_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_defender_armor_sums_worn_clothing(registry):
|
||||||
|
defender = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
assert defender_armor(defender, registry) == pytest.approx(3.0) # leather_jacket's armor_reduction
|
||||||
|
|
||||||
|
|
||||||
|
def test_unarmored_defender_has_zero_armor(registry):
|
||||||
|
defender = make_human(registry)
|
||||||
|
assert defender_armor(defender, registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_armor_reduces_damage_taken(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
|
||||||
|
unarmored = make_human(registry)
|
||||||
|
armored = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
|
||||||
|
unarmored_result = perform_attack(attacker, unarmored, registry, rng=FixedRng(roll=15, choice_value="torso"))
|
||||||
|
armored_result = perform_attack(attacker, armored, registry, rng=FixedRng(roll=15, choice_value="torso"))
|
||||||
|
|
||||||
|
assert armored_result.armor_reduction == pytest.approx(3.0)
|
||||||
|
assert armored_result.damage == pytest.approx(max(0.0, unarmored_result.damage - 3.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_heavy_armor_can_absorb_a_hit_entirely(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
defender = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
|
||||||
|
# a roll that only barely clears the DC: margin (1) is smaller than armor_reduction (3)
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=11, choice_value="torso"))
|
||||||
|
assert result.hit is True # the check itself succeeded...
|
||||||
|
assert result.damage == 0.0 # ...but armor soaked up all of it
|
||||||
|
assert defender.get_body_part("torso").integrity == pytest.approx(100.0)
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.combat import perform_attack
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceRng:
|
||||||
|
"""Deterministic stand-in: returns rolls from a fixed sequence, one call at a time."""
|
||||||
|
|
||||||
|
def __init__(self, rolls: list[int], choice_value=None):
|
||||||
|
self._rolls = list(rolls)
|
||||||
|
self._choice_value = choice_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._rolls.pop(0)
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_defender_uses_flat_difficulty(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
defender = make_human(registry)
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=SequenceRng([15], choice_value="torso"))
|
||||||
|
assert result.blocked_check is False
|
||||||
|
assert result.hit is True # 15 > BASE_DIFFICULTY(10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocking_defender_uses_opposed_check(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 0
|
||||||
|
defender = make_human(registry, is_blocking=True)
|
||||||
|
defender.bio["athletics"] = 15 # strong defense roll
|
||||||
|
|
||||||
|
# attacker rolls 12, defender rolls 5 but has a high skill -> defender wins
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=SequenceRng([12, 5], choice_value="torso"))
|
||||||
|
assert result.blocked_check is True
|
||||||
|
assert result.hit is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocking_defender_can_still_be_hit_if_attacker_beats_the_opposed_check(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 15
|
||||||
|
defender = make_human(registry, is_blocking=True)
|
||||||
|
defender.bio["athletics"] = 0
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=SequenceRng([18, 3], choice_value="torso"))
|
||||||
|
assert result.blocked_check is True
|
||||||
|
assert result.hit is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_shield_bonus_helps_the_defender_block(registry):
|
||||||
|
attacker = make_human(registry) # bio athletics=0, so attacker_check == the raw roll
|
||||||
|
|
||||||
|
unshielded = make_human(registry, is_blocking=True)
|
||||||
|
shielded = make_human(registry, is_blocking=True)
|
||||||
|
shielded.wield_item("left_hand", "wooden_shield", registry)
|
||||||
|
|
||||||
|
# attacker rolls 10 (check=10), defender rolls 8 (check=8) both times;
|
||||||
|
# only wooden_shield's +4 block bonus should flip the outcome
|
||||||
|
result_unshielded = perform_attack(attacker, unshielded, registry, rng=SequenceRng([10, 8], choice_value="torso"))
|
||||||
|
result_shielded = perform_attack(attacker, shielded, registry, rng=SequenceRng([10, 8], choice_value="torso"))
|
||||||
|
|
||||||
|
assert result_unshielded.hit is True # 10 > 8
|
||||||
|
assert result_shielded.hit is False # 10 < 8 + 4 (shield bonus)
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character_creator import CharacterCreator
|
||||||
|
from engine.character_library import CharacterLibrary
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def library():
|
||||||
|
return CharacterLibrary(sqlite3.connect(":memory:"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- species / gender selection -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_species_defaults_gender_to_the_first_valid_option(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_species("human") is True
|
||||||
|
assert creator.species_id == "human"
|
||||||
|
assert creator.gender == "male"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_species_rejects_unknown_species(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_species("dragon") is False
|
||||||
|
assert creator.species_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_gender_accepts_a_valid_option(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.set_gender("nonbinary") is True
|
||||||
|
assert creator.gender == "nonbinary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_gender_rejects_option_not_valid_for_species(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("alien_tri") # genders: alpha/beta/gamma
|
||||||
|
assert creator.set_gender("male") is False
|
||||||
|
assert creator.gender == "alpha" # unchanged, still the auto-picked default
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_gender_before_species_is_a_no_op(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_gender("male") is False
|
||||||
|
assert creator.gender is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_species_resets_gender_if_no_longer_valid(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
creator.set_gender("nonbinary")
|
||||||
|
creator.set_species("bot") # monogender, "none" - "nonbinary" isn't valid here
|
||||||
|
assert creator.gender == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_species_keeps_gender_if_still_valid(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
creator.set_gender("female")
|
||||||
|
creator.set_species("dog") # dog also has "male"/"female"
|
||||||
|
assert creator.gender == "female"
|
||||||
|
|
||||||
|
|
||||||
|
# --- skill point budget ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_starts_with_the_full_budget_unspent(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.spent_skill_points() == 0
|
||||||
|
assert creator.remaining_skill_points() == creator.skill_points_budget
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_skill_spends_a_point(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.increase_skill("stealth") is True
|
||||||
|
assert creator.bio["stealth"] == 1
|
||||||
|
assert creator.remaining_skill_points() == creator.skill_points_budget - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_skill_fails_once_budget_exhausted(registry):
|
||||||
|
creator = CharacterCreator(registry, skill_points_budget=2)
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
assert creator.increase_skill("athletics") is False
|
||||||
|
assert creator.bio["athletics"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_skill_fails_past_max_level(registry):
|
||||||
|
creator = CharacterCreator(registry, skill_points_budget=99, max_skill_level=2)
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
assert creator.increase_skill("stealth") is False
|
||||||
|
assert creator.bio["stealth"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_skill_rejects_unknown_skill(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.increase_skill("lockpicking") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrease_skill_refunds_a_point(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
assert creator.decrease_skill("stealth") is True
|
||||||
|
assert creator.bio["stealth"] == 0
|
||||||
|
assert creator.remaining_skill_points() == creator.skill_points_budget
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrease_skill_below_zero_is_a_no_op(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.decrease_skill("stealth") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- body part preview ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_body_parts_empty_before_species_chosen(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.preview_body_parts() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_body_parts_matches_species_defaults(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("dog")
|
||||||
|
slots = {p.slot for p in creator.preview_body_parts()}
|
||||||
|
assert slots == {"head", "torso", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- readiness / build_character ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_ready_without_name_species_and_gender(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.is_ready() is False
|
||||||
|
creator.set_name("Vex")
|
||||||
|
assert creator.is_ready() is False
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.is_ready() is True # species auto-fills a valid gender
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_character_returns_none_when_not_ready(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_name("Vex")
|
||||||
|
assert creator.build_character() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_character_produces_a_character_with_chosen_traits(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_name(" Vex ")
|
||||||
|
creator.set_species("reptilian_quad")
|
||||||
|
creator.increase_skill("athletics")
|
||||||
|
|
||||||
|
character = creator.build_character()
|
||||||
|
assert character is not None
|
||||||
|
assert character.name == "Vex" # whitespace trimmed
|
||||||
|
assert character.species_id == "reptilian_quad"
|
||||||
|
assert character.gender == "none"
|
||||||
|
assert character.bio["athletics"] == 1
|
||||||
|
assert {p.slot for p in character.default_body_parts} == {
|
||||||
|
"head", "torso", "left_arm", "right_arm", "left_arm_2", "right_arm_2", "left_leg", "right_leg",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- confirm: saves into the library -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_saves_into_the_library(registry, library):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_name("Vex")
|
||||||
|
creator.set_species("human")
|
||||||
|
|
||||||
|
character = creator.confirm(library, "acct-1", "char-1")
|
||||||
|
assert character is not None
|
||||||
|
|
||||||
|
loaded = library.load_character("acct-1", "char-1", registry)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.name == "Vex"
|
||||||
|
assert loaded.species_id == "human"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_does_nothing_when_not_ready(registry, library):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.confirm(library, "acct-1", "char-1") is None
|
||||||
|
assert library.load_character("acct-1", "char-1", registry) is None
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_body_part_falls_back_to_default_when_missing():
|
||||||
|
character = Character(
|
||||||
|
default_body_parts=[
|
||||||
|
BodyPart("head", "human_head"),
|
||||||
|
BodyPart("torso", "human_torso"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert character.get_body_part("head").part_def_id == "human_head"
|
||||||
|
assert character.get_body_part("torso").part_def_id == "human_torso"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_body_part_prefers_explicit_override_over_default():
|
||||||
|
character = Character(
|
||||||
|
body_parts=[BodyPart("head", "human_head_hat")],
|
||||||
|
default_body_parts=[
|
||||||
|
BodyPart("head", "human_head"),
|
||||||
|
BodyPart("torso", "human_torso"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert character.get_body_part("head").part_def_id == "human_head_hat"
|
||||||
|
assert character.get_body_part("torso").part_def_id == "human_torso" # still falls back
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_body_part_returns_none_for_unknown_slot():
|
||||||
|
character = Character(default_body_parts=[BodyPart("head", "human_head")])
|
||||||
|
assert character.get_body_part("left_arm") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolved_body_parts_covers_union_of_both_arrays():
|
||||||
|
character = Character(
|
||||||
|
body_parts=[BodyPart("head", "human_head_hat")],
|
||||||
|
default_body_parts=[
|
||||||
|
BodyPart("head", "human_head"),
|
||||||
|
BodyPart("torso", "human_torso"),
|
||||||
|
BodyPart("left_arm", "human_left_arm"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resolved = character.resolved_body_parts()
|
||||||
|
assert resolved["head"].part_def_id == "human_head_hat"
|
||||||
|
assert resolved["torso"].part_def_id == "human_torso"
|
||||||
|
assert resolved["left_arm"].part_def_id == "human_left_arm"
|
||||||
|
assert set(resolved.keys()) == {"head", "torso", "left_arm"}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character, ClothingPiece
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(
|
||||||
|
species_id="human",
|
||||||
|
gender="female",
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blood_status_thresholds(registry):
|
||||||
|
species = registry.get_species_def("human")
|
||||||
|
character = make_human(registry, blood_percentage=100.0)
|
||||||
|
assert character.blood_status(species) == "normal"
|
||||||
|
|
||||||
|
character.blood_percentage = 50.0
|
||||||
|
assert character.blood_status(species) == "danger"
|
||||||
|
|
||||||
|
character.blood_percentage = 10.0
|
||||||
|
assert character.blood_status(species) == "critical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bio_defaults_to_zeroed_dnd_skills(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.bio["stealth"] == 0
|
||||||
|
assert character.bio["athletics"] == 0
|
||||||
|
assert set(character.bio.keys()) >= {"perception", "insight", "acrobatics"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mental_stats_optional_by_default(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.mental_stats is None
|
||||||
|
|
||||||
|
npc = make_human(registry, mental_stats={"insanity": 0.0})
|
||||||
|
assert npc.mental_stats == {"insanity": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_penalty_zero_when_all_parts_healthy(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.skill_penalty("acrobatics", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_penalty_full_when_relevant_parts_missing(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [
|
||||||
|
BodyPart("left_leg", "human_left_leg", removed=True),
|
||||||
|
BodyPart("right_leg", "human_right_leg", removed=True),
|
||||||
|
]
|
||||||
|
# acrobatics is weighted only on the legs (0.5 each) -> fully lost when both are gone
|
||||||
|
assert character.skill_penalty("acrobatics", registry) == pytest.approx(1.0)
|
||||||
|
# athletics also draws from torso/arms, which are untouched -> partial loss
|
||||||
|
penalty = character.skill_penalty("athletics", registry)
|
||||||
|
assert 0.0 < penalty < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_penalty_scales_with_integrity_not_just_removal(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("head", "human_head", integrity=50.0)]
|
||||||
|
penalty = character.skill_penalty("perception", registry)
|
||||||
|
assert penalty == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_body_part_returns_none_when_explicitly_removed(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("left_arm", "human_left_arm", removed=True)]
|
||||||
|
assert character.get_body_part("left_arm") is None
|
||||||
|
assert character.get_body_part("right_arm") is not None # still falls back to default
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_clothing_falls_back_to_default_like_body_parts(registry):
|
||||||
|
character = make_human(
|
||||||
|
registry,
|
||||||
|
default_clothing=registry.new_default_clothing("player"),
|
||||||
|
)
|
||||||
|
assert character.get_clothing("back").item_def_id == "backpack_basic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_clothing_override_beats_default_and_removed_returns_none(registry):
|
||||||
|
character = make_human(
|
||||||
|
registry,
|
||||||
|
clothing=[ClothingPiece("torso", "leather_jacket")],
|
||||||
|
default_clothing=registry.new_default_clothing("player"),
|
||||||
|
)
|
||||||
|
assert character.get_clothing("torso").item_def_id == "leather_jacket"
|
||||||
|
assert character.get_clothing("back").item_def_id == "backpack_basic" # still falls back
|
||||||
|
|
||||||
|
character.clothing.append(ClothingPiece("back", "backpack_basic", removed=True))
|
||||||
|
assert character.get_clothing("back") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_equipped_backpack_grants_a_lazily_created_inventory(registry):
|
||||||
|
character = make_human(
|
||||||
|
registry,
|
||||||
|
default_clothing=registry.new_default_clothing("player"),
|
||||||
|
)
|
||||||
|
assert character.get_clothing("back").inventory is None
|
||||||
|
|
||||||
|
inventory = character.get_equipped_inventory("back", registry)
|
||||||
|
assert (inventory.width, inventory.height) == (4, 4)
|
||||||
|
# same instance on repeated access, and matches what's stored on the clothing piece
|
||||||
|
assert character.get_equipped_inventory("back", registry) is inventory
|
||||||
|
assert character.get_clothing("back").inventory is inventory
|
||||||
|
|
||||||
|
|
||||||
|
def test_equipped_inventory_is_none_for_non_container_clothing(registry):
|
||||||
|
character = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
assert character.get_equipped_inventory("torso", registry) is None
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character, ClothingPiece, HeldItem, Organ
|
||||||
|
from engine.character_library import CharacterLibrary
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def library():
|
||||||
|
return CharacterLibrary(sqlite3.connect(":memory:"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_character(registry) -> Character:
|
||||||
|
character = Character(
|
||||||
|
entity_id="ignored-on-save", # library keys on (account_id, character_id), not entity_id
|
||||||
|
name="Vex",
|
||||||
|
species_id="reptilian_quad",
|
||||||
|
gender="none",
|
||||||
|
blood_percentage=88.0,
|
||||||
|
strength=14,
|
||||||
|
body_parts=[
|
||||||
|
BodyPart(
|
||||||
|
"torso", "reptilian_torso", integrity=70.0,
|
||||||
|
ailments=[Ailment("poisoned", "Poisoned", severity=0.3, progress=12.0)],
|
||||||
|
organs=[Organ("reptilian_heart", integrity=60.0)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default_body_parts=registry.new_default_body_parts("reptilian_quad"),
|
||||||
|
clothing=[ClothingPiece("torso", "leather_jacket")],
|
||||||
|
default_clothing=[],
|
||||||
|
held_items=[HeldItem("right_hand", "chronowand", attachments=["scope_basic"])],
|
||||||
|
implants=["chronoimplant"],
|
||||||
|
mental_stats={"insanity": 4.0},
|
||||||
|
)
|
||||||
|
character.bio["stealth"] = 5
|
||||||
|
backpack = Inventory(4, 4)
|
||||||
|
backpack.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0)
|
||||||
|
character.clothing[0] = ClothingPiece("torso", "backpack_basic")
|
||||||
|
character.clothing[0].inventory = backpack
|
||||||
|
character.inventory = Inventory(6, 5)
|
||||||
|
character.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0)
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip_preserves_identity_and_species(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.name == "Vex"
|
||||||
|
assert loaded.species_id == "reptilian_quad"
|
||||||
|
assert loaded.gender == "none"
|
||||||
|
assert loaded.blood_percentage == pytest.approx(88.0)
|
||||||
|
assert loaded.strength == 14
|
||||||
|
assert loaded.position is None # portable template, not tied to any world
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_body_parts_ailments_and_organs(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
|
||||||
|
torso = loaded.get_body_part("torso")
|
||||||
|
assert torso.part_def_id == "reptilian_torso"
|
||||||
|
assert torso.integrity == pytest.approx(70.0)
|
||||||
|
assert [a.id for a in torso.ailments] == ["poisoned"]
|
||||||
|
assert torso.ailments[0].progress == pytest.approx(12.0)
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "reptilian_heart")
|
||||||
|
assert heart.integrity == pytest.approx(60.0)
|
||||||
|
|
||||||
|
# default (non-overridden) parts and their organs came back too
|
||||||
|
head = loaded.get_body_part("head")
|
||||||
|
assert head.part_def_id == "reptilian_head"
|
||||||
|
assert {o.organ_def_id for o in head.organs} == {"reptilian_brain"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_clothing_and_its_granted_inventory(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
|
||||||
|
backpack = loaded.get_clothing("torso")
|
||||||
|
assert backpack.item_def_id == "backpack_basic"
|
||||||
|
assert backpack.inventory is not None
|
||||||
|
assert {p.item.item_id for p in backpack.inventory.items()} == {"rope-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_held_items_and_attachments(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
|
||||||
|
held = loaded.get_held_item("right_hand")
|
||||||
|
assert held.item_def_id == "chronowand"
|
||||||
|
assert held.attachments == ["scope_basic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_implants(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
assert loaded.implants == ["chronoimplant"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_bio_and_mental_stats(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
assert loaded.bio["stealth"] == 5
|
||||||
|
assert loaded.mental_stats == {"insanity": 4.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_preserves_top_level_inventory(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
placed = {p.item.item_id: (p.x, p.y) for p in loaded.inventory.items()}
|
||||||
|
assert placed == {"staff-1": (0, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_character_returns_none_when_not_found(registry, library):
|
||||||
|
assert library.load_character("account-1", "no-such-char", registry) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_characters_returns_id_name_species_for_an_account(registry, library):
|
||||||
|
library.save_character("account-1", "char-1", build_character(registry))
|
||||||
|
other = build_character(registry)
|
||||||
|
other.name = "Kess"
|
||||||
|
library.save_character("account-1", "char-2", other)
|
||||||
|
library.save_character("account-2", "char-3", build_character(registry)) # different account
|
||||||
|
|
||||||
|
listed = library.list_characters("account-1")
|
||||||
|
assert {(cid, name) for cid, name, species in listed if species} == {("char-1", "Vex"), ("char-2", "Kess")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_character_removes_it_and_leaves_others(registry, library):
|
||||||
|
library.save_character("account-1", "char-1", build_character(registry))
|
||||||
|
library.save_character("account-1", "char-2", build_character(registry))
|
||||||
|
|
||||||
|
library.delete_character("account-1", "char-1")
|
||||||
|
|
||||||
|
assert library.load_character("account-1", "char-1", registry) is None
|
||||||
|
assert library.load_character("account-1", "char-2", registry) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_twice_under_the_same_id_overwrites_not_duplicates(registry, library):
|
||||||
|
character = build_character(registry)
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
character.blood_percentage = 50.0
|
||||||
|
library.save_character("account-1", "char-1", character)
|
||||||
|
|
||||||
|
loaded = library.load_character("account-1", "char-1", registry)
|
||||||
|
assert loaded.blood_percentage == pytest.approx(50.0)
|
||||||
|
assert len(library.list_characters("account-1")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_character_id_isolated_between_accounts(registry, library):
|
||||||
|
a = build_character(registry)
|
||||||
|
a.name = "Account A's Vex"
|
||||||
|
b = build_character(registry)
|
||||||
|
b.name = "Account B's Vex"
|
||||||
|
library.save_character("account-a", "char-1", a)
|
||||||
|
library.save_character("account-b", "char-1", b)
|
||||||
|
|
||||||
|
assert library.load_character("account-a", "char-1", registry).name == "Account A's Vex"
|
||||||
|
assert library.load_character("account-b", "char-1", registry).name == "Account B's Vex"
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character, ClothingPiece
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.ui import CharacterMenu
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_to_the_bio_tab():
|
||||||
|
menu = CharacterMenu()
|
||||||
|
assert menu.active_tab == "bio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_tab_switches_to_equipment():
|
||||||
|
menu = CharacterMenu()
|
||||||
|
menu.select_tab("equipment")
|
||||||
|
assert menu.active_tab == "equipment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_tab_ignores_unknown_tab_names():
|
||||||
|
menu = CharacterMenu()
|
||||||
|
menu.select_tab("inventory") # not a real tab
|
||||||
|
assert menu.active_tab == "bio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_tabs_closes_any_open_backpack_popup(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
|
||||||
|
menu.right_click_equipment_slot("back", character, registry)
|
||||||
|
assert menu.open_backpack_slot == "back"
|
||||||
|
menu.select_tab("bio")
|
||||||
|
assert menu.open_backpack_slot is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_click_backpack_opens_popup(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
|
||||||
|
opened = menu.right_click_equipment_slot("back", character, registry)
|
||||||
|
assert opened is True
|
||||||
|
assert menu.open_backpack_slot == "back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_click_same_slot_again_closes_the_popup(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
|
||||||
|
menu.right_click_equipment_slot("back", character, registry)
|
||||||
|
closed = menu.right_click_equipment_slot("back", character, registry)
|
||||||
|
assert closed is False
|
||||||
|
assert menu.open_backpack_slot is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_click_a_different_backpack_slot_switches_the_popup(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(
|
||||||
|
registry,
|
||||||
|
clothing=[ClothingPiece("back", "backpack_basic"), ClothingPiece("torso", "leather_jacket")],
|
||||||
|
)
|
||||||
|
# leather_jacket grants no inventory, so this is really just proving "back" is what opens
|
||||||
|
menu.right_click_equipment_slot("back", character, registry)
|
||||||
|
assert menu.open_backpack_slot == "back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_click_non_inventory_item_does_not_open_a_popup(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
opened = menu.right_click_equipment_slot("torso", character, registry)
|
||||||
|
assert opened is False
|
||||||
|
assert menu.open_backpack_slot is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_click_empty_slot_is_a_no_op(registry):
|
||||||
|
menu = CharacterMenu()
|
||||||
|
character = make_human(registry)
|
||||||
|
opened = menu.right_click_equipment_slot("torso", character, registry)
|
||||||
|
assert opened is False
|
||||||
|
assert menu.open_backpack_slot is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_backpack_popup_clears_it():
|
||||||
|
menu = CharacterMenu(open_backpack_slot="back")
|
||||||
|
menu.close_backpack_popup()
|
||||||
|
assert menu.open_backpack_slot is None
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character, ClothingPiece
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_character(registry, species_id: str, **kwargs) -> Character:
|
||||||
|
return Character(species_id=species_id, default_body_parts=registry.new_default_body_parts(species_id), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- arm_slot_count -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_slot_count_for_two_armed_species(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
assert human.arm_slot_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_slot_count_for_four_armed_species(registry):
|
||||||
|
reptilian = make_character(registry, "reptilian_quad")
|
||||||
|
assert reptilian.arm_slot_count() == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_slot_count_for_armless_species(registry):
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.arm_slot_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_slot_count_ignores_a_missing_arm(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
human.body_parts.append(BodyPart("left_arm", "human_left_arm", removed=True))
|
||||||
|
assert human.arm_slot_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- wear_item: bigger (more sleeves) fits smaller, not vice versa --------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_armed_species_can_wear_a_four_armed_jacket(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
assert human.wear_item("torso", "reptilian_jacket", registry) is True
|
||||||
|
assert human.get_clothing("torso").item_def_id == "reptilian_jacket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_four_armed_species_cannot_wear_a_two_armed_jacket(registry):
|
||||||
|
reptilian = make_character(registry, "reptilian_quad")
|
||||||
|
assert reptilian.wear_item("torso", "leather_jacket", registry) is False
|
||||||
|
assert reptilian.get_clothing("torso") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_four_armed_species_can_wear_a_four_armed_jacket(registry):
|
||||||
|
reptilian = make_character(registry, "reptilian_quad")
|
||||||
|
assert reptilian.wear_item("torso", "reptilian_jacket", registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_armed_species_can_wear_a_two_armed_jacket(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
assert human.wear_item("torso", "leather_jacket", registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_sleeved_item_ignores_arm_count_entirely(registry):
|
||||||
|
# dog_saddlebag has no arm_slots_required set (0 = unconstrained) - arm count is
|
||||||
|
# irrelevant for a non-sleeved item, regardless of body type
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.wear_item("back", "dog_saddlebag", registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- wear_item: body_type gate (a dog can't wear human clothes, full stop) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dog_cannot_wear_a_human_jacket(registry):
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.wear_item("torso", "leather_jacket", registry) is False
|
||||||
|
assert dog.get_clothing("torso") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dog_cannot_wear_a_four_armed_jacket_either(registry):
|
||||||
|
# arm count would technically fit (0 <= 4) but body_type ("quadruped" vs "biped") rejects
|
||||||
|
# it outright - a completely different anatomy, not just a sleeve-count mismatch
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.wear_item("torso", "reptilian_jacket", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dog_cannot_wear_a_human_backpack(registry):
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.wear_item("back", "backpack_basic", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dog_can_wear_dog_equipment(registry):
|
||||||
|
dog = make_character(registry, "dog")
|
||||||
|
assert dog.wear_item("torso", "dog_harness", registry) is True
|
||||||
|
assert dog.wear_item("back", "dog_saddlebag", registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_biped_species_cannot_wear_dog_equipment(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
reptilian = make_character(registry, "reptilian_quad")
|
||||||
|
assert human.wear_item("torso", "dog_harness", registry) is False
|
||||||
|
assert reptilian.wear_item("back", "dog_saddlebag", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reptilian_and_zenari_share_biped_body_type_and_can_wear_each_others_jackets(registry):
|
||||||
|
zenari = make_character(registry, "alien_tri")
|
||||||
|
assert zenari.wear_item("torso", "reptilian_jacket", registry) is True # both bipeds, fits (2 <= 4)
|
||||||
|
|
||||||
|
|
||||||
|
# --- wear_item: slot / replacement semantics -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wear_item_rejects_item_meant_for_a_different_slot(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
assert human.wear_item("back", "leather_jacket", registry) is False # leather_jacket is "torso"
|
||||||
|
assert human.get_clothing("back") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wear_item_replaces_whatever_was_in_that_slot(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
human.wear_item("torso", "leather_jacket", registry)
|
||||||
|
human.wear_item("torso", "reptilian_jacket", registry)
|
||||||
|
assert human.get_clothing("torso").item_def_id == "reptilian_jacket"
|
||||||
|
assert len([c for c in human.clothing if c.slot == "torso"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_wear_item_does_not_replace_on_failed_fit_check(registry):
|
||||||
|
reptilian = make_character(registry, "reptilian_quad")
|
||||||
|
reptilian.wear_item("torso", "reptilian_jacket", registry)
|
||||||
|
assert reptilian.wear_item("torso", "leather_jacket", registry) is False
|
||||||
|
assert reptilian.get_clothing("torso").item_def_id == "reptilian_jacket" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_off_clears_a_slot(registry):
|
||||||
|
human = make_character(registry, "human")
|
||||||
|
human.wear_item("torso", "leather_jacket", registry)
|
||||||
|
human.take_off("torso")
|
||||||
|
assert human.get_clothing("torso") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_off_reveals_a_default_garment_underneath(registry):
|
||||||
|
human = make_character(registry, "human", default_clothing=[ClothingPiece("torso", "leather_jacket")])
|
||||||
|
human.wear_item("torso", "reptilian_jacket", registry)
|
||||||
|
assert human.get_clothing("torso").item_def_id == "reptilian_jacket"
|
||||||
|
human.take_off("torso")
|
||||||
|
assert human.get_clothing("torso").item_def_id == "leather_jacket" # fell back to the default
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.combat import BASE_DIFFICULTY, apply_body_part_damage, choose_hit_slot, perform_attack
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
"""Deterministic stand-in for random.Random: fixed die roll, fixed choice."""
|
||||||
|
|
||||||
|
def __init__(self, roll: int, choice_value=None):
|
||||||
|
self._roll = roll
|
||||||
|
self._choice_value = choice_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_roll_and_skill_hits_and_damages_a_body_part(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=15, choice_value="torso"))
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.skill_id == "athletics"
|
||||||
|
assert result.damage > 0
|
||||||
|
assert result.target_slot is not None
|
||||||
|
hit_part = defender.get_body_part(result.target_slot)
|
||||||
|
assert hit_part.integrity == pytest.approx(100.0 - result.damage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_roll_and_no_skill_misses(registry):
|
||||||
|
attacker = make_human(registry) # bio athletics defaults to 0
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=1))
|
||||||
|
assert result.hit is False
|
||||||
|
assert result.damage == 0.0
|
||||||
|
assert result.target_slot is None
|
||||||
|
# nothing was damaged
|
||||||
|
assert defender.get_body_part("torso").integrity == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weapon_bonus_increases_damage_on_hit(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 5
|
||||||
|
defender = make_human(registry)
|
||||||
|
weapon = registry.get_item_def("staff_oak")
|
||||||
|
|
||||||
|
unarmed = perform_attack(attacker, defender, registry, rng=FixedRng(roll=12, choice_value="torso"))
|
||||||
|
defender2 = make_human(registry)
|
||||||
|
armed = perform_attack(attacker, defender2, registry, weapon_def=weapon, rng=FixedRng(roll=12, choice_value="torso"))
|
||||||
|
|
||||||
|
assert armed.damage > unarmed.damage
|
||||||
|
assert armed.weapon_bonus == pytest.approx(weapon.weapon_damage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_penalty_from_missing_arm_reduces_effective_skill(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
attacker.body_parts = [BodyPart("left_arm", "human_left_arm", removed=True)]
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=10, choice_value="torso"))
|
||||||
|
assert result.effective_skill < 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_body_part_damage_marks_removed_at_zero_integrity(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
apply_body_part_damage(character, "left_arm", 100.0)
|
||||||
|
part = next(p for p in character.body_parts if p.slot == "left_arm")
|
||||||
|
assert part.integrity == 0.0
|
||||||
|
assert part.removed is True
|
||||||
|
assert character.get_body_part("left_arm") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_body_part_damage_on_unknown_slot_returns_none(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert apply_body_part_damage(character, "tail", 10.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_difficulty_is_reasonable_default():
|
||||||
|
assert BASE_DIFFICULTY == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_hit_slot_low_precision_hits_easiest_target(registry):
|
||||||
|
defender = make_human(registry)
|
||||||
|
assert choose_hit_slot(defender, precision_check=0) == "torso"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_hit_slot_high_precision_reaches_the_hardest_target(registry):
|
||||||
|
defender = make_human(registry)
|
||||||
|
assert choose_hit_slot(defender, precision_check=1000) == "head"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_hit_slot_skips_missing_body_parts(registry):
|
||||||
|
defender = make_human(registry, body_parts=[BodyPart("head", "human_head", removed=True)])
|
||||||
|
# even at max precision, a missing head can never be the target
|
||||||
|
assert choose_hit_slot(defender, precision_check=1000) != "head"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_hit_slot_none_when_no_body_parts_at_all(registry):
|
||||||
|
defender = Character()
|
||||||
|
assert choose_hit_slot(defender, precision_check=50) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_higher_precision_check_reaches_further_down_the_target_list(registry):
|
||||||
|
defender = make_human(registry)
|
||||||
|
low = choose_hit_slot(defender, precision_check=0)
|
||||||
|
high = choose_hit_slot(defender, precision_check=20)
|
||||||
|
from resources.logic.combat import BODY_PART_DIFFICULTY_ORDER
|
||||||
|
|
||||||
|
assert BODY_PART_DIFFICULTY_ORDER.index(high) >= BODY_PART_DIFFICULTY_ORDER.index(low)
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.companion import open_companion_inventory
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_companion(registry, entity_def_id, position) -> Character:
|
||||||
|
entity_def = registry.get_entity_def(entity_def_id)
|
||||||
|
dog = Character(
|
||||||
|
name=entity_def.name,
|
||||||
|
def_id=entity_def.id,
|
||||||
|
position=position,
|
||||||
|
species_id=entity_def.species_id,
|
||||||
|
gender="male",
|
||||||
|
default_body_parts=registry.new_default_body_parts(entity_def.species_id),
|
||||||
|
default_clothing=registry.new_default_clothing(entity_def.id),
|
||||||
|
)
|
||||||
|
return dog
|
||||||
|
|
||||||
|
|
||||||
|
def test_companion_dog_entity_wears_harness_and_saddlebag(registry):
|
||||||
|
dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 5, 5, 0))
|
||||||
|
assert dog.get_clothing("torso").item_def_id == "dog_harness"
|
||||||
|
assert dog.get_clothing("back").item_def_id == "dog_saddlebag"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_companion_inventory_returns_the_saddlebag(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(player)
|
||||||
|
world.add_entity(dog)
|
||||||
|
|
||||||
|
inventory = open_companion_inventory(player, world, registry, (1, 0, 0), "back")
|
||||||
|
assert inventory is not None
|
||||||
|
assert (inventory.width, inventory.height) == (3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_placed_in_the_opened_inventory_persist_on_the_dog(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(player)
|
||||||
|
world.add_entity(dog)
|
||||||
|
|
||||||
|
inventory = open_companion_inventory(player, world, registry, (1, 0, 0), "back")
|
||||||
|
inventory.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0)
|
||||||
|
|
||||||
|
# re-fetching via the dog's own gear (not the helper) sees the same items - same object
|
||||||
|
assert dog.get_equipped_inventory("back", registry) is inventory
|
||||||
|
assert {p.item.item_id for p in dog.get_equipped_inventory("back", registry).items()} == {"rope-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_companion_inventory_none_when_not_adjacent(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 8, 8, 0)) # far away
|
||||||
|
world.add_entity(player)
|
||||||
|
world.add_entity(dog)
|
||||||
|
|
||||||
|
assert open_companion_inventory(player, world, registry, (1, 0, 0), "back") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_companion_inventory_none_for_empty_tile(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(player)
|
||||||
|
assert open_companion_inventory(player, world, registry, (1, 0, 0), "back") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_companion_inventory_none_targeting_self(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(player)
|
||||||
|
# aim_direction (0,0,0) means "ahead" is the actor's own tile - entity_at finds the actor
|
||||||
|
# themself there, which the target-is-actor check must reject
|
||||||
|
assert open_companion_inventory(player, world, registry, (0, 0, 0), "back") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_companion_inventory_none_for_slot_with_nothing_worn(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
position=EntityPosition("field", 5, 5, 0))
|
||||||
|
dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(player)
|
||||||
|
world.add_entity(dog)
|
||||||
|
|
||||||
|
assert open_companion_inventory(player, world, registry, (1, 0, 0), "head") is None
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine.config import DEFAULT_KEYBINDINGS, KeyBindings
|
||||||
|
|
||||||
|
CONF_PATH = Path(__file__).resolve().parent.parent / "config" / "keybindings.conf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_keybindings_used_when_file_missing(tmp_path):
|
||||||
|
bindings = KeyBindings.load(tmp_path / "does_not_exist.conf")
|
||||||
|
assert bindings.action_for_key("w") == "move_up"
|
||||||
|
assert bindings.action_for_key("ArrowUp") == "move_up"
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_alias_normalizes_to_the_real_key_value():
|
||||||
|
bindings = KeyBindings.load(Path("/nonexistent"))
|
||||||
|
assert bindings.action_for_key(" ") == "leap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_keys_share_one_action(tmp_path):
|
||||||
|
conf = tmp_path / "keybindings.conf"
|
||||||
|
conf.write_text("[movement]\nmove_up = w, k, ArrowUp\n")
|
||||||
|
bindings = KeyBindings.load(conf)
|
||||||
|
assert bindings.action_for_key("w") == "move_up"
|
||||||
|
assert bindings.action_for_key("k") == "move_up"
|
||||||
|
assert bindings.action_for_key("ArrowUp") == "move_up"
|
||||||
|
assert bindings.keys_for_action("move_up") == ["w", "k", "ArrowUp"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_overrides_only_the_actions_it_mentions(tmp_path):
|
||||||
|
conf = tmp_path / "keybindings.conf"
|
||||||
|
conf.write_text("[actions]\nleap = j\n")
|
||||||
|
bindings = KeyBindings.load(conf)
|
||||||
|
assert bindings.action_for_key("j") == "leap"
|
||||||
|
assert bindings.action_for_key(" ") is None # default "space" binding was replaced, not merged
|
||||||
|
assert bindings.action_for_key("w") == "move_up" # untouched defaults still present
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_key_returns_none():
|
||||||
|
bindings = KeyBindings.load(Path("/nonexistent"))
|
||||||
|
assert bindings.action_for_key("q") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_project_keybindings_conf_loads_and_covers_expected_actions():
|
||||||
|
bindings = KeyBindings.load(CONF_PATH)
|
||||||
|
for action in DEFAULT_KEYBINDINGS:
|
||||||
|
assert bindings.keys_for_action(action), f"missing action: {action}"
|
||||||
|
assert bindings.action_for_key("mouse_left") == "activate_right_hand"
|
||||||
|
assert bindings.action_for_key("shift+mouse_left") == "activate_right_hand_2"
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.ui import LayerPickerMenu
|
||||||
|
from resources.logic.construction import (
|
||||||
|
construct_tile_layer,
|
||||||
|
damage_tile_layer,
|
||||||
|
deconstruct_tile_layer,
|
||||||
|
destroy_tile_layer,
|
||||||
|
present_layers,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_walled_tile() -> Tile:
|
||||||
|
return Tile(
|
||||||
|
subfloor=TileLayerInstance("dirt"),
|
||||||
|
flooring=TileLayerInstance("wood_deck"),
|
||||||
|
room=TileLayerInstance("wood_wall"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_present_layers_lists_only_occupied_layers():
|
||||||
|
tile = make_walled_tile()
|
||||||
|
assert present_layers(tile) == ["subfloor", "flooring", "room"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroy_clears_layer_with_no_item_yielded():
|
||||||
|
tile = make_walled_tile()
|
||||||
|
assert destroy_tile_layer(tile, "room") is True
|
||||||
|
assert tile.room is None
|
||||||
|
assert present_layers(tile) == ["subfloor", "flooring"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroy_on_empty_layer_is_a_no_op():
|
||||||
|
tile = make_walled_tile()
|
||||||
|
assert destroy_tile_layer(tile, "roof") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstruct_clears_layer_and_yields_its_item(registry):
|
||||||
|
tile = make_walled_tile()
|
||||||
|
item = deconstruct_tile_layer(tile, "room", registry)
|
||||||
|
assert tile.room is None
|
||||||
|
assert item is not None
|
||||||
|
assert item.def_id == "wood_planks"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstruct_layer_without_yield_returns_none(registry):
|
||||||
|
tile = make_walled_tile()
|
||||||
|
item = deconstruct_tile_layer(tile, "flooring", registry) # wood_deck has no deconstruct_item_id
|
||||||
|
assert tile.flooring is None
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_damage_tile_layer_reduces_integrity_and_clears_at_zero():
|
||||||
|
tile = make_walled_tile()
|
||||||
|
assert damage_tile_layer(tile, "room", 40.0) is False
|
||||||
|
assert tile.room.integrity == pytest.approx(60.0)
|
||||||
|
assert damage_tile_layer(tile, "room", 60.0) is True
|
||||||
|
assert tile.room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_construct_tile_layer_consumes_item_and_places_layer(registry):
|
||||||
|
tile = Tile(subfloor=TileLayerInstance("dirt"), flooring=TileLayerInstance("wood_deck"))
|
||||||
|
inventory = Inventory(4, 4)
|
||||||
|
planks_def = registry.get_item_def("wood_planks")
|
||||||
|
inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0)
|
||||||
|
|
||||||
|
assert construct_tile_layer(tile, "room", planks_def, inventory, "planks-1") is True
|
||||||
|
assert tile.room.def_id == "wood_wall"
|
||||||
|
assert inventory.items() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_construct_fails_if_layer_already_occupied(registry):
|
||||||
|
tile = make_walled_tile() # room already has a wall
|
||||||
|
inventory = Inventory(4, 4)
|
||||||
|
planks_def = registry.get_item_def("wood_planks")
|
||||||
|
inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0)
|
||||||
|
|
||||||
|
assert construct_tile_layer(tile, "room", planks_def, inventory, "planks-1") is False
|
||||||
|
assert {p.item.item_id for p in inventory.items()} == {"planks-1"} # item not consumed
|
||||||
|
|
||||||
|
|
||||||
|
def test_construct_fails_if_item_does_not_build_this_layer(registry):
|
||||||
|
tile = Tile()
|
||||||
|
inventory = Inventory(4, 4)
|
||||||
|
sandwich_def = registry.get_item_def("sandwich")
|
||||||
|
inventory.place(ItemInstance("sandwich-1", "sandwich"), sandwich_def, 0, 0)
|
||||||
|
|
||||||
|
assert construct_tile_layer(tile, "room", sandwich_def, inventory, "sandwich-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_picker_menu_cycle_wraps_and_confirm_returns_selected():
|
||||||
|
menu = LayerPickerMenu(map_id="ship", x=1, y=1, z=0, options=["subfloor", "flooring", "room"])
|
||||||
|
assert menu.confirm() == "subfloor"
|
||||||
|
menu.cycle(1)
|
||||||
|
assert menu.confirm() == "flooring"
|
||||||
|
menu.cycle(1)
|
||||||
|
menu.cycle(1)
|
||||||
|
assert menu.confirm() == "subfloor" # wrapped around
|
||||||
|
menu.cycle(-1)
|
||||||
|
assert menu.confirm() == "room"
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_picker_menu_with_no_options_confirms_none():
|
||||||
|
menu = LayerPickerMenu(map_id="ship", x=1, y=1, z=0, options=[])
|
||||||
|
assert menu.confirm() is None
|
||||||
|
menu.cycle(1) # no-op, doesn't crash
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerDef, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def get_tile_layer_def(self, layer, def_id):
|
||||||
|
if layer == "room" and def_id == "wood_wall":
|
||||||
|
return TileLayerDef(id="wood_wall", layer="room", walkable=False, blocks_los=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_world():
|
||||||
|
parent = Map("world", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
|
||||||
|
ship = Map("ship", 4, 3, 1)
|
||||||
|
for x in range(4):
|
||||||
|
for y in range(3):
|
||||||
|
ship.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck")))
|
||||||
|
for x, y in [(0, 0), (1, 0), (3, 0), (0, 1), (3, 1), (0, 2), (1, 2), (2, 2), (3, 2)]:
|
||||||
|
ship.get_tile(x, y, 0).room = TileLayerInstance("wood_wall") # (2,0) is the open doorway
|
||||||
|
|
||||||
|
parent.embed(ship, (6, 6, 0))
|
||||||
|
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": ship}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
return world, parent, ship
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_lands_at_a_fractional_position():
|
||||||
|
world, _parent, _ship = make_world()
|
||||||
|
player = Entity(position=EntityPosition("world", 2.0, 2.0, 0))
|
||||||
|
assert world.try_move_continuous(player, 1, 0, 0, distance=0.35) is True
|
||||||
|
assert player.position.x == pytest.approx(2.35)
|
||||||
|
assert player.position.y == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_normalizes_diagonal_direction():
|
||||||
|
world, _parent, _ship = make_world()
|
||||||
|
player = Entity(position=EntityPosition("world", 2.0, 2.0, 0))
|
||||||
|
assert world.try_move_continuous(player, 1, 1, 0, distance=1.0) is True
|
||||||
|
# moved exactly 1 unit total, split evenly between x and y (not 1 in each axis)
|
||||||
|
import math
|
||||||
|
|
||||||
|
dx = player.position.x - 2.0
|
||||||
|
dy = player.position.y - 2.0
|
||||||
|
assert math.hypot(dx, dy) == pytest.approx(1.0)
|
||||||
|
assert dx == pytest.approx(dy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_blocked_by_wall_leaves_position_unchanged():
|
||||||
|
world, _parent, ship = make_world()
|
||||||
|
# ship-local (0,0) is a wood_wall; approach it from inside at (1.2,1.2) moving toward it
|
||||||
|
player = Entity(position=EntityPosition("ship", 1.2, 1.2, 0))
|
||||||
|
assert world.try_move_continuous(player, -1, -1, 0, distance=1.5) is False
|
||||||
|
assert player.position.x == pytest.approx(1.2)
|
||||||
|
assert player.position.y == pytest.approx(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_crosses_into_embedded_ship_at_fractional_local_position():
|
||||||
|
world, _parent, ship = make_world()
|
||||||
|
# doorway column is parent x=8 (ship-local x=2); approach from just north of it
|
||||||
|
player = Entity(position=EntityPosition("world", 8.3, 5.6, 0))
|
||||||
|
assert world.try_move_continuous(player, 0, 1, 0, distance=0.6) is True
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
assert player.position.x == pytest.approx(2.3)
|
||||||
|
assert player.position.y == pytest.approx(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_disembarks_at_fractional_parent_position():
|
||||||
|
world, _parent, ship = make_world()
|
||||||
|
player = Entity(position=EntityPosition("ship", 2.3, 0.2, 0))
|
||||||
|
assert world.try_move_continuous(player, 0, -1, 0, distance=0.6) is True
|
||||||
|
assert player.position.map_id == "world"
|
||||||
|
assert player.position.x == pytest.approx(8.3)
|
||||||
|
assert player.position.y == pytest.approx(5.6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_at_matches_by_tile_membership_for_fractional_positions():
|
||||||
|
world, _parent, _ship = make_world()
|
||||||
|
entity = Entity(position=EntityPosition("world", 3.7, 4.2, 0))
|
||||||
|
world.add_entity(entity)
|
||||||
|
assert world.entity_at("world", 3, 4, 0) is entity
|
||||||
|
assert world.entity_at("world", 4, 4, 0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_position_round_trips_through_a_rotated_embedding():
|
||||||
|
world, _parent, ship = make_world()
|
||||||
|
embedding = ship.parent_embedding
|
||||||
|
embedding.rotate_to(1)
|
||||||
|
|
||||||
|
for lx, ly in [(0.5, 0.5), (2.7, 1.1), (3.99, 0.01)]:
|
||||||
|
px, py, pz = embedding.local_to_parent_pos(lx, ly, 0)
|
||||||
|
rx, ry, rz = embedding.parent_to_local_pos(px, py, pz)
|
||||||
|
assert rx == pytest.approx(lx)
|
||||||
|
assert ry == pytest.approx(ly)
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_move_crosses_into_rotated_embedded_ship():
|
||||||
|
world, _parent, ship = make_world()
|
||||||
|
embedding = ship.parent_embedding
|
||||||
|
embedding.rotate_to(1) # 90 deg CW: 4x3 footprint becomes 3x4 in parent space
|
||||||
|
|
||||||
|
# find where the doorway (ship-local 2,0) lands post-rotation, approach from just outside it
|
||||||
|
door_parent = embedding.local_to_parent(2, 0, 0)
|
||||||
|
start_x = door_parent[0] + 0.5 - 1 # one tile back along x, offset into the tile
|
||||||
|
start_y = door_parent[1] + 0.5
|
||||||
|
player = Entity(position=EntityPosition("world", start_x, start_y, 0))
|
||||||
|
assert world.try_move_continuous(player, 1, 0, 0, distance=1.0) is True
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character, ClothingPiece
|
||||||
|
from engine.db import WorldRepository
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def build_world(registry) -> World:
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
demo_world = loader.load("demo_world.json")
|
||||||
|
ship = demo_world.embeddings[0].child_map
|
||||||
|
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(demo_world)
|
||||||
|
world.add_map(ship)
|
||||||
|
|
||||||
|
player = Character(
|
||||||
|
entity_id="player-1",
|
||||||
|
name="Player",
|
||||||
|
def_id="player",
|
||||||
|
position=EntityPosition("demo_world", 7, 5, 0),
|
||||||
|
species_id="human",
|
||||||
|
gender="nonbinary",
|
||||||
|
blood_percentage=82.5,
|
||||||
|
body_parts=[
|
||||||
|
BodyPart("head", "human_head_hat"),
|
||||||
|
BodyPart("left_leg", "human_left_leg", integrity=40.0, ailments=[Ailment("frostbite", "Frostbite", 0.5)]),
|
||||||
|
],
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
clothing=[ClothingPiece("torso", "leather_jacket")],
|
||||||
|
default_clothing=registry.new_default_clothing("player"),
|
||||||
|
mental_stats={"insanity": 12.5},
|
||||||
|
)
|
||||||
|
player.bio["stealth"] = 3
|
||||||
|
backpack_inventory = player.get_equipped_inventory("back", registry)
|
||||||
|
backpack_inventory.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0)
|
||||||
|
player.inventory = Inventory(6, 5)
|
||||||
|
player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0)
|
||||||
|
player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0)
|
||||||
|
|
||||||
|
world.add_entity(player)
|
||||||
|
world.player = player
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip_preserves_entity_and_inventory(registry):
|
||||||
|
world = build_world(registry)
|
||||||
|
repo = WorldRepository(sqlite3.connect(":memory:"))
|
||||||
|
repo.save_world(world)
|
||||||
|
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
reloaded = repo.load_world(registry, loader)
|
||||||
|
|
||||||
|
assert reloaded is not None
|
||||||
|
assert reloaded.player is not None
|
||||||
|
assert reloaded.player.entity_id == "player-1"
|
||||||
|
assert (reloaded.player.position.map_id, reloaded.player.position.x, reloaded.player.position.y) == (
|
||||||
|
"demo_world",
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(reloaded.player, Character)
|
||||||
|
assert reloaded.player.get_body_part("head").part_def_id == "human_head_hat"
|
||||||
|
assert reloaded.player.get_body_part("torso").part_def_id == "human_torso"
|
||||||
|
|
||||||
|
placed = {p.item.item_id: (p.x, p.y) for p in reloaded.player.inventory.items()}
|
||||||
|
assert placed == {"staff-1": (0, 0), "sandwich-1": (1, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_roundtrip_preserves_health_fields(registry):
|
||||||
|
world = build_world(registry)
|
||||||
|
repo = WorldRepository(sqlite3.connect(":memory:"))
|
||||||
|
repo.save_world(world)
|
||||||
|
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
reloaded = repo.load_world(registry, loader)
|
||||||
|
player = reloaded.player
|
||||||
|
|
||||||
|
assert player.species_id == "human"
|
||||||
|
assert player.gender == "nonbinary"
|
||||||
|
assert player.blood_percentage == pytest.approx(82.5)
|
||||||
|
assert player.mental_stats == {"insanity": 12.5}
|
||||||
|
assert player.bio["stealth"] == 3
|
||||||
|
assert player.bio["athletics"] == 0
|
||||||
|
|
||||||
|
left_leg = player.get_body_part("left_leg")
|
||||||
|
assert left_leg.integrity == pytest.approx(40.0)
|
||||||
|
assert [a.id for a in left_leg.ailments] == ["frostbite"]
|
||||||
|
assert left_leg.ailments[0].severity == pytest.approx(0.5)
|
||||||
|
|
||||||
|
assert player.get_clothing("torso").item_def_id == "leather_jacket"
|
||||||
|
backpack = player.get_clothing("back")
|
||||||
|
assert backpack.item_def_id == "backpack_basic"
|
||||||
|
assert backpack.inventory is not None
|
||||||
|
assert (backpack.inventory.width, backpack.inventory.height) == (4, 4)
|
||||||
|
assert {p.item.item_id for p in backpack.inventory.items()} == {"rope-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_world_returns_none_when_no_save_exists(registry):
|
||||||
|
repo = WorldRepository(sqlite3.connect(":memory:"))
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
assert repo.load_world(registry, loader) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_tile_edits_survive_save_and_load(registry):
|
||||||
|
world = build_world(registry)
|
||||||
|
demo_world = world.maps["demo_world"]
|
||||||
|
|
||||||
|
# simulate base-building: knock a wall down on the ship, damage a flooring tile elsewhere
|
||||||
|
ship = world.maps["ship_small"]
|
||||||
|
ship.get_tile(0, 0, 0).room = None
|
||||||
|
demo_world.get_tile(3, 3, 0).flooring.integrity = 42.0
|
||||||
|
|
||||||
|
repo = WorldRepository(sqlite3.connect(":memory:"))
|
||||||
|
repo.save_world(world)
|
||||||
|
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
reloaded = repo.load_world(registry, loader)
|
||||||
|
|
||||||
|
reloaded_ship = reloaded.maps["ship_small"]
|
||||||
|
assert reloaded_ship.get_tile(0, 0, 0).room is None
|
||||||
|
|
||||||
|
reloaded_demo = reloaded.maps["demo_world"]
|
||||||
|
edited_tile = reloaded_demo.get_tile(3, 3, 0)
|
||||||
|
assert edited_tile.flooring.def_id == "grass"
|
||||||
|
assert edited_tile.flooring.integrity == pytest.approx(42.0)
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tile_layer_defs_load_from_json(registry):
|
||||||
|
grass = registry.get_tile_layer_def("flooring", "grass")
|
||||||
|
assert grass is not None
|
||||||
|
assert grass.walkable is True
|
||||||
|
|
||||||
|
wall = registry.get_tile_layer_def("room", "wood_wall")
|
||||||
|
assert wall is not None
|
||||||
|
assert wall.walkable is False
|
||||||
|
assert wall.blocks_los is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_defs_load_with_correct_footprints(registry):
|
||||||
|
staff = registry.get_item_def("staff_oak")
|
||||||
|
assert (staff.width, staff.height) == (1, 5)
|
||||||
|
|
||||||
|
sandwich = registry.get_item_def("sandwich")
|
||||||
|
assert (sandwich.width, sandwich.height) == (2, 2)
|
||||||
|
assert sandwich.stackable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_def_references_species_and_inventory_size(registry):
|
||||||
|
player = registry.get_entity_def("player")
|
||||||
|
assert player.species_id == "human"
|
||||||
|
assert player.inventory_size == (6, 5)
|
||||||
|
assert [c.item_def_id for c in player.default_clothing] == ["backpack_basic"]
|
||||||
|
assert player.default_clothing[0].slot == "back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_def_clothing_fields(registry):
|
||||||
|
backpack = registry.get_item_def("backpack_basic")
|
||||||
|
assert backpack.clothing_slot == "back"
|
||||||
|
assert backpack.grants_inventory_size == (4, 4)
|
||||||
|
|
||||||
|
staff = registry.get_item_def("staff_oak")
|
||||||
|
assert staff.clothing_slot is None
|
||||||
|
assert staff.grants_inventory_size is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_default_clothing_returns_independent_instances(registry):
|
||||||
|
a = registry.new_default_clothing("player")
|
||||||
|
b = registry.new_default_clothing("player")
|
||||||
|
assert a is not b
|
||||||
|
assert a[0] is not b[0]
|
||||||
|
a[0].removed = True
|
||||||
|
assert b[0].removed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_species_def_resolves_default_body_parts_with_correct_slots(registry):
|
||||||
|
human = registry.get_species_def("human")
|
||||||
|
slots = {bp.slot for bp in human.default_body_parts}
|
||||||
|
assert slots == {"head", "torso", "left_arm", "right_arm", "left_leg", "right_leg"}
|
||||||
|
assert human.genders == ["male", "female", "nonbinary"]
|
||||||
|
assert human.blood_danger_threshold == 50.0
|
||||||
|
assert human.blood_critical_threshold == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_default_body_parts_returns_independent_instances(registry):
|
||||||
|
parts_a = registry.new_default_body_parts("human")
|
||||||
|
parts_b = registry.new_default_body_parts("human")
|
||||||
|
assert parts_a is not parts_b
|
||||||
|
parts_a[0].integrity = 10.0
|
||||||
|
assert parts_b[0].integrity == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_part_def_carries_bleeding_and_skill_weights(registry):
|
||||||
|
head = registry.get_body_part_def("human_head")
|
||||||
|
assert head.bleeding_weight == pytest.approx(0.10)
|
||||||
|
assert head.skill_weights["perception"] == pytest.approx(0.6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_world_loads_with_ship_embedded_at_anchor():
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
world = loader.load("demo_world.json")
|
||||||
|
|
||||||
|
assert (world.width, world.height, world.depth) == (10, 10, 1)
|
||||||
|
assert len(world.embeddings) == 1
|
||||||
|
|
||||||
|
embedding = world.embeddings[0]
|
||||||
|
assert embedding.anchor == (6, 6, 0)
|
||||||
|
assert embedding.child_map.map_id == "ship_small"
|
||||||
|
assert (embedding.child_map.width, embedding.child_map.height) == (4, 3)
|
||||||
|
assert embedding.child_map.parent_embedding is embedding
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_world_tiles_resolve_against_tile_defs(registry):
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
world = loader.load("demo_world.json")
|
||||||
|
|
||||||
|
tile = world.get_tile(0, 0, 0)
|
||||||
|
assert tile.flooring.def_id == "grass"
|
||||||
|
assert tile.is_walkable(registry) is True
|
||||||
|
|
||||||
|
ship = world.embeddings[0].child_map
|
||||||
|
wall_tile = ship.get_tile(0, 0, 0)
|
||||||
|
assert wall_tile.room.def_id == "wood_wall"
|
||||||
|
assert wall_tile.is_walkable(registry) is False
|
||||||
|
|
||||||
|
door_tile = ship.get_tile(2, 0, 0)
|
||||||
|
assert door_tile.room is None
|
||||||
|
assert door_tile.is_walkable(registry) is True
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
from engine.gamepad import (
|
||||||
|
AXIS_LEFT_TRIGGER,
|
||||||
|
AXIS_LEFT_X,
|
||||||
|
AXIS_RIGHT_TRIGGER,
|
||||||
|
DEFAULT_GAMEPAD_BINDINGS,
|
||||||
|
GamepadBindings,
|
||||||
|
GamepadState,
|
||||||
|
apply_deadzone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- apply_deadzone ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadzone_zeroes_small_values():
|
||||||
|
assert apply_deadzone(0.05) == 0.0
|
||||||
|
assert apply_deadzone(-0.1) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadzone_passes_through_large_values():
|
||||||
|
assert apply_deadzone(0.5) == 0.5
|
||||||
|
assert apply_deadzone(-0.9) == -0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadzone_respects_custom_threshold():
|
||||||
|
assert apply_deadzone(0.3, deadzone=0.5) == 0.0
|
||||||
|
assert apply_deadzone(0.6, deadzone=0.5) == 0.6
|
||||||
|
|
||||||
|
|
||||||
|
# --- GamepadState ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_axis_out_of_range_defaults_to_zero():
|
||||||
|
state = GamepadState(axes=[0.1, 0.2])
|
||||||
|
assert state.axis(AXIS_LEFT_X) == 0.1
|
||||||
|
assert state.axis(5) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_digital_buttons_includes_pressed_real_buttons():
|
||||||
|
state = GamepadState(buttons={"gp_a": True, "gp_b": False})
|
||||||
|
assert state.digital_buttons() == {"gp_a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_digital_buttons_thresholds_triggers_into_pseudo_buttons():
|
||||||
|
axes = [0.0] * 6
|
||||||
|
axes[AXIS_LEFT_TRIGGER] = 0.9
|
||||||
|
axes[AXIS_RIGHT_TRIGGER] = 0.1
|
||||||
|
state = GamepadState(axes=axes)
|
||||||
|
assert state.digital_buttons() == {"gp_left_trigger"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_digital_buttons_combines_real_and_trigger_buttons():
|
||||||
|
axes = [0.0] * 6
|
||||||
|
axes[AXIS_RIGHT_TRIGGER] = 1.0
|
||||||
|
state = GamepadState(buttons={"gp_a": True}, axes=axes)
|
||||||
|
assert state.digital_buttons() == {"gp_a", "gp_right_trigger"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- GamepadBindings ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_bindings_resolve_expected_actions():
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
assert bindings.action_for_button("gp_a") == "leap"
|
||||||
|
assert bindings.action_for_button("gp_left_trigger") == "activate_left_hand_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_button_resolves_to_none():
|
||||||
|
bindings = GamepadBindings({"leap": ["gp_a"]})
|
||||||
|
assert bindings.action_for_button("gp_y") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_buttons_for_action_returns_the_configured_list():
|
||||||
|
bindings = GamepadBindings({"leap": ["gp_a", "gp_start"]})
|
||||||
|
assert bindings.buttons_for_action("leap") == ["gp_a", "gp_start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_falls_back_to_defaults_when_file_missing(tmp_path):
|
||||||
|
bindings = GamepadBindings.load(tmp_path / "does_not_exist.conf")
|
||||||
|
assert bindings.action_for_button("gp_a") == "leap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_reads_conf_file_and_overrides_defaults(tmp_path):
|
||||||
|
conf = tmp_path / "controller_bindings.conf"
|
||||||
|
conf.write_text("[actions]\nleap = gp_y\n")
|
||||||
|
bindings = GamepadBindings.load(conf)
|
||||||
|
assert bindings.action_for_button("gp_y") == "leap"
|
||||||
|
assert bindings.action_for_button("gp_a") is None # overridden, not appended
|
||||||
|
# untouched actions still fall back to the defaults
|
||||||
|
assert bindings.action_for_button("gp_left_bumper") == "block"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_reads_the_real_shipped_controller_bindings_conf():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parent.parent / "config" / "controller_bindings.conf"
|
||||||
|
bindings = GamepadBindings.load(path)
|
||||||
|
assert bindings.action_for_button("gp_a") == "leap"
|
||||||
|
assert bindings.action_for_button("gp_dpad_up") == "select_ability_1"
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.health_ticks import apply_bleeding_tick
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_bleeding_when_no_parts_removed(registry):
|
||||||
|
character = Character(default_body_parts=registry.new_default_body_parts("human"))
|
||||||
|
apply_bleeding_tick(character, registry, ticks=5)
|
||||||
|
assert character.blood_percentage == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_part_bleeds_at_its_defs_rate_per_tick(registry):
|
||||||
|
character = Character(
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
body_parts=[BodyPart("left_arm", "human_left_arm", removed=True)],
|
||||||
|
)
|
||||||
|
# human_left_arm bleeding_speed is 2.0/tick
|
||||||
|
apply_bleeding_tick(character, registry, ticks=3)
|
||||||
|
assert character.blood_percentage == pytest.approx(100.0 - 2.0 * 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_removed_parts_stack_bleeding(registry):
|
||||||
|
character = Character(
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
body_parts=[
|
||||||
|
BodyPart("left_leg", "human_left_leg", removed=True), # 3.0/tick
|
||||||
|
BodyPart("right_leg", "human_right_leg", removed=True), # 3.0/tick
|
||||||
|
],
|
||||||
|
)
|
||||||
|
apply_bleeding_tick(character, registry, ticks=2)
|
||||||
|
assert character.blood_percentage == pytest.approx(100.0 - (3.0 + 3.0) * 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blood_percentage_does_not_go_below_zero(registry):
|
||||||
|
character = Character(
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
body_parts=[BodyPart("torso", "human_torso", removed=True)], # 6.0/tick
|
||||||
|
)
|
||||||
|
apply_bleeding_tick(character, registry, ticks=100)
|
||||||
|
assert character.blood_percentage == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_ticks_is_a_no_op(registry):
|
||||||
|
character = Character(
|
||||||
|
default_body_parts=registry.new_default_body_parts("human"),
|
||||||
|
body_parts=[BodyPart("left_arm", "human_left_arm", removed=True)],
|
||||||
|
)
|
||||||
|
apply_bleeding_tick(character, registry, ticks=0)
|
||||||
|
assert character.blood_percentage == pytest.approx(100.0)
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import activate_implant
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 15, 15, 1)
|
||||||
|
for x in range(15):
|
||||||
|
for y in range(15):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
# --- install/uninstall -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_implant_adds_to_character(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.install_implant("chronoimplant", registry) is True
|
||||||
|
assert "chronoimplant" in character.implants
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_implant_rejects_non_implant_item(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.install_implant("chronowand", registry) is False
|
||||||
|
assert character.implants == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_implant_rejects_duplicate(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.install_implant("chronoimplant", registry)
|
||||||
|
assert character.install_implant("chronoimplant", registry) is False
|
||||||
|
assert character.implants == ["chronoimplant"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_implant_removes_it(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.install_implant("chronoimplant", registry)
|
||||||
|
character.uninstall_implant("chronoimplant")
|
||||||
|
assert character.implants == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_implant_no_op_if_not_installed(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.uninstall_implant("chronoimplant") # should not raise
|
||||||
|
assert character.implants == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- activate_implant -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_implant_casts_time_warp_sphere(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
assert world.time_warp_zones == []
|
||||||
|
result = activate_implant(caster, world, registry, "chronoimplant", now=3.0)
|
||||||
|
assert len(world.time_warp_zones) == 1
|
||||||
|
assert result is world.time_warp_zones[0]
|
||||||
|
assert result.expires_at > 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_implant_returns_none_if_not_installed(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
world.add_entity(caster)
|
||||||
|
|
||||||
|
result = activate_implant(caster, world, registry, "chronoimplant", now=3.0)
|
||||||
|
assert result is None
|
||||||
|
assert world.time_warp_zones == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_implant_returns_none_for_non_ability_implant(registry):
|
||||||
|
world = make_world(registry)
|
||||||
|
caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0))
|
||||||
|
caster.install_implant("chronoimplant", registry)
|
||||||
|
world.add_entity(caster)
|
||||||
|
# a hypothetical implant with no grants_ability should be a safe no-op, not a crash
|
||||||
|
object.__setattr__(registry.get_item_def("chronoimplant"), "grants_ability", None)
|
||||||
|
|
||||||
|
result = activate_implant(caster, world, registry, "chronoimplant", now=3.0)
|
||||||
|
assert result is None
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
from engine.config import KeyBindings
|
||||||
|
from engine.gamepad import DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, GamepadState
|
||||||
|
from engine.input import InputState
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_key_sets_pending_move_and_updates_facing():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"})
|
||||||
|
assert input_state.consume_move() == (1, 0, 0)
|
||||||
|
assert input_state.facing == (1, 0, 0)
|
||||||
|
assert input_state.consume_move() is None # consumed
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_key_uses_last_facing_direction():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "w"})
|
||||||
|
input_state.consume_move()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": " "})
|
||||||
|
assert input_state.consume_leap() == (0, -1, 0)
|
||||||
|
assert input_state.consume_leap() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_key_before_any_move_uses_default_facing():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": " "})
|
||||||
|
assert input_state.consume_leap() == (0, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_up_events_are_ignored_for_movement():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_up", "key": "d"})
|
||||||
|
assert input_state.consume_move() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_held_key_contributes_to_current_move_vector():
|
||||||
|
input_state = InputState()
|
||||||
|
assert input_state.current_move_vector() == (0.0, 0.0, 0.0)
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"})
|
||||||
|
assert input_state.current_move_vector() == (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_releasing_a_held_key_removes_its_contribution():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"})
|
||||||
|
input_state.handle_event({"event_type": "key_up", "key": "d"})
|
||||||
|
assert input_state.current_move_vector() == (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_two_keys_combines_into_a_diagonal_vector():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"})
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "s"})
|
||||||
|
assert input_state.current_move_vector() == (1.0, 1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_opposite_held_keys_cancel_out():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"})
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "a"})
|
||||||
|
assert input_state.current_move_vector() == (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_key_is_a_held_state_not_a_one_shot():
|
||||||
|
input_state = InputState()
|
||||||
|
assert input_state.is_blocking is False
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "b"})
|
||||||
|
assert input_state.is_blocking is True
|
||||||
|
input_state.handle_event({"event_type": "key_up", "key": "b"})
|
||||||
|
assert input_state.is_blocking is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_left_click_activates_right_hand():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ()})
|
||||||
|
assert input_state.consume_hand_activation() == "right_hand"
|
||||||
|
assert input_state.consume_hand_activation() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_right_click_activates_left_hand():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "pointer_down", "button": 2, "modifiers": ()})
|
||||||
|
assert input_state.consume_hand_activation() == "left_hand"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_plus_mouse_click_activates_the_second_hand_pair():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ("Shift",)})
|
||||||
|
assert input_state.consume_hand_activation() == "right_hand_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pointer_move_tracks_position():
|
||||||
|
input_state = InputState()
|
||||||
|
assert input_state.pointer_pos is None
|
||||||
|
input_state.handle_event({"event_type": "pointer_move", "x": 12.5, "y": 34.0})
|
||||||
|
assert input_state.pointer_pos == (12.5, 34.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_key_is_ignored():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "q"})
|
||||||
|
assert input_state.consume_move() is None
|
||||||
|
assert input_state.consume_leap() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_keybindings_are_respected():
|
||||||
|
bindings = KeyBindings({"move_up": ["j"]})
|
||||||
|
input_state = InputState(bindings)
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "j"})
|
||||||
|
assert input_state.consume_move() == (0, -1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_numkey_1_selects_ability_bar_slot_0():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "1"})
|
||||||
|
assert input_state.consume_ability_select() == 0
|
||||||
|
assert input_state.consume_ability_select() is None # consumed
|
||||||
|
|
||||||
|
|
||||||
|
def test_numkey_0_selects_ability_bar_slot_9():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "0"})
|
||||||
|
assert input_state.consume_ability_select() == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_ten_numkeys_map_to_distinct_slots():
|
||||||
|
input_state = InputState()
|
||||||
|
seen = set()
|
||||||
|
for key in "1234567890":
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": key})
|
||||||
|
seen.add(input_state.consume_ability_select())
|
||||||
|
assert seen == set(range(10))
|
||||||
|
|
||||||
|
|
||||||
|
# --- gamepad -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_left_stick_drives_current_move_vector():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(axes=[0.8, -0.5, 0, 0, 0, 0]), bindings)
|
||||||
|
assert input_state.current_move_vector() == (0.8, -0.5, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_stick_within_deadzone_contributes_nothing():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(axes=[0.05, -0.05, 0, 0, 0, 0]), bindings)
|
||||||
|
assert input_state.current_move_vector() == (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_stick_adds_to_held_keyboard_movement():
|
||||||
|
input_state = InputState()
|
||||||
|
input_state.handle_event({"event_type": "key_down", "key": "d"}) # move_right: (1, 0, 0)
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(axes=[0.5, 0, 0, 0, 0, 0]), bindings)
|
||||||
|
assert input_state.current_move_vector() == (1.5, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_button_press_triggers_bound_action():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings) # gp_a -> leap
|
||||||
|
assert input_state.consume_leap() == input_state.facing
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_block_is_held_not_one_shot():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_left_bumper": True}), bindings)
|
||||||
|
assert input_state.is_blocking is True
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_left_bumper": False}), bindings)
|
||||||
|
assert input_state.is_blocking is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_trigger_press_activates_bound_hand():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
axes = [0.0] * 6
|
||||||
|
axes[4] = 0.9 # left trigger -> gp_left_trigger -> activate_left_hand_2
|
||||||
|
input_state.apply_gamepad_state(GamepadState(axes=axes), bindings)
|
||||||
|
assert input_state.consume_hand_activation() == "left_hand_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_holding_a_button_across_frames_does_not_refire():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings)
|
||||||
|
input_state.consume_leap()
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings) # still held
|
||||||
|
assert input_state.consume_leap() is None # no new leap queued on the second frame
|
||||||
|
|
||||||
|
|
||||||
|
def test_gamepad_unbound_button_is_ignored():
|
||||||
|
input_state = InputState()
|
||||||
|
bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS))
|
||||||
|
input_state.apply_gamepad_state(GamepadState(buttons={"gp_guide": True}), bindings)
|
||||||
|
assert input_state.consume_leap() is None
|
||||||
|
assert input_state.consume_hand_activation() is None
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.construction import interact_with_tile, wielded_tool
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 5, 5, 1)
|
||||||
|
for x in range(5):
|
||||||
|
for y in range(5):
|
||||||
|
field.set_tile(x, y, 0, Tile(subfloor=TileLayerInstance("dirt"), room=TileLayerInstance("wood_wall")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world, field
|
||||||
|
|
||||||
|
|
||||||
|
def test_wielded_tool_finds_a_held_tool_kind_item(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert wielded_tool(character, registry) is None
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
tool = wielded_tool(character, registry)
|
||||||
|
assert tool is not None
|
||||||
|
assert tool.id == "multi_deconstructor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_interact_without_a_wielded_tool_is_a_no_op(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left")
|
||||||
|
assert result is None
|
||||||
|
assert field.get_tile(2, 2, 0).room is not None # untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstructor_left_click_destroys_with_no_item(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left")
|
||||||
|
assert result is True
|
||||||
|
assert field.get_tile(2, 2, 0).room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstructor_right_click_yields_item_into_inventory(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "right")
|
||||||
|
assert result is not None
|
||||||
|
assert result.def_id == "wood_planks"
|
||||||
|
assert field.get_tile(2, 2, 0).room is None
|
||||||
|
assert {p.item.def_id for p in character.inventory.items()} == {"wood_planks"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deconstructor_right_click_without_room_in_inventory_still_clears_layer(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
character.wield_item("right_hand", "multi_deconstructor", registry)
|
||||||
|
character.inventory = None # no inventory to receive the item
|
||||||
|
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "right")
|
||||||
|
assert result is not None
|
||||||
|
assert field.get_tile(2, 2, 0).room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_consumes_matching_material_and_builds_layer(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(2, 2, 0).room = None # bare slot to build on
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
character.wield_item("right_hand", "constructor_tool", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
planks_def = registry.get_item_def("wood_planks")
|
||||||
|
character.inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0)
|
||||||
|
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left")
|
||||||
|
assert result is True
|
||||||
|
assert field.get_tile(2, 2, 0).room.def_id == "wood_wall"
|
||||||
|
assert character.inventory.items() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_constructor_fails_without_matching_material(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(2, 2, 0).room = None
|
||||||
|
character = make_human(registry, position=EntityPosition("field", 2, 2, 0))
|
||||||
|
character.wield_item("right_hand", "constructor_tool", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
|
||||||
|
result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left")
|
||||||
|
assert result is False
|
||||||
|
assert field.get_tile(2, 2, 0).room is None
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemDef, ItemInstance
|
||||||
|
|
||||||
|
STAFF = ItemDef(id="staff_oak", name="Oak Staff", width=1, height=5)
|
||||||
|
SANDWICH = ItemDef(id="sandwich", name="Sandwich", width=2, height=2, stackable=True, max_stack=4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_place_non_overlapping_items_succeeds():
|
||||||
|
inv = Inventory(6, 5)
|
||||||
|
staff = ItemInstance("item-1", "staff_oak")
|
||||||
|
sandwich = ItemInstance("item-2", "sandwich")
|
||||||
|
|
||||||
|
assert inv.place(staff, STAFF, 0, 0) is True
|
||||||
|
assert inv.place(sandwich, SANDWICH, 1, 0) is True
|
||||||
|
assert {p.item.item_id for p in inv.items()} == {"item-1", "item-2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlapping_placement_rejected():
|
||||||
|
inv = Inventory(6, 5)
|
||||||
|
staff = ItemInstance("item-1", "staff_oak")
|
||||||
|
other = ItemInstance("item-2", "sandwich")
|
||||||
|
|
||||||
|
assert inv.place(staff, STAFF, 0, 0) is True
|
||||||
|
assert inv.can_place(SANDWICH, 0, 2) is False # overlaps rows 2-3 of the staff at x=0
|
||||||
|
assert inv.place(other, SANDWICH, 0, 2) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_bounds_placement_rejected():
|
||||||
|
inv = Inventory(6, 4)
|
||||||
|
assert inv.can_place(STAFF, 0, 0) is False # height 5 doesn't fit in a 4-tall grid at all
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_frees_footprint_for_reuse():
|
||||||
|
inv = Inventory(6, 5)
|
||||||
|
staff = ItemInstance("item-1", "staff_oak")
|
||||||
|
inv.place(staff, STAFF, 0, 0)
|
||||||
|
|
||||||
|
removed = inv.remove("item-1", STAFF)
|
||||||
|
assert removed is staff
|
||||||
|
assert inv.can_place(STAFF, 0, 0) is True
|
||||||
|
assert inv.place(staff, STAFF, 0, 0) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_first_fit_returns_leftmost_topmost_slot():
|
||||||
|
inv = Inventory(6, 5)
|
||||||
|
inv.place(ItemInstance("item-1", "staff_oak"), STAFF, 0, 0)
|
||||||
|
|
||||||
|
slot = inv.find_first_fit(SANDWICH)
|
||||||
|
assert slot == (1, 0)
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.actions import activate_hand
|
||||||
|
from resources.logic.knockback import (
|
||||||
|
apply_knockback,
|
||||||
|
apply_recoil,
|
||||||
|
direction_between,
|
||||||
|
melee_knockback_distance,
|
||||||
|
resolve_explosion_knockback,
|
||||||
|
resolve_melee_knockback,
|
||||||
|
resolve_ranged_knockback,
|
||||||
|
)
|
||||||
|
from resources.logic.ranged_combat import fire_held_weapon
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 15, 15, 1)
|
||||||
|
for x in range(15):
|
||||||
|
for y in range(15):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world, field
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
def __init__(self, roll=15, choice_value="torso"):
|
||||||
|
self._roll = roll
|
||||||
|
self._choice_value = choice_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
def uniform(self, _a, _b):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- primitives ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_direction_between_computes_signed_unit_steps():
|
||||||
|
a = EntityPosition("field", 5, 5, 0)
|
||||||
|
b = EntityPosition("field", 8, 3, 0)
|
||||||
|
assert direction_between(a, b) == (1, -1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_knockback_moves_entity_up_to_distance():
|
||||||
|
world, _field = make_world(None)
|
||||||
|
entity = Entity(position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.registry = None
|
||||||
|
world.add_entity(entity)
|
||||||
|
world.registry = _FreeRegistry()
|
||||||
|
moved = apply_knockback(world, entity, (1, 0, 0), 3)
|
||||||
|
assert moved == 3
|
||||||
|
assert (entity.position.x, entity.position.y) == (8, 5)
|
||||||
|
|
||||||
|
|
||||||
|
class _FreeRegistry:
|
||||||
|
def get_tile_layer_def(self, _layer, _def_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_knockback_stops_at_a_wall():
|
||||||
|
world, field = make_world(None)
|
||||||
|
world.registry = _FreeRegistry()
|
||||||
|
field.get_tile(7, 5, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
|
||||||
|
class WallAwareRegistry:
|
||||||
|
def get_tile_layer_def(self, layer, def_id):
|
||||||
|
if layer == "room" and def_id == "wood_wall":
|
||||||
|
from engine.tile import TileLayerDef
|
||||||
|
|
||||||
|
return TileLayerDef(id="wood_wall", layer="room", walkable=False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
world.registry = WallAwareRegistry()
|
||||||
|
entity = Entity(position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(entity)
|
||||||
|
moved = apply_knockback(world, entity, (1, 0, 0), 5)
|
||||||
|
assert moved == 1 # stopped just before the wall at x=7
|
||||||
|
assert entity.position.x == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_melee_knockback_distance_scales_with_strength(registry):
|
||||||
|
weak = make_human(registry, strength=5)
|
||||||
|
strong = make_human(registry, strength=20)
|
||||||
|
staff = registry.get_item_def("staff_oak")
|
||||||
|
assert melee_knockback_distance(strong, staff, registry) > melee_knockback_distance(weak, staff, registry)
|
||||||
|
|
||||||
|
|
||||||
|
def test_melee_knockback_distance_unarmed_uses_default_base(registry):
|
||||||
|
character = make_human(registry, strength=10)
|
||||||
|
assert melee_knockback_distance(character, None, registry) == round(0.3 * 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recoil_skips_rockets():
|
||||||
|
world, _field = make_world(None)
|
||||||
|
world.registry = _FreeRegistry()
|
||||||
|
shooter = Entity(position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=True)
|
||||||
|
assert moved == 0
|
||||||
|
assert shooter.position.x == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recoil_pushes_shooter_backward_proportional_to_knockback():
|
||||||
|
world, _field = make_world(None)
|
||||||
|
world.registry = _FreeRegistry()
|
||||||
|
shooter = Entity(position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=False)
|
||||||
|
assert moved == round(10 * 0.3)
|
||||||
|
assert shooter.position.x == 5 - moved
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_melee_knockback ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_melee_knockback_pushes_defender_and_recoils_attacker(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20)
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
world.add_entity(defender)
|
||||||
|
staff = registry.get_item_def("staff_oak")
|
||||||
|
|
||||||
|
moved = resolve_melee_knockback(world, attacker, defender, staff, hit=True, registry=registry)
|
||||||
|
assert moved > 0
|
||||||
|
assert defender.position.x > 6 # pushed away from the attacker
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_melee_knockback_no_op_on_miss(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20)
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
world.add_entity(defender)
|
||||||
|
staff = registry.get_item_def("staff_oak")
|
||||||
|
|
||||||
|
moved = resolve_melee_knockback(world, attacker, defender, staff, hit=False, registry=registry)
|
||||||
|
assert moved == 0
|
||||||
|
assert defender.position.x == 6
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_ranged_knockback / resolve_explosion_knockback -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ranged_knockback_pushes_target_and_recoils_shooter(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
target = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
world.add_entity(target)
|
||||||
|
heavy = registry.get_item_def("ammo_heavy_caliber")
|
||||||
|
|
||||||
|
moved = resolve_ranged_knockback(world, shooter, target, heavy, (1, 0, 0))
|
||||||
|
assert moved == round(heavy.ranged_knockback)
|
||||||
|
assert target.position.x == 5 + moved
|
||||||
|
assert shooter.position.x < 0.0 + 1 # shooter recoiled backward from x=0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_explosion_knockback_pushes_entities_radially(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
east = make_human(registry, position=EntityPosition("field", 7, 5, 0))
|
||||||
|
west = make_human(registry, position=EntityPosition("field", 3, 5, 0))
|
||||||
|
world.add_entity(east)
|
||||||
|
world.add_entity(west)
|
||||||
|
rocket = registry.get_item_def("ammo_rocket")
|
||||||
|
|
||||||
|
resolve_explosion_knockback(world, rocket, center=(5, 5, 0), hit_entities=[(east, 20.0), (west, 20.0)])
|
||||||
|
assert east.position.x > 7 # pushed further east, away from center
|
||||||
|
assert west.position.x < 3 # pushed further west, away from center
|
||||||
|
|
||||||
|
|
||||||
|
# --- end-to-end through activate_hand / fire_held_weapon -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_hand_melee_knocks_back_target(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20)
|
||||||
|
attacker.bio["athletics"] = 15
|
||||||
|
attacker.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(attacker)
|
||||||
|
world.add_entity(defender)
|
||||||
|
|
||||||
|
activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=18))
|
||||||
|
assert defender.position.x > 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_heavy_caliber_knocks_back_even_when_blocked(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
shooter.inventory = Inventory(4, 4)
|
||||||
|
ammo_def = registry.get_item_def("ammo_heavy_caliber")
|
||||||
|
shooter.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=5), ammo_def, 0, 0)
|
||||||
|
shooter.reload("right_hand", registry)
|
||||||
|
|
||||||
|
# heavy_shield doesn't cover heavy_caliber... use one that does, and set is_blocking so the
|
||||||
|
# opposed-check path runs (knockback should still land regardless of that outcome)
|
||||||
|
defender = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True)
|
||||||
|
world.add_entity(shooter)
|
||||||
|
world.add_entity(defender)
|
||||||
|
|
||||||
|
original_x = defender.position.x
|
||||||
|
fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15))
|
||||||
|
assert defender.position.x > original_x
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def get_tile_layer_def(self, _layer, _def_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_world_with_gap():
|
||||||
|
# a 10x10 field with a "water" gap (still walkable=True at the data level, but no dock
|
||||||
|
# path) between the player's dock and a ship anchored two tiles away
|
||||||
|
parent = Map("world", 10, 10, 1)
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
|
||||||
|
ship = Map("ship", 3, 3, 1)
|
||||||
|
for x in range(3):
|
||||||
|
for y in range(3):
|
||||||
|
ship.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck")))
|
||||||
|
|
||||||
|
parent.embed(ship, (7, 5, 0)) # ship's footprint is x:7-9, y:5-7
|
||||||
|
return parent, ship
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_crosses_a_gap_that_a_single_step_could_not():
|
||||||
|
parent, ship = make_world_with_gap()
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": ship}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("world", 5, 6, 0))
|
||||||
|
# a single step east only reaches (6,6,0), still short of the ship at x=7
|
||||||
|
assert world.try_move(player, 1, 0, 0) is True
|
||||||
|
assert (player.position.x, player.position.y) == (6, 6)
|
||||||
|
|
||||||
|
# leaping east 2 tiles clears the remaining gap and lands aboard the ship
|
||||||
|
assert world.try_leap(player, 1, 0, 0) is True
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_distance_is_configurable():
|
||||||
|
parent, ship = make_world_with_gap()
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": ship}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("world", 4, 6, 0))
|
||||||
|
assert world.try_leap(player, 1, 0, 0, distance=1) is True
|
||||||
|
assert player.position.map_id == "world" # a 1-tile leap from x=4 lands short of the ship at x=7
|
||||||
|
assert (player.position.x, player.position.y) == (5, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_lands_out_of_bounds_fails_like_a_normal_move():
|
||||||
|
parent, ship = make_world_with_gap()
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": ship}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("world", 0, 0, 0))
|
||||||
|
assert world.try_leap(player, -1, 0, 0) is False
|
||||||
|
assert (player.position.x, player.position.y) == (0, 0)
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerDef, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
def make_world():
|
||||||
|
parent = Map("world", 10, 10, 1)
|
||||||
|
child = Map("ship", 4, 3, 1)
|
||||||
|
for x in range(4):
|
||||||
|
for y in range(3):
|
||||||
|
child.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck")))
|
||||||
|
# leave (2, 0, 0) open as the doorway, wall the rest of the front/back/sides
|
||||||
|
for x, y in [(0, 0), (1, 0), (3, 0), (0, 2), (3, 2)]:
|
||||||
|
tile = child.get_tile(x, y, 0)
|
||||||
|
tile.room = TileLayerInstance("wood_wall")
|
||||||
|
|
||||||
|
for x in range(10):
|
||||||
|
for y in range(10):
|
||||||
|
parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
|
||||||
|
parent.embed(child, (6, 6, 0))
|
||||||
|
return parent, child
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_to_parent_and_back_are_inverses():
|
||||||
|
_parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
for lx, ly, lz in [(0, 0, 0), (2, 0, 0), (3, 2, 0)]:
|
||||||
|
px, py, pz = embedding.local_to_parent(lx, ly, lz)
|
||||||
|
assert embedding.parent_to_local(px, py, pz) == (lx, ly, lz)
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedding_at_detects_child_footprint():
|
||||||
|
parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
assert parent.embedding_at(8, 6, 0) is embedding # inside ship footprint (6..9, 6..8)
|
||||||
|
assert parent.embedding_at(0, 0, 0) is None # outside ship footprint
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def get_tile_layer_def(self, layer, def_id):
|
||||||
|
if layer == "room" and def_id == "wood_wall":
|
||||||
|
return TileLayerDef(id="wood_wall", layer="room", walkable=False, blocks_los=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_move_boards_ship_from_parent_map():
|
||||||
|
parent, child = make_world()
|
||||||
|
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": child}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("world", 7, 5, 0)) # just below the doorway at (8,6)
|
||||||
|
assert world.try_move(player, 1, 1, 0) is True
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_move_disembarks_ship_back_to_parent_map():
|
||||||
|
parent, child = make_world()
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": child}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
# standing in the doorway (ship-local 2,0,0), step north off the ship's edge
|
||||||
|
player = Entity(position=EntityPosition("ship", 2, 0, 0))
|
||||||
|
assert world.try_move(player, 0, -1, 0) is True
|
||||||
|
assert player.position.map_id == "world"
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (8, 5, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_move_blocked_by_wall():
|
||||||
|
parent, child = make_world()
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": child}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("ship", 1, 1, 0))
|
||||||
|
assert world.try_move(player, -1, -1, 0) is False # (0,0) is a wood_wall
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_embedding_local_to_parent_and_back_are_inverses():
|
||||||
|
_parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
embedding.rotate_to(1) # 90 degrees clockwise: 4x3 footprint becomes 3x4
|
||||||
|
assert embedding.footprint_size() == (3, 4)
|
||||||
|
for lx, ly, lz in [(0, 0, 0), (2, 0, 0), (3, 2, 0), (1, 1, 0)]:
|
||||||
|
px, py, pz = embedding.local_to_parent(lx, ly, lz)
|
||||||
|
assert embedding.parent_to_local(px, py, pz) == (lx, ly, lz)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boarding_still_works_after_ship_rotates():
|
||||||
|
parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
embedding.rotate_to(1)
|
||||||
|
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": child}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
# the doorway (ship-local 2,0) now lands at a different parent cell post-rotation
|
||||||
|
door_parent = embedding.local_to_parent(2, 0, 0)
|
||||||
|
approach = (door_parent[0] - 1, door_parent[1] - 1, 0)
|
||||||
|
player = Entity(position=EntityPosition("world", *approach))
|
||||||
|
assert world.try_move(player, 1, 1, 0) is True
|
||||||
|
assert player.position.map_id == "ship"
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (2, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_and_rotating_ship_does_not_touch_aboard_entities_local_position():
|
||||||
|
_parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
|
||||||
|
sailor = Entity(position=EntityPosition("ship", 2, 1, 0))
|
||||||
|
original_local = (sailor.position.x, sailor.position.y, sailor.position.z)
|
||||||
|
|
||||||
|
embedding.move_to((0, 0, 0))
|
||||||
|
embedding.rotate_to(2)
|
||||||
|
|
||||||
|
assert (sailor.position.x, sailor.position.y, sailor.position.z) == original_local
|
||||||
|
|
||||||
|
|
||||||
|
def test_disembarking_resolves_against_current_anchor_after_ship_moves():
|
||||||
|
parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
|
||||||
|
world = World()
|
||||||
|
world.maps = {"world": parent, "ship": child}
|
||||||
|
world.registry = FakeRegistry()
|
||||||
|
|
||||||
|
embedding.move_to((5, 5, 0)) # ship has sailed elsewhere since boarding
|
||||||
|
|
||||||
|
player = Entity(position=EntityPosition("ship", 2, 0, 0))
|
||||||
|
assert world.try_move(player, 0, -1, 0) is True
|
||||||
|
assert player.position.map_id == "world"
|
||||||
|
# disembarks relative to the ship's *current* anchor, not the original (6,6,0)
|
||||||
|
assert (player.position.x, player.position.y, player.position.z) == (7, 4, 0)
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from engine.network.client import GameClient
|
||||||
|
from engine.network.protocol import DEFAULT_PORT, decode_message, encode_message
|
||||||
|
from engine.network.server import GameServer
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(asyncio.wait_for(coro, timeout=5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_port_is_42069():
|
||||||
|
assert DEFAULT_PORT == 42069
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_round_trip():
|
||||||
|
message = {"type": "move", "entity_id": "p1", "x": 3, "y": 4}
|
||||||
|
decoded = decode_message(encode_message(message).rstrip(b"\n"))
|
||||||
|
assert decoded == message
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_clients_see_each_others_join_and_move():
|
||||||
|
async def scenario():
|
||||||
|
server = GameServer(host="127.0.0.1", port=0)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port)
|
||||||
|
bob = GameClient("bob", "Bob", host="127.0.0.1", port=server.port)
|
||||||
|
|
||||||
|
await alice.connect()
|
||||||
|
await bob.connect() # bob's hello -> alice should see a "join" for bob
|
||||||
|
join_event = await alice.receive()
|
||||||
|
assert join_event == {"type": "join", "entity_id": "bob", "name": "Bob"}
|
||||||
|
|
||||||
|
await bob.send_move("demo_world", 5, 5, 0)
|
||||||
|
move_event = await alice.receive()
|
||||||
|
assert move_event == {"type": "move", "entity_id": "bob", "map_id": "demo_world", "x": 5, "y": 5, "z": 0}
|
||||||
|
|
||||||
|
await bob.close()
|
||||||
|
leave_event = await alice.receive()
|
||||||
|
assert leave_event == {"type": "leave", "entity_id": "bob"}
|
||||||
|
|
||||||
|
await alice.close()
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_broadcast_excludes_the_sender():
|
||||||
|
async def scenario():
|
||||||
|
server = GameServer(host="127.0.0.1", port=0)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port)
|
||||||
|
await alice.connect()
|
||||||
|
await alice.send_move("demo_world", 1, 1, 0)
|
||||||
|
# nothing else connected, so alice's own move should never be echoed back to her;
|
||||||
|
# confirm by racing a short timeout against receive()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(alice.receive(), timeout=0.2)
|
||||||
|
assert False, "sender should not receive its own broadcast"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
await alice.close()
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_reports_connected_clients():
|
||||||
|
async def scenario():
|
||||||
|
server = GameServer(host="127.0.0.1", port=0)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
assert server.clients == {}
|
||||||
|
alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port)
|
||||||
|
await alice.connect()
|
||||||
|
await asyncio.sleep(0.1) # let the server process the hello
|
||||||
|
assert "alice" in server.clients
|
||||||
|
await alice.close()
|
||||||
|
finally:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.organs import apply_organ_interactions_tick
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- def loading + auto-attachment ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_organ_defs_load_with_check_weights_and_affects_organs(registry):
|
||||||
|
heart = registry.get_organ_def("human_heart")
|
||||||
|
assert heart.host_slot == "torso"
|
||||||
|
assert heart.check_weights["strength"] == pytest.approx(0.6)
|
||||||
|
assert heart.affects_organs["human_kidneys"] == pytest.approx(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_default_body_parts_auto_attaches_organs_by_host_slot(registry):
|
||||||
|
parts = registry.new_default_body_parts("human")
|
||||||
|
torso = next(p for p in parts if p.slot == "torso")
|
||||||
|
organ_ids = {o.organ_def_id for o in torso.organs}
|
||||||
|
assert organ_ids == {"human_heart", "human_lungs", "human_liver", "human_kidneys"}
|
||||||
|
|
||||||
|
head = next(p for p in parts if p.slot == "head")
|
||||||
|
assert {o.organ_def_id for o in head.organs} == {"human_brain"}
|
||||||
|
|
||||||
|
arm = next(p for p in parts if p.slot == "left_arm")
|
||||||
|
assert arm.organs == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_default_body_parts_organs_are_independent_per_call(registry):
|
||||||
|
a = registry.new_default_body_parts("human")
|
||||||
|
b = registry.new_default_body_parts("human")
|
||||||
|
torso_a = next(p for p in a if p.slot == "torso")
|
||||||
|
torso_b = next(p for p in b if p.slot == "torso")
|
||||||
|
heart_a = next(o for o in torso_a.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart_b = next(o for o in torso_b.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart_a.integrity = 10.0
|
||||||
|
assert heart_b.integrity == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_or_create_body_part_override preserves organs/ailments ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_creation_preserves_default_organs(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
# damage integrity for an unrelated reason - this must not silently drop the organs
|
||||||
|
override = character.get_or_create_body_part_override("torso")
|
||||||
|
assert {o.organ_def_id for o in override.organs} == {"human_heart", "human_lungs", "human_liver", "human_kidneys"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_creation_preserves_existing_ailments(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
default_torso = character.get_body_part("torso")
|
||||||
|
default_torso.ailments.append(Ailment(id="preexisting", name="Preexisting"))
|
||||||
|
override = character.get_or_create_body_part_override("torso")
|
||||||
|
assert any(a.id == "preexisting" for a in override.ailments)
|
||||||
|
|
||||||
|
|
||||||
|
# --- organ_penalty / total_check_penalty ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_organ_penalty_zero_when_healthy(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.organ_penalty("strength", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_organ_penalty_rises_with_heart_damage(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 50.0
|
||||||
|
penalty = character.organ_penalty("strength", registry)
|
||||||
|
assert penalty > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_organ_penalty_zero_for_unrelated_check(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 0.0
|
||||||
|
heart.removed = True
|
||||||
|
# heart doesn't weight "perception" at all
|
||||||
|
assert character.organ_penalty("perception", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_check_penalty_equals_skill_penalty_when_organs_healthy(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("left_leg", "human_left_leg", integrity=20.0)]
|
||||||
|
assert character.total_check_penalty("acrobatics", registry) == pytest.approx(
|
||||||
|
character.skill_penalty("acrobatics", registry)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_check_penalty_combines_body_part_and_organ_damage(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("left_arm", "human_left_arm", integrity=0.0, removed=True)]
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
lungs = next(o for o in torso.organs if o.organ_def_id == "human_lungs")
|
||||||
|
lungs.integrity = 40.0 # damaged but not removed, so neither penalty alone saturates at 1.0
|
||||||
|
|
||||||
|
combined = character.total_check_penalty("athletics", registry)
|
||||||
|
body_only = character.skill_penalty("athletics", registry)
|
||||||
|
organ_only = character.organ_penalty("athletics", registry)
|
||||||
|
assert combined > body_only
|
||||||
|
assert combined > organ_only
|
||||||
|
assert combined <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- effective_strength / effective_speed ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_strength_reduced_by_damaged_heart(registry):
|
||||||
|
character = make_human(registry, strength=20)
|
||||||
|
healthy = character.effective_strength(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 20.0
|
||||||
|
damaged = character.effective_strength(registry)
|
||||||
|
assert damaged < healthy
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_speed_reduced_by_damaged_heart_and_lungs(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
healthy_speed = character.effective_speed(30.0, registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
for organ_id in ("human_heart", "human_lungs"):
|
||||||
|
organ = next(o for o in torso.organs if o.organ_def_id == organ_id)
|
||||||
|
organ.integrity = 0.0
|
||||||
|
organ.removed = True
|
||||||
|
damaged_speed = character.effective_speed(30.0, registry)
|
||||||
|
assert damaged_speed < healthy_speed
|
||||||
|
|
||||||
|
|
||||||
|
# --- organ-to-organ interaction tick ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_organ_interactions_tick_damages_kidneys_from_a_weak_heart(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 0.0 # fully damaged -> damage_fraction = 1.0
|
||||||
|
|
||||||
|
apply_organ_interactions_tick(character, registry, ticks=10)
|
||||||
|
|
||||||
|
kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys")
|
||||||
|
# rate 0.05 * damage_fraction 1.0 * 10 ticks = 0.5 damage
|
||||||
|
assert kidneys.integrity == pytest.approx(99.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_organ_interactions_tick_no_op_when_organs_healthy(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
apply_organ_interactions_tick(character, registry, ticks=10)
|
||||||
|
kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys")
|
||||||
|
assert kidneys.integrity == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_organ_interactions_tick_zero_ticks_is_a_no_op(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 0.0
|
||||||
|
apply_organ_interactions_tick(character, registry, ticks=0)
|
||||||
|
kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys")
|
||||||
|
assert kidneys.integrity == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_organ_interactions_tick_does_not_cascade_within_one_call(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 0.0 # heart affects kidneys, but kidneys don't affect anything themselves
|
||||||
|
|
||||||
|
apply_organ_interactions_tick(character, registry, ticks=1)
|
||||||
|
# sanity: no exception, no unexpected organ (e.g. liver) got touched by a cascade
|
||||||
|
liver = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_liver")
|
||||||
|
assert liver.integrity == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_organ_no_longer_contributes_to_further_interactions(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
heart = next(o for o in torso.organs if o.organ_def_id == "human_heart")
|
||||||
|
heart.integrity = 0.0
|
||||||
|
heart.removed = True
|
||||||
|
|
||||||
|
apply_organ_interactions_tick(character, registry, ticks=100)
|
||||||
|
kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys")
|
||||||
|
assert kidneys.integrity == 100.0 # removed heart is skipped as a source entirely
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.entity import EntityPosition
|
||||||
|
from engine.inventory import Inventory
|
||||||
|
from engine.item import ItemInstance
|
||||||
|
from engine.map import Map
|
||||||
|
from engine.tile import Tile, TileLayerInstance
|
||||||
|
from engine.world import World
|
||||||
|
from resources.logic.ranged_combat import effective_aim_cone, fire_held_weapon, perform_explosion
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_world(registry):
|
||||||
|
field = Map("field", 15, 15, 1)
|
||||||
|
for x in range(15):
|
||||||
|
for y in range(15):
|
||||||
|
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
|
||||||
|
world = World(registry=registry)
|
||||||
|
world.add_map(field)
|
||||||
|
return world, field
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
def __init__(self, roll=15, choice_value=None, uniform_value=0.0):
|
||||||
|
self._roll = roll
|
||||||
|
self._choice_value = choice_value
|
||||||
|
self._uniform_value = uniform_value
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return self._choice_value if self._choice_value is not None else seq[0]
|
||||||
|
|
||||||
|
def uniform(self, _a, _b):
|
||||||
|
return self._uniform_value
|
||||||
|
|
||||||
|
|
||||||
|
# --- effective_aim_cone -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_zero_for_unarmed_hand(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert effective_aim_cone(character, "right_hand", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_zero_for_non_cone_weapon(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
assert effective_aim_cone(character, "right_hand", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_uses_weapon_base_cone(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
rifle = registry.get_item_def("rifle_heavy")
|
||||||
|
assert effective_aim_cone(character, "right_hand", registry) == pytest.approx(rifle.aim_cone_degrees)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_narrows_with_skill(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
baseline = effective_aim_cone(character, "right_hand", registry)
|
||||||
|
character.bio["sleight_of_hand"] = 10
|
||||||
|
with_skill = effective_aim_cone(character, "right_hand", registry)
|
||||||
|
assert with_skill < baseline
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_narrows_with_attachments(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
baseline = effective_aim_cone(character, "right_hand", registry)
|
||||||
|
character.attach_mod("right_hand", "scope_basic", registry)
|
||||||
|
with_scope = effective_aim_cone(character, "right_hand", registry)
|
||||||
|
assert with_scope == pytest.approx(baseline - 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_aim_cone_never_below_minimum(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
character.bio["sleight_of_hand"] = 1000 # absurdly high, should clamp not go negative
|
||||||
|
assert effective_aim_cone(character, "right_hand", registry) >= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- attach_mod / magazine_capacity / reload --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_mod_fails_on_non_ranged_weapon(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
assert character.attach_mod("right_hand", "scope_basic", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_mod_succeeds_on_ranged_weapon(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
assert character.attach_mod("right_hand", "scope_basic", registry) is True
|
||||||
|
assert "scope_basic" in character.get_held_item("right_hand").attachments
|
||||||
|
|
||||||
|
|
||||||
|
def test_magazine_capacity_includes_extended_mag_bonus(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
base_capacity = character.magazine_capacity("right_hand", registry)
|
||||||
|
character.attach_mod("right_hand", "extended_mag", registry)
|
||||||
|
assert character.magazine_capacity("right_hand", registry) == base_capacity + 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_consumes_compatible_ammo_from_inventory(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
ammo_def = registry.get_item_def("ammo_heavy_caliber")
|
||||||
|
character.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=10), ammo_def, 0, 0)
|
||||||
|
|
||||||
|
assert character.reload("right_hand", registry) is True
|
||||||
|
held = character.get_held_item("right_hand")
|
||||||
|
assert held.loaded_ammo_type == "heavy_caliber"
|
||||||
|
assert held.loaded_ammo_count == 5 # rifle_heavy magazine_size
|
||||||
|
remaining = next(p for p in character.inventory.items() if p.item.item_id == "ammo-1")
|
||||||
|
assert remaining.item.quantity == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_fails_without_compatible_ammo(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
shell_def = registry.get_item_def("ammo_shotgun_shell")
|
||||||
|
character.inventory.place(ItemInstance("ammo-1", "ammo_shotgun_shell", quantity=10), shell_def, 0, 0)
|
||||||
|
assert character.reload("right_hand", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_fails_when_magazine_already_full(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
ammo_def = registry.get_item_def("ammo_heavy_caliber")
|
||||||
|
character.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=20), ammo_def, 0, 0)
|
||||||
|
character.reload("right_hand", registry)
|
||||||
|
assert character.reload("right_hand", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_fails_for_non_ammo_weapon(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.wield_item("right_hand", "staff_oak", registry)
|
||||||
|
character.inventory = Inventory(4, 4)
|
||||||
|
assert character.reload("right_hand", registry) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- fire_held_weapon --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_fails_when_unloaded(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
world.add_entity(shooter)
|
||||||
|
assert fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_decrements_ammo_and_hits_target(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "rifle_heavy", registry)
|
||||||
|
shooter.inventory = Inventory(4, 4)
|
||||||
|
ammo_def = registry.get_item_def("ammo_heavy_caliber")
|
||||||
|
shooter.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=5), ammo_def, 0, 0)
|
||||||
|
shooter.reload("right_hand", registry)
|
||||||
|
|
||||||
|
target = make_human(registry, position=EntityPosition("field", 3, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
world.add_entity(target)
|
||||||
|
|
||||||
|
result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15))
|
||||||
|
assert shooter.get_held_item("right_hand").loaded_ammo_count == 4
|
||||||
|
assert result.hit is True
|
||||||
|
assert result.target_entity_id == target.entity_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_shotgun_fires_multiple_pellets(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "shotgun_basic", registry)
|
||||||
|
shooter.inventory = Inventory(4, 4)
|
||||||
|
shell_def = registry.get_item_def("ammo_shotgun_shell")
|
||||||
|
shooter.inventory.place(ItemInstance("shells-1", "ammo_shotgun_shell", quantity=5), shell_def, 0, 0)
|
||||||
|
shooter.reload("right_hand", registry)
|
||||||
|
world.add_entity(shooter)
|
||||||
|
|
||||||
|
result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15))
|
||||||
|
assert len(result.pellets) == 6 # shotgun_basic pellet_count
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_shotgun_slug_fires_single_projectile(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "shotgun_basic", registry)
|
||||||
|
shooter.inventory = Inventory(4, 4)
|
||||||
|
slug_def = registry.get_item_def("ammo_shotgun_slug")
|
||||||
|
shooter.inventory.place(ItemInstance("slugs-1", "ammo_shotgun_slug", quantity=5), slug_def, 0, 0)
|
||||||
|
shooter.reload("right_hand", registry)
|
||||||
|
world.add_entity(shooter)
|
||||||
|
|
||||||
|
result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15))
|
||||||
|
assert len(result.pellets) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_held_weapon_rocket_launcher_explodes(registry):
|
||||||
|
world, _field = make_world(registry)
|
||||||
|
shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0))
|
||||||
|
shooter.wield_item("right_hand", "rocket_launcher", registry)
|
||||||
|
shooter.inventory = Inventory(4, 4)
|
||||||
|
rocket_def = registry.get_item_def("ammo_rocket")
|
||||||
|
shooter.inventory.place(ItemInstance("rockets-1", "ammo_rocket", quantity=2), rocket_def, 0, 0)
|
||||||
|
shooter.reload("right_hand", registry)
|
||||||
|
target = make_human(registry, position=EntityPosition("field", 6, 5, 0))
|
||||||
|
world.add_entity(shooter)
|
||||||
|
world.add_entity(target)
|
||||||
|
|
||||||
|
result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15))
|
||||||
|
assert result.impact is not None
|
||||||
|
assert any(target is t for t, _dmg in result.hit_entities)
|
||||||
|
|
||||||
|
|
||||||
|
# --- perform_explosion --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_explosion_damages_entities_with_falloff_by_distance(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
close = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
||||||
|
far = make_human(registry, position=EntityPosition("field", 7, 5, 0))
|
||||||
|
world.add_entity(close)
|
||||||
|
world.add_entity(far)
|
||||||
|
|
||||||
|
result = perform_explosion(world, "field", (5, 5, 0), radius=2, base_damage=30.0)
|
||||||
|
hit_map = {id(t): dmg for t, dmg in result.hit_entities}
|
||||||
|
close_damage = hit_map[id(close)]
|
||||||
|
far_damage = hit_map[id(far)]
|
||||||
|
assert close_damage > far_damage
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_explosion_damages_walls_within_radius(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
perform_explosion(world, "field", (5, 5, 0), radius=2, base_damage=200.0)
|
||||||
|
# 200 damage with falloff should be enough to destroy a wall within radius 1
|
||||||
|
assert field.get_tile(6, 5, 0).room is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_explosion_does_not_affect_tiles_outside_radius(registry):
|
||||||
|
world, field = make_world(registry)
|
||||||
|
field.get_tile(10, 5, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
perform_explosion(world, "field", (5, 5, 0), radius=1, base_damage=200.0)
|
||||||
|
assert field.get_tile(10, 5, 0).room is not None
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue