# 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]
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