dot-player-char covers the character definition (body metrics and the
customisation document), a 3D rig with named mounts, a 2D sprite sheet with
layered overlays, a locomotion state machine that drives either, and two things
built on top: ready-made animated people and ragdolls for when they die.
Requires dot-core and dot-player. Both drawing halves ship in one addon and
neither loads anything until a catalogue points it at content, so a 3D game pays
a handful of unused scripts for the 2D half and no assets.
Usage
One definition carries every body measurement, and the node applies it:
var ch := DotPlayerChar.new()
ch.catalogue = DotPlayerCharCatalogue.three_builds()
player.add_child(ch)
ch.set_char(&"heavy") # the capsule resizes with the model
ch.set_crouched(true) # so does the crouched one
ch.eye_height() # asked by the camera, the muzzle and the trace
Height decides the collision capsule, eye height decides where the camera and
the muzzle are, crouch height decides what it fits under, radius decides what it
fits through, and hitbox scale decides how hard it is to shoot. Those are five
numbers in five files in most projects, and one DotPlayerCharDef here.
`validate()` refuses three configurations with no obvious symptom
Crouching taller than standing (the player gets stuck coming out of every gap they crouched into), eyes above the head (they see over walls they are standing behind), and the crouched pair of the same.
What is in it
DotPlayerCharDef / DotPlayerCharCatalogue |
Body metrics, slots and options. Three preset builds ship. |
DotPlayerCharLook |
The customisation document: slot choices and tint colours. |
DotPlayerChar |
The node. Applies a definition to the body and holds the look. |
DotPlayerModelBuilder / DotPlayerModelVisual / DotPlayerModelCatalogue |
The 3D half: a plan built from ids, applied to a rig. |
DotPlayerSpriteVisual / DotPlayerSpriteFacing |
The 2D half: layered overlays on one grid, and the facing lookup. |
DotPlayerAnimDriver / DotPlayerAnimState / DotPlayerAnimSet |
Locomotion, and the clip table both halves share. |
DotPlayerAnimPlayerSink / DotPlayerAnimSpriteSink |
Where the animation decision is applied: an AnimationPlayer, or a sheet. |
DotHumanoidLook / DotHumanoidFigure / DotHumanoidClips |
Animated people: four skinned characters on one rig, coloured by a look. |
DotPlayerRagdolls / DotPlayerDeath |
Ragdolls, and the death reaction the server picks. |
Validating a look
var res := look.validate(def) # does the slot exist, is the option real,
# are there no more colours than channels
Nothing in validating a look loads art, so a server can check one without holding the cosmetics — the same rule as dot-user-avatar.
conform() is the other half, and the split is deliberate:
validateis for the trust boundary. A bad document is refused.conformis for loading a saved character after an option was retired. Refusing there would lock a player out of their own character, so it drops what is illegal and keeps the rest.
fill_defaults() completes a partial look, so a player who never opened the
customisation screen still has a valid one.
Drawing it in 3D
var plan := DotPlayerModelBuilder.plan(model_def, look)
DotPlayerModelBuilder.apply(plan, rig, placeholder)
Planning never touches the filesystem. missing means the definition names no
path for this part, not the file is absent — a dedicated server plans from
ids and holds none of the content, so a plan that consulted
ResourceLoader.exists would answer differently on each machine.
DotPlayerModelVisual keeps a signature of slot→scene, so a colour or stance
change writes to the instances that already exist instead of rebuilding the
figure. Tints are per-instance shader parameters, never a material swap: two
players wearing the same part must not recolour each other.
DotPlayerModelCatalogue.humanoid() ships nine mount names — head, face,
chest, back, waist, left_hand, right_hand, left_foot, right_foot —
and no scene paths. Agreeing on the spelling is what lets a weapon written for
one game attach in another.
Drawing it in 2D
A sheet is a grid: a row is a state, a direction offsets that row, and a column
is a frame. def.frame_index(row, direction, frame) resolves one, and overlays
are cut on the base’s grid so a single index serves every layer.
DotPlayerSpriteFacing.resolve(angle, count, mirror) returns the row offset
and the flip, because a mirrored eight-way sheet is five drawn rows and
three derived.
Animating it
DotPlayerAnimDriver anim_set = DotPlayerAnimSet.locomotion()
DotPlayerAnimPlayerSink 3D: drives an AnimationPlayer
DotPlayerAnimSpriteSink 2D: calls set_row / set_frame
DotPlayerAnimState.advance(motion, delta) is pure — no node, no clock, no
input device — so a server can run the same state machine as a client and check
what the client claims to be doing.
Transitions need hysteresis, not a smaller epsilon
speed > run_speed ? "run" : "walk" flickers for a player holding a stick at
the threshold. Each transition takes two thresholds. air_grace is the same
idea in time: a stair step leaves the floor for a tick, and a character that
started a jump animation each time twitches its way upstairs.
Animation events come from the clip’s own timeline rather than a Timer, as
fractions, so 0.25 is the same place in a rig’s clip and a sheet’s.
People
var look := DotHumanoidLook.from_site(site_avatar) # or DotHumanoidLook.stock(hash(player_id))
var person := DotHumanoidFigure.new()
world.add_child(person)
person.build(look.with_side(team_colour, 0.6), 1.8)
# every frame:
person.set_motion({"on_floor": on_floor, "vertical": velocity.y, "crouched": crouched})
person.set_hold(DotHumanoidFigure.Hold.GUN)
person.pose(drawn_position, yaw, ground_speed)
Players, bots and NPCs can be four animated people on one rig, coloured by a look: a body and its skin, hair, top and bottom. A member’s site avatar becomes a look, a team colour goes on the top, and everybody dressed alike shares one material. The figure chooses the clip from how the player moves (idle, walk, run, crouch, jump, fall, land, crawl when down, swim, climb, sit, drive, death), holds a gun with the gun clips, bends the upper body to aim up and down (aim), and plays swings, flinches and gestures over the legs. The sit and driving motion keys seat it, with driving falling back to the sit clip if the driving one is missing, and a hat, crest or similar attaches at the head_top mount.
Two settings matter for a body that moves in steps, as one carried on physics ticks or network snapshots does. Its per-frame speed alternates between zero and double and the clip flips between idle, walk and run, so set auto_smooth_time to average the measured speed over a short time. A game whose walking pace is near the default raises set_run_speeds(run_speed, run_exit_speed) so a walk does not play the hunched run clip.
When somebody dies
var yard := DotPlayerRagdolls.new() # one per world, on a client
yard.rules.follow_cvars(link) # sv_ragdoll and friends
world.add_child(yard)
# the server, on a kill:
var death := DotPlayerDeath.decide(reactions, headshot, damage, direction, seed, weapon_def)
# every client, on that message:
yard.spawn(victim_figure, DotPlayerDeath.from_dict(kill_message), victim_velocity)
The dead figure becomes a ragdoll: its parts on rigid bodies joined at the hips, shoulders and neck, pushed the way the shot went, then settling, freezing and fading, with the oldest making room when there are too many. A skinned figure falls by its bones. Death reactions are data (a plain fall, a headshot that throws the body, a powerful weapon that carries it along the shot, a face plant, a crumple), and the server picks and sends the pick with the kill, so every screen shows the same fall. A server can also make ragdolls one stiff piece, a scripted fall with no physics, or turn them off.
A pack that draws a ragdoll or a person needs a shell with the addon API level that has it (level 2 for ragdolls, 3 for people, 4 for driving), and says so in its requires.json.
