GDScript 305 lines
class_name HeroModelLibrary
extends RefCounted
## The placeholder 3D models the heroes wear, and the logic that drops one onto the
## field at a consistent size, facing, and team colour under a stylised cel shader. Each
## hero kit maps to a low-poly animal glTF standing in for the species the shapeshifter
## takes. The models come from mixed sources at wildly different authored scales and
## facings, so this module normalises every one to a single on-field size, re-skins each
## surface with the shared toon shader (`cel.gdshader` — banded light, team colour mixed
## in), and leaves the match presenter to only ask for a model by kit.
## A hero kit's placeholder model, keyed by `kit_id`. A kit with no entry (an unknown
## kit, or a hero whose `kit_id` did not survive the wire on a pure CLIENT) has no model
## and the presenter falls back to a plain capsule body.
const HERO_MODELS := {
"lion": "res://assets/models/heroes/lion.glb",
"cheetah": "res://assets/models/heroes/cheetah.glb",
"hyena": "res://assets/models/heroes/hyena.glb",
"snake": "res://assets/models/heroes/snake.glb",
"spider": "res://assets/models/heroes/spider.glb",
"chameleon": "res://assets/models/heroes/chameleon.glb",
}
## The non-hero field props that now wear models too — lane creeps and the two structure
## kinds — keyed by a prop name the presenter passes (`creep`/`tower`/`nexus`). Each entry
## carries its glTF and the on-field size its longest axis is scaled to, so a creep reads
## small under the heroes while a tower stands imposing over them. Same low-poly source
## family (Quaternius / iPoly3D, all CC0) as the hero animals, credited in CREDITS.md.
const PROP_MODELS := {
"creep": {"path": "res://assets/models/creeps/slime.glb", "size": 120.0},
"tower": {"path": "res://assets/models/structures/tower.glb", "size": 460.0},
"nexus": {"path": "res://assets/models/structures/nexus.glb", "size": 360.0},
}
## The world size a model's longest axis is scaled to, so every model reads at one size
## on the field regardless of the units it was authored in. Sized a touch above the
## capsule it replaces so the species is legible from the follow-camera.
const HERO_MODEL_SIZE := 260.0
## The shared cel shader every model is re-skinned with, so a stylised toon-banded look
## replaces the raw PBR import. Driven per surface in `_stylize`, which copies the source
## material's albedo into it and folds the team colour in.
const CEL_SHADER: Shader = preload("res://src/client/cel.gdshader")
## The soft drop-shadow blob laid under every unit so it sits on the ground rather than
## floating: a flat quad washed by this radial-fade shader, sized to the unit's own footprint
## and scaled out a touch, set a hair above the ground decor (SHADOW_Y) so a unit's shadow
## reads over a lane or the river too. A blob, not a shadow-map cast — it grounds thin
## low-poly legs cleanly without a stretched, noisy silhouette.
const SHADOW_SHADER: Shader = preload("res://src/client/shadow.gdshader")
const SHADOW_COLOR := Color(0.0, 0.0, 0.0, 0.5)
const SHADOW_SCALE := 1.7
const SHADOW_Y := 3.0
## How far a hero's albedo is mixed toward its team colour (0 keeps the species colour, 1
## replaces it), strong enough to read blue or red at a glance while the species texture
## still shows through. Kept light so an already-dark mesh (the spider) is tinted, not
## drowned to near-black.
const TEAM_TINT_STRENGTH := 0.25
## The heavier mix a structure or creep takes — a prop has no species identity of its own
## to protect, so it leans harder into the team colour than a hero does, reading blue/red
## at a glance from across the lane.
const PROP_TINT_STRENGTH := 0.4
## 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.
static func has_model(kit_id: String) -> bool:
return HERO_MODELS.has(kit_id)
## Instances `kit_id`'s model under `parent`, size-normalised and re-skinned with the cel
## shader mixed toward `team_tint`, and returns it. `parent` must already be in the tree so
## the model's mesh transforms resolve for the bounds measurement. Call only when
## `has_model(kit_id)`.
static func add_to(parent: Node3D, kit_id: String, team_tint: Color) -> Node3D:
var packed := load(HERO_MODELS[kit_id]) as PackedScene
var model := packed.instantiate() as Node3D
parent.add_child(model)
_normalize(model, HERO_MODEL_SIZE)
_stylize(model, team_tint, TEAM_TINT_STRENGTH)
return model
## Instances a field prop's model (`prop` is a `PROP_MODELS` key — `creep`/`tower`/`nexus`)
## under `parent`, normalised to that prop's size and re-skinned with the cel shader at the
## heavier prop mix, and returns it. Mirrors `add_to` for the non-hero field: a creep or a
## structure stands on the ground at a consistent size instead of a debug capsule or box.
## `parent` must be in the tree for the bounds measurement.
static func add_prop(parent: Node3D, prop: String, team_tint: Color) -> Node3D:
var def: Dictionary = PROP_MODELS[prop]
var packed := load(def["path"]) as PackedScene
var model := packed.instantiate() as Node3D
parent.add_child(model)
_normalize(model, def["size"])
_stylize(model, team_tint, PROP_TINT_STRENGTH)
return model
## Lays a soft drop-shadow blob under a unit, parented to its view `root`: a flat quad sized
## to `body`'s measured footprint (scaled out by SHADOW_SCALE), washed by the radial-fade
## shadow shader, set at SHADOW_Y above the ground. Grounds every unit — hero, creep, or
## structure — against the grass. No-op for a footprint that measures to nothing. `body` must
## already sit under `root` in the tree for its footprint to resolve.
static func add_shadow(root: Node3D, body: Node3D) -> void:
var radius := footprint_radius(body)
if radius <= 0.0:
return
var blob := MeshInstance3D.new()
var quad := QuadMesh.new()
var diameter := radius * 2.0 * SHADOW_SCALE
quad.size = Vector2(diameter, diameter)
blob.mesh = quad
blob.rotation.x = -PI / 2.0 # lay the upright quad flat on the ground, facing up
blob.position = Vector3(0.0, SHADOW_Y, 0.0)
var mat := ShaderMaterial.new()
mat.shader = SHADOW_SHADER
mat.set_shader_parameter("shadow_color", SHADOW_COLOR)
blob.material_override = mat
root.add_child(blob)
## The horizontal half-extent of `body` about its parent origin — the furthest its merged
## mesh bounds reach on x or z, measured in the parent's space. Lets the presenter size a
## unit's drop-shadow blob to its actual footprint, so a wide tower and a slim chameleon each
## get a shadow that fits. `body` must be in the tree for its mesh transforms to resolve.
static func footprint_radius(body: Node3D) -> float:
var parent := body.get_parent() as Node3D
var inv := parent.global_transform.affine_inverse() if parent else Transform3D()
var radius := 0.0
for mi in _meshes(body):
var box: AABB = inv * (mi.global_transform * mi.get_aabb())
radius = maxf(radius, maxf(absf(box.position.x), absf(box.end.x)))
radius = maxf(radius, maxf(absf(box.position.z), absf(box.end.z)))
return radius
## The height of `body`'s top above its parent origin — the merged mesh bounds' max y,
## measured in the parent's space so a normalised model's own ground-standing offset is
## included. Lets the presenter float a hero's bars a fixed margin above whatever model
## (or capsule) it wears instead of a one-size height that detaches over a small one.
## `body` must be in the tree for its mesh transforms to resolve.
static func top_of(body: Node3D) -> float:
var parent := body.get_parent() as Node3D
var inv := parent.global_transform.affine_inverse() if parent else Transform3D()
var top := 0.0
for mi in _meshes(body):
var box: AABB = inv * (mi.global_transform * mi.get_aabb())
top = maxf(top, box.position.y + box.size.y)
return top
## Scales `model` so its longest axis spans `size`, then offsets it so its footprint is
## centred on the parent origin and its base rests on the ground (y = 0). The models arrive
## at wildly different authored scales, so this is what makes every one read at its intended
## on-field size — a common size for the heroes, a per-prop size for creeps and structures.
static func _normalize(model: Node3D, size: float) -> void:
var aabb := _model_aabb(model)
var longest := maxf(aabb.size.x, maxf(aabb.size.y, aabb.size.z))
if longest <= 0.0:
return
var s := size / longest
model.scale = Vector3(s, s, s)
var center := aabb.get_center()
model.position = Vector3(-center.x * s, -aabb.position.y * s, -center.z * s)
## The merged bounds of every mesh under `model`, in the model's own local space so a
## later offset on the model does not skew it. The model must be in the tree for the
## mesh global transforms to resolve.
static func _model_aabb(model: Node3D) -> AABB:
var inv := model.global_transform.affine_inverse()
var out := AABB()
var first := true
for mi in _meshes(model):
var local: AABB = inv * (mi.global_transform * mi.get_aabb())
if first:
out = local
first = false
else:
out = out.merge(local)
return out
## Re-skins every surface of `model` with the shared cel shader, mixed `strength` toward
## the team `color`. Each surface gets its own ShaderMaterial seeded from the source
## material's albedo (texture, base colour, and vertex-colour flag) so the model still
## reads as itself — only the lighting turns toon-banded and the team colour blends in. A
## hero takes a light mix to keep its species; a prop a heavier one.
static func _stylize(model: Node3D, color: Color, strength: float) -> void:
for mi in _meshes(model):
for surface in mi.mesh.get_surface_count():
var src := mi.get_active_material(surface) as BaseMaterial3D
var mat := ShaderMaterial.new()
mat.shader = CEL_SHADER
mat.set_shader_parameter("team_tint", color)
mat.set_shader_parameter("tint_strength", strength)
if src != null:
mat.set_shader_parameter("albedo", src.albedo_color)
mat.set_shader_parameter("use_vertex", 1.0 if src.vertex_color_use_as_albedo else 0.0)
if src.albedo_texture != null:
mat.set_shader_parameter("albedo_tex", src.albedo_texture)
mi.set_surface_override_material(surface, mat)
## Every MeshInstance3D carrying a mesh in the subtree under `node`, gathered depth-first.
static func _meshes(node: Node, acc: Array[MeshInstance3D] = []) -> Array[MeshInstance3D]:
if node is MeshInstance3D and (node as MeshInstance3D).mesh != null:
acc.append(node as MeshInstance3D)
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