Commit
Theria
feat: turn the hero models to face their movement, walk the spider
The simulation carries no facing, so the presenter reads each hero's heading off its per-tick position delta and eases the model round to it — the animals stop sliding sideways. Where a model is rigged (only the spider, among the placeholders) it loops a walk clip while moving and an idle one while still; the static ones just turn. The per-kit forward correction lives in HeroModelLibrary.FORWARD_OFFSET, empty until the playtest shows which animals come out tail-first. Presentation only — the simulation and the netcode protocol are unchanged.
modified CHANGELOG.md
@@ -26,6 +26,9 @@ protocol version.
### Changed
- The hero models now turn to face where they move instead of sliding sideways, and the
rigged one (the spider) loops a walk cycle while it moves and an idle one while it stands
— the others, which ship no animation, just turn. Presentation only.
- The hero models read cleaner: their team-colour wash is lighter, so a dark animal (the
spider) keeps its own colour instead of drowning to near-black, and each hero's floating
bars now hang just above its own model — the short animals and the tall ones both read
modified src/client/hero_model_library.gd
@@ -29,6 +29,89 @@ const HERO_MODEL_SIZE := 260.0
## so an already-dark mesh (the spider) is tinted, not drowned to near-black.
const TEAM_TINT_ALPHA := 0.25
## The yaw, in radians, that turns a kit's model to face its movement direction once the
## presenter has aimed its length axis down the move vector. The land animals are all
## authored with their body along Z but not all nose the same way, and the spider reads
## the same from every side — so this corrects the ones that come out tail-first. A kit
## with no entry needs no correction. Eyeball-calibrated against the windowed playtest.
const FORWARD_OFFSET := {}
## Per-tick world distance below which a hero counts as standing (it holds its heading and
## idles, rather than snapping to the jitter of a near-zero step), and the share of the
## remaining angle it turns through each 60 Hz tick — so it swings round to a new heading
## over a few ticks instead of popping.
const FACE_MOVE_EPS := 1.0
const FACE_TURN_RATE := 0.3
## The yaw correction for `kit_id`'s model, 0 when it already faces down its move vector.
static func forward_offset(kit_id: String) -> float:
return FORWARD_OFFSET.get(kit_id, 0.0)
## The first AnimationPlayer inside `model`, or null when the model ships no clips (most
## of the placeholders are static meshes — only the spider is rigged). Lets the presenter
## drive an idle/walk loop where there is one and leave the rest as static bodies.
static func animator(model: Node3D) -> AnimationPlayer:
for child in _descendants(model):
if child is AnimationPlayer:
return child as AnimationPlayer
return null
## The name of `anim`'s clip whose name contains `want` (case-insensitive), or "" when
## none does — so the presenter can ask for "walk"/"idle" without knowing a model's
## armature-prefixed clip names (the spider's read `SpiderArmature|Spider_Walk` etc.).
static func clip_named(anim: AnimationPlayer, want: String) -> String:
for name in anim.get_animation_list():
if (name as String).to_lower().contains(want):
return name
return ""
## Primes `view` to turn and animate a model hero's `body`: stashes the running heading
## (`yaw`), the kit's forward correction (`yaw_offset`), and — for a rigged model — its
## AnimationPlayer (`anim`) with looped walk/idle clip names (`clip_walk`/`clip_idle`).
## The presenter then drives it each tick with `drive_facing`. The animal placeholders are
## mostly static meshes, so a model with no AnimationPlayer simply turns without a clip.
static func setup_facing(view: Dictionary, kit_id: String, body: Node3D) -> void:
view["yaw"] = 0.0
view["yaw_offset"] = forward_offset(kit_id)
var anim := animator(body)
if anim == null:
return
view["anim"] = anim
view["clip_walk"] = clip_named(anim, "walk")
view["clip_idle"] = clip_named(anim, "idle")
_loop_clip(anim, view["clip_walk"])
_loop_clip(anim, view["clip_idle"])
## Turns `body` toward this tick's `move` (its ground-plane step, world units) and drives
## its walk/idle clip, reading the facing state `setup_facing` stashed on `view`. A step
## longer than FACE_MOVE_EPS counts as moving: the heading eases toward the move vector
## (plus the kit's forward correction) by FACE_TURN_RATE of the remaining angle and the
## walk clip loops; otherwise the body holds its heading and idles. Rigless models turn,
## no clip.
static func drive_facing(view: Dictionary, body: Node3D, move: Vector2) -> void:
var moving := move.length() > FACE_MOVE_EPS
if moving:
var target := atan2(move.x, move.y) + float(view["yaw_offset"])
view["yaw"] = lerp_angle(view["yaw"], target, FACE_TURN_RATE)
body.rotation.y = view["yaw"]
if view.has("anim"):
var clip: String = view["clip_walk"] if moving else view["clip_idle"]
var anim := view["anim"] as AnimationPlayer
if clip != "" and anim.current_animation != clip:
anim.play(clip)
## Marks `clip` on `anim` as looping, so a walk or idle cycle repeats instead of playing
## once and freezing on its last frame (glTF imports clips non-looping). No-op for "".
static func _loop_clip(anim: AnimationPlayer, clip: String) -> void:
if clip != "" and anim.has_animation(clip):
anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
## Whether `kit_id` has a placeholder model. Gate `add_to` on this — an unmodelled kit
## keeps the capsule body.
@@ -115,3 +198,12 @@ static func _meshes(node: Node, acc: Array[MeshInstance3D] = []) -> Array[MeshIn
for child in node.get_children():
_meshes(child, acc)
return acc
## Every node in the subtree under `node` (including `node`), gathered depth-first — the
## walk `animator` scans for the model's AnimationPlayer.
static func _descendants(node: Node, acc: Array[Node] = []) -> Array[Node]:
acc.append(node)
for child in node.get_children():
_descendants(child, acc)
return acc
modified src/client/main.gd
@@ -691,9 +691,12 @@ func _camera_focus(state: SimState) -> SimEntity:
## per-tick update mutates, so nothing is rebuilt while the entity lives.
func _make_view(entity: SimEntity) -> Dictionary:
var root := Node3D.new()
root.position = _world(entity.position)
add_child(root)
var view := {"root": root}
view["body"] = _build_body(root, entity)
if entity.is_hero and HeroModelLibrary.has_model(entity.kit_id):
HeroModelLibrary.setup_facing(view, entity.kit_id, view["body"])
if entity.is_hero:
var ring := MeshInstance3D.new()
ring.mesh = _ring_mesh()
@@ -747,10 +750,14 @@ func _attach_overlay(view: Dictionary, entity: SimEntity) -> void:
view["status"] = label
## Reconciles one view with its entity: position, the form-ring colour, the bar fills,
## and the status label. Cheap per-tick mutation only — no node is created here.
## Reconciles one view with its entity: position, facing, the form-ring colour, the bar
## fills, and the status label. Cheap per-tick mutation only — no node is created here.
func _update_view(view: Dictionary, entity: SimEntity) -> void:
(view["root"] as Node3D).position = _world(entity.position)
var root := view["root"] as Node3D
var moved := _world(entity.position) - root.position
root.position = _world(entity.position)
if view.has("yaw"):
HeroModelLibrary.drive_facing(view, view["body"], Vector2(moved.x, moved.z))
if view.has("ring"):
var mat := (view["ring"] as MeshInstance3D).material_override as StandardMaterial3D
var animal := entity.form == AbilitySpec.FORM_ANIMAL