Source code for bacommon.characterbuilder

# Released under the MIT License. See LICENSE for details.
#
"""Character builders: the authoring form of a character.

A ``.bchar`` file in an assets workspace holds one
:class:`CharacterBuilder` as json. It says everything needed for a
character to appear in the store and in games -- its name, its icon,
its default colors, and how it looks and is proportioned in play --
by pointing at other assets **in the same workspace**.

This is an *authoring* format: written by people (or the workspace
editor), stored once, and read by the server, which builds the
compact definitions clients actually receive from it. So its keys are
plain words, and nothing here is sent to game clients as is.

Every asset is named by its **logical path**: its source file's path
from the workspace root, minus the extension (``zoe/head.obj`` is
``zoe/head``). Logical paths are unique across all kinds of asset in
a workspace, so a path alone is never ambiguous. Assets in other
workspaces or packages cannot be referenced; copy what a character
needs into its own workspace.

Files inside published package versions are kept forever, so this
schema only ever grows: new optional fields, or a new
:class:`CharacterBuilderData` kind beside the existing ones.
"""

from __future__ import annotations  # Docs-generation hack.

from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never

from efro.dataclassio import ioprepped, IOAttrs, IOMultiType

#: The file extension of a character builder in a workspace.
CHARACTER_BUILDER_EXTENSION = '.bchar'


[docs] class CharacterBuilderDataTypeID(Enum): """Type ID for each kind of character a builder can describe.""" BASIC_SPAZ = 'basic_spaz'
[docs] class CharacterBuilderData(IOMultiType[CharacterBuilderDataTypeID]): """A complete character of some kind; see the kinds below. A kind owns everything about its characters: name, icon, colors and in-game form alike. """
[docs] @override @classmethod def get_type_id(cls) -> CharacterBuilderDataTypeID: raise NotImplementedError()
[docs] @override @classmethod def get_type( cls, type_id: CharacterBuilderDataTypeID ) -> type[CharacterBuilderData]: t = CharacterBuilderDataTypeID if type_id is t.BASIC_SPAZ: return BasicSpazBuilder assert_never(type_id)
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return 'type'
[docs] class BuilderEyeStyle(Enum): """How one of a character's eyes is drawn.""" #: Eyeball plus a resting/blinking eyelid. REGULAR = 'regular' #: Bare eyeball; the lid appears only during blinks. LIDLESS = 'lidless' #: No eye drawn at all (painted-on or eyeless heads, eyepatches). NONE = 'none'
[docs] @ioprepped @dataclass class BuilderTransform: """A placement: move, turn and resize, in that order of effect. Applied to a thing in the space of whatever it is attached to. The thing is resized about its own origin, then turned, then moved; so ``scale`` never changes how far ``translate`` moves it, and ``rotate`` never swings it around some distant point. Left out entirely, a transform changes nothing. ``rotate`` is heading, pitch and roll in degrees, applied in that order and described the way an aircraft's are, the thing's 'nose' being its own forward (+z) axis: * **heading** turns it about the up axis of what it is attached to. Positive turns right, so something pointing out of a character's nose swings toward the character's right ear. * **pitch** then raises its nose. Positive aims it up, so toward the top of a head. * **roll** then spins it about its own nose without changing where that points. Positive is clockwise seen from behind. """ translate: tuple[float, float, float] = (0.0, 0.0, 0.0) rotate: tuple[float, float, float] = (0.0, 0.0, 0.0) scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
[docs] class BuilderAttachTarget(Enum): """A body part an attachment can hang off.""" HEAD = 'head' TORSO = 'torso' PELVIS = 'pelvis' UPPER_ARM = 'upper_arm' FOREARM = 'forearm' HAND = 'hand' UPPER_LEG = 'upper_leg' LOWER_LEG = 'lower_leg' TOES = 'toes' #: Not a body part but a place wings go; a pair of spots at the #: character's back that swing and flap as wings do. Whatever is #: attached here is the wing, and a character has wings exactly #: when something is. Its forward (+z) points out along the wing #: from the middle of the torso. Sided like a limb, and like a #: limb it takes static attachments only. WING = 'wing'
[docs] class BuilderAttachSide(Enum): """Which of a paired body part's two sides an attachment is on. Like the limb meshes themselves, a limb (or wing) attachment is authored for the character's right side and drawn mirrored on the left. """ #: Both sides. The only choice for the head, torso and pelvis. BOTH = 'both' RIGHT = 'right' LEFT = 'left'
[docs] class BuilderAttachmentKind(Enum): """How an attachment moves. Each kind is a ready-made physical rig; an attachment picks one, places it, tunes it and supplies art for its segments. All of them are for looks only and never affect play. The ``LEGACY_`` kinds reproduce one classic hair rig and twirl oddly when disturbed; prefer the ``ANTENNA`` kinds for anything new. """ #: Single softly-jointed capsule. One segment. LEGACY_TUFT_LARGE = 'legacy_tuft_large' #: The same with a medium-sized capsule. One segment. LEGACY_TUFT_MEDIUM = 'legacy_tuft_medium' #: The same with a small capsule. One segment. LEGACY_TUFT_SMALL = 'legacy_tuft_small' #: A stiffer root capsule plus a floppy tip. Two segments. LEGACY_PONYTAIL_2 = 'legacy_ponytail_2' #: A stiff single capsule that mostly holds its pose, for #: antennas, ears, horns and the like. One segment. ANTENNA = 'antenna' #: A chain of two such capsules. Two segments. ANTENNA_2 = 'antenna_2' #: A chain of three. Three segments. ANTENNA_3 = 'antenna_3' #: A chain of four. Four segments. ANTENNA_4 = 'antenna_4' #: No movement at all, just art fixed to the body part, for hats, #: horns, badges and the like. One segment. The only kind limbs #: and wings can take. STATIC = 'static'
[docs] @ioprepped @dataclass class BuilderAttachmentSegment: """The art for one segment of an attachment. Drawn with the character's own texture, tint mask, color and highlight, like every other piece of it, unless it names textures of its own. """ #: The segment's mesh, by logical path. mesh: str #: A color texture of its own, by logical path. Left out, the #: character's ``color_texture`` is used, which suits art whose #: picture is part of the character's one texture. texture: str | None = ( None ) #: A tint mask of its own, read as the character's #: ``color_mask_texture`` is. Left out, that one is used. For art #: with a texture of its own that should take no tint at all, #: name an all-black mask here. mask_texture: str | None = None #: How the mesh is drawn on its segment (for a static attachment, #: on the body part itself). transform: BuilderTransform = field(default_factory=BuilderTransform)
[docs] @ioprepped @dataclass class BuilderAttachment: """One piece hung off one of a character's body parts. The tuning numbers below run 0 to 1 (or -1 to 1 where noted) and what they adjust depends on the kind; a static attachment ignores them all. """ # pylint: disable=too-many-instance-attributes #: The body part it hangs off. target: BuilderAttachTarget kind: BuilderAttachmentKind #: Art for each of the kind's segments, root first. segments: list[BuilderAttachmentSegment] side: BuilderAttachSide = ( BuilderAttachSide.BOTH ) #: Where it is anchored on the body part and which way it rests. #: A static attachment is simply drawn there. Scale is not used; #: resize a segment's own transform instead. transform: BuilderTransform = field(default_factory=BuilderTransform) #: How firmly it springs back to its resting pose. stiffness: float = 0.5 #: How quickly its motion settles. damping: float = 0.5 #: How much it trails behind a moving character. drag: float = 0.5 #: Resting bend at every joint of a chain, -1 to 1. Positive bends #: toward the attachment's own up; 1 is a half turn per joint. curl: float = 0.0 #: Segment length. The default 0.5 is the kind's own length; 0 is #: half of it and 1 is one and a half times it. Art is unaffected. length: float = 0.5 #: Segment thickness. The default 0 is the kind's own; 1 is three #: times it. Art is unaffected. radius: float = 0.0 #: How much the same-named number above changes from one segment #: of a chain to the next, -1 to 1 each. A chain can run stiff at #: the root and floppy at the tip, say. curl_change: float = 0.0 length_change: float = 0.0 radius_change: float = 0.0 stiffness_change: float = 0.0 damping_change: float = 0.0
[docs] @ioprepped @dataclass class BasicSpazBuilder(CharacterBuilderData): """A classic spaz: per-body-part meshes sharing one texture. Has one name, one icon, a color and up to two highlights. Every part is drawn from a single color texture, tinted through a single mask texture: the mask's red channel takes the character's color, its green channel the highlight and its blue channel the second highlight. Arm and leg meshes are authored for the character's right side and mirrored for the left; a ``left_`` version of any of them gives the left side a mesh of its own. Numbers left out take the standard spaz's values, so a builder need only state what makes its character different. """ # pylint: disable=too-many-instance-attributes # ---- Identity ---- #: The character's name, as the logical path of a string (a #: ``.bstr`` file) in the same workspace, so it can be translated. name: str #: The picture that stands for the character in menus, and its #: tint mask (red takes the color, green the highlight). icon_texture: str icon_mask_texture: str #: The character's own colors. It is shown in these wherever nobody #: has picked others (the store, a new profile). color: tuple[float, float, float] highlight: tuple[float, float, float] # ---- Textures ---- color_texture: str color_mask_texture: str # ---- Meshes ---- head_mesh: str torso_mesh: str upper_arm_mesh: str upper_leg_mesh: str lower_leg_mesh: str toes_mesh: str #: Optional. Left out, nothing is drawn for that part (an upper-arm #: mesh may be the whole arm; a big round body may hide its pelvis). forearm_mesh: str | None = None hand_mesh: str | None = None pelvis_mesh: str | None = None #: Optional left-side versions of the arm and leg meshes, for a #: character whose two sides differ (one clawed hand, a peg leg). #: Each replaces the mesh of the same name for the character's left #: side only; left out, the left side draws that mesh as usual. A #: left mesh is still authored as a right-side part and drawn #: mirrored, exactly as the shared one is. (A side can't be given #: nothing this way: leaving one out means 'same as the right'.) left_upper_arm_mesh: str | None = None left_forearm_mesh: str | None = None left_hand_mesh: str | None = None left_upper_leg_mesh: str | None = None left_lower_leg_mesh: str | None = None left_toes_mesh: str | None = None # ---- Highlights ---- # (Here, apart from ``color`` and ``highlight`` above, because # everything from this point on can be left out.) #: What the highlight is called where someone picks it (a profile #: editor): the logical path of a string in the same workspace, as #: ``name`` is, for a character whose highlight is something in #: particular ('Hair Color'). Empty: plain 'Highlight'. highlight_name: str = '' #: Whether someone using the character may pick its highlight #: themselves. Off for one whose highlight is part of who it is. highlight_editing: bool = True #: A second highlight, taken by the masks' blue channel. White (the #: default) leaves what that channel covers untinted, so a #: character that doesn't use one needn't say anything. highlight2: tuple[float, float, float] = (1.0, 1.0, 1.0) #: As ``highlight_name`` and ``highlight_editing``, for the second #: highlight (plain name: 'Highlight 2'). Not editable unless a #: character says so: most don't have one. highlight2_name: str = '' highlight2_editing: bool = False # ---- Sounds ---- # (each set is picked from at random; empty means silent) jump_sounds: list[str] = field(default_factory=list) attack_sounds: list[str] = field(default_factory=list) impact_sounds: list[str] = field(default_factory=list) death_sounds: list[str] = field(default_factory=list) pickup_sounds: list[str] = field(default_factory=list) fall_sounds: list[str] = field(default_factory=list) # ---- Physique ---- # (These shape the character's physical body in play, not just how # it is drawn.) torso_radius: float = 0.15 shoulder_offset: tuple[float, float, float] = (0.0, 0.0, 0.0) thigh_radius: float = 0.04 ankle_radius: float = 0.07 #: Sideways distance between the feet while walking. step_separation: float = 0.08 #: How stiffly the lower arms are held while idle. 1 is fully #: rigid; lower values give relaxed dangling arms. idle_arm_stiffness: float = 1.0 #: How far the arms swing while walking. arm_swing: float = 0.6 #: How far the body sways while standing. idle_sway: float = ( 0.05 ) # ---- Eyes ---- # (Sides are the character's own left and right.) eye_style_left: BuilderEyeStyle = BuilderEyeStyle.REGULAR eye_style_right: BuilderEyeStyle = BuilderEyeStyle.REGULAR eye_scale: float = 1.0 #: Where one eye sits relative to the head's center; the other #: mirrors it. eye_offset: tuple[float, float, float] = (0.065, -0.036, 0.205) #: Iris color. eye_color: tuple[float, float, float] = (0.5, 0.5, 1.2) eyeball_color: tuple[float, float, float] = (0.46, 0.38, 0.36) eyelid_color: tuple[float, float, float] = (0.5, 0.3, 0.2) #: Resting eyelid tilt in degrees (positive reads as angry). eyelid_angle: float = 0.0 # ---- Surface ---- #: How strongly the body mirrors its surroundings. 0 is matte; #: higher is glossier. reflection_scale: float = 0.1 # ---- Arms ---- #: For a character whose arms are flippers or fins, meaning one #: long upper-arm mesh that is the whole limb. Its arms are drawn #: shorter while it wears boxing gloves, so they don't poke through #: the gloves. This changes only that; to leave out the forearms #: and hands such a character doesn't have, leave ``forearm_mesh`` #: and ``hand_mesh`` unset. flippers: bool = False # ---- Attachments ---- #: Extra pieces hung off the character's body parts, such as hair, #: antennas and hats. attachments: list[BuilderAttachment] = field(default_factory=list)
[docs] @override @classmethod def get_type_id(cls) -> CharacterBuilderDataTypeID: return CharacterBuilderDataTypeID.BASIC_SPAZ
[docs] @ioprepped @dataclass class CharacterBuilder: """One character; a ``.bchar`` file's contents. Deliberately just a wrapper: everything that defines the character -- even its name, icon and colors -- belongs to its :class:`CharacterBuilderData` kind, so a later kind is free to define any of those differently (several highlights, none, ...). Fields land here only if they turn out to be common to every kind. """ #: The character itself. data: CharacterBuilderData
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self, TypeIs) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum