256 lines
12 KiB
Python
256 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
|
|
from engine.character import BodyPart, Character, ClothingPiece
|
|
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
|
|
DEFAULT_ROLL_COUNT = 6 # how many values roll_assign mode generates to place among the skills
|
|
|
|
# The four ways skills can be filled in - see CharacterCreator.set_skill_mode.
|
|
SKILL_MODE_POINT_BUY = "point_buy" # spend a shared budget freely (increase_skill/decrease_skill)
|
|
SKILL_MODE_ROLL_ASSIGN = "roll_assign" # roll a small pool of values, place each on a skill you pick
|
|
SKILL_MODE_MANUAL = "manual" # set any skill to any value directly, no budget
|
|
SKILL_MODE_RANDOM = "random" # every skill gets its own random value immediately, nothing to place
|
|
SKILL_MODES = (SKILL_MODE_POINT_BUY, SKILL_MODE_ROLL_ASSIGN, SKILL_MODE_MANUAL, SKILL_MODE_RANDOM)
|
|
|
|
# Species a player can create a character as. "dog" is deliberately excluded - it's a
|
|
# companion/NPC-only species (see the companion_dog entity template), still fully valid to
|
|
# spawn as an NPC or load via CharacterLibrary, just not offered as a player choice here.
|
|
PLAYER_SELECTABLE_SPECIES: tuple[str, ...] = ("human", "reptilian_quad", "alien_tri", "bot")
|
|
|
|
|
|
@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 cosmetic body type and hair color (also species-defined, see SpeciesDef.body_type_options/
|
|
hair_colors - either can be empty for a species that doesn't offer that choice, e.g. no
|
|
species-defined hair colors means no hair to color), starter clothing (species/arm-count
|
|
compatibility enforced the same way Character.wear_item enforces it - see
|
|
set_starter_clothing), a name, and a fixed point budget spent 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, color
|
|
swatches, 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
|
|
selectable_species: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES
|
|
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
|
|
body_type: str | None = None
|
|
hair_color: str | None = None
|
|
starter_clothing: dict[str, str] = field(default_factory=dict) # slot -> item_def_id
|
|
bio: dict[str, int] = field(default_factory=default_bio)
|
|
skill_mode: str = SKILL_MODE_POINT_BUY
|
|
rolled_values: list[int] = field(default_factory=list) # roll_assign mode's unplaced pool
|
|
|
|
def set_name(self, name: str) -> None:
|
|
self.name = name.strip()
|
|
|
|
def available_species(self) -> list[str]:
|
|
"""Species ids a player can actually pick, for a UI to list - selectable_species
|
|
filtered against whatever's actually loaded in the registry.
|
|
"""
|
|
return [s for s in self.selectable_species if s in self.registry.species_defs]
|
|
|
|
def set_species(self, species_id: str) -> bool:
|
|
"""Picks a species, resetting gender/body_type/hair_color to that species' first valid
|
|
option if the current pick (from a previous species) isn't valid for it, and dropping
|
|
any starter clothing (fit/compatibility is species-specific - see set_starter_clothing).
|
|
|
|
Rejects any species not in selectable_species (e.g. "dog" - companion/NPC-only, not a
|
|
player choice) even if it's otherwise a perfectly valid, fully-loaded species.
|
|
"""
|
|
if species_id not in self.selectable_species or 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
|
|
if self.body_type not in species.body_type_options:
|
|
self.body_type = species.body_type_options[0] if species.body_type_options else None
|
|
if self.hair_color not in species.hair_colors:
|
|
self.hair_color = species.hair_colors[0] if species.hair_colors else None
|
|
self.starter_clothing = {}
|
|
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 set_body_type(self, body_type: str) -> bool:
|
|
if self.species_id is None:
|
|
return False
|
|
species = self.registry.get_species_def(self.species_id)
|
|
if body_type not in species.body_type_options:
|
|
return False
|
|
self.body_type = body_type
|
|
return True
|
|
|
|
def set_hair_color(self, hair_color: str) -> bool:
|
|
if self.species_id is None:
|
|
return False
|
|
species = self.registry.get_species_def(self.species_id)
|
|
if hair_color not in species.hair_colors:
|
|
return False
|
|
self.hair_color = hair_color
|
|
return True
|
|
|
|
def set_starter_clothing(self, slot: str, item_def_id: str) -> bool:
|
|
"""Picks a starter garment for `slot`, validated exactly the way Character.wear_item
|
|
validates it (species body_type + arm-sleeve fit) - via a throwaway probe character, so
|
|
the fit rule only has to live in one place. Requires a species to already be chosen.
|
|
"""
|
|
if self.species_id is None:
|
|
return False
|
|
probe = Character(species_id=self.species_id, default_body_parts=self.registry.new_default_body_parts(self.species_id))
|
|
if not probe.wear_item(slot, item_def_id, self.registry):
|
|
return False
|
|
self.starter_clothing[slot] = item_def_id
|
|
return True
|
|
|
|
def remove_starter_clothing(self, slot: str) -> None:
|
|
self.starter_clothing.pop(slot, None)
|
|
|
|
def set_skill_mode(self, mode: str, rng: random.Random | None = None) -> bool:
|
|
"""Switches how skills get filled in, resetting bio to a clean slate for that mode:
|
|
|
|
- point_buy: everything back to 0, spend a shared budget freely (increase_skill/
|
|
decrease_skill) - the default, classic point-buy.
|
|
- roll_assign: rolls a fixed pool of DEFAULT_ROLL_COUNT values (0..max_skill_level
|
|
each) up front; each gets placed on a skill of your choosing via assign_rolled_value,
|
|
one at a time - whatever skills are left unassigned stay at 0.
|
|
- manual: every skill directly settable to any value via set_skill_manual, no budget at
|
|
all - full freeform control.
|
|
- random: every skill immediately gets its own independent random value - nothing left
|
|
to place, the character's ready to go as rolled (switching to another mode discards
|
|
this and starts that mode fresh).
|
|
"""
|
|
if mode not in SKILL_MODES:
|
|
return False
|
|
self.skill_mode = mode
|
|
self.bio = default_bio()
|
|
self.rolled_values = []
|
|
rng = rng or random.Random()
|
|
if mode == SKILL_MODE_ROLL_ASSIGN:
|
|
self.rolled_values = [rng.randint(0, self.max_skill_level) for _ in range(DEFAULT_ROLL_COUNT)]
|
|
elif mode == SKILL_MODE_RANDOM:
|
|
for skill_id in self.bio:
|
|
self.bio[skill_id] = rng.randint(0, self.max_skill_level)
|
|
return True
|
|
|
|
def assign_rolled_value(self, roll_index: int, skill_id: str) -> bool:
|
|
"""roll_assign mode only: places one of the still-unplaced rolled values onto a skill
|
|
(overwriting whatever it currently holds - 0, if untouched) and removes that value from
|
|
the pool, so each rolled value can only be placed once.
|
|
"""
|
|
if self.skill_mode != SKILL_MODE_ROLL_ASSIGN:
|
|
return False
|
|
if not (0 <= roll_index < len(self.rolled_values)):
|
|
return False
|
|
if skill_id not in self.bio:
|
|
return False
|
|
value = self.rolled_values.pop(roll_index)
|
|
self.bio[skill_id] = value
|
|
return True
|
|
|
|
def set_skill_manual(self, skill_id: str, value: int) -> bool:
|
|
"""manual mode only: directly sets a skill to any value in [0, max_skill_level]."""
|
|
if self.skill_mode != SKILL_MODE_MANUAL:
|
|
return False
|
|
if skill_id not in self.bio:
|
|
return False
|
|
if not (0 <= value <= self.max_skill_level):
|
|
return False
|
|
self.bio[skill_id] = value
|
|
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:
|
|
"""point_buy mode only - see set_skill_mode for the other three ways to fill in skills."""
|
|
if self.skill_mode != SKILL_MODE_POINT_BUY:
|
|
return False
|
|
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:
|
|
"""point_buy mode only - see set_skill_mode for the other three ways to fill in skills."""
|
|
if self.skill_mode != SKILL_MODE_POINT_BUY:
|
|
return False
|
|
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:
|
|
if not self.name or self.species_id is None or self.gender is None:
|
|
return False
|
|
species = self.registry.get_species_def(self.species_id)
|
|
if species.body_type_options and self.body_type is None:
|
|
return False
|
|
if species.hair_colors and self.hair_color is None:
|
|
return False
|
|
return True
|
|
|
|
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,
|
|
body_type=self.body_type,
|
|
hair_color=self.hair_color,
|
|
default_body_parts=self.registry.new_default_body_parts(self.species_id),
|
|
default_clothing=[ClothingPiece(slot, item_def_id) for slot, item_def_id in self.starter_clothing.items()],
|
|
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
|