This commit is contained in:
Nicholas Butzke
2026-08-09 01:31:44 -04:00
parent 922983429e
commit 9358746582
43 changed files with 4324 additions and 481 deletions
+5
View File
@@ -57,6 +57,11 @@ __pycache__/
# Asset pipeline staging (raw downloads, not game-ready)
assets/characters/incoming/
# Copyrighted visual references (local-only; never imported or exported)
/.reference_assets/
/assets/characters/local_reference/
/assets/characters/skins/skins.local.json
# Misc
*LF*
tags
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Anime Maid Character",
"author": "413 Games",
"author_url": "https://413games.itch.io/",
"license": "CC0 1.0 Universal / Public Domain",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"source_url": "https://413games.itch.io/maid-character",
"changes": "Original rig and materials retained, retargeted to the Papaya-Shooter animation library; game-native mecha equipment added at runtime."
}
@@ -0,0 +1,306 @@
{
"roles": {
"hips": "mixamorig:Hips",
"head": "mixamorig:Head",
"neck": "mixamorig:Neck",
"spine": [
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2",
"mixamorig:Neck",
"mixamorig:Head"
],
"shoulder.L": "mixamorig:LeftShoulder",
"shoulder.R": "mixamorig:RightShoulder",
"toe.R": "mixamorig:RightToeBase",
"toe.L": "mixamorig:LeftToeBase",
"forearm.L": "mixamorig:LeftForeArm",
"forearm.R": "mixamorig:RightForeArm",
"thigh.R": "mixamorig:RightUpLeg",
"thigh.L": "mixamorig:LeftUpLeg",
"foot.R": "mixamorig:RightFoot",
"foot.L": "mixamorig:LeftFoot",
"hand.R": "mixamorig:RightHand",
"hand.L": "mixamorig:LeftHand",
"upper_arm.L": "mixamorig:LeftArm",
"upper_arm.R": "mixamorig:RightArm",
"shin.L": "mixamorig:LeftLeg",
"shin.R": "mixamorig:RightLeg"
},
"fingers": {
"thumb.L": [
"mixamorig:LeftHandThumb1",
"mixamorig:LeftHandThumb2",
"mixamorig:LeftHandThumb3",
"mixamorig:LeftHandThumb4",
"mixamorig:LeftHandThumb4_end"
],
"index.L": [
"mixamorig:LeftHandIndex1",
"mixamorig:LeftHandIndex2",
"mixamorig:LeftHandIndex3",
"mixamorig:LeftHandIndex4",
"mixamorig:LeftHandIndex4_end"
],
"middle.L": [
"mixamorig:LeftHandMiddle1",
"mixamorig:LeftHandMiddle2",
"mixamorig:LeftHandMiddle3",
"mixamorig:LeftHandMiddle4",
"mixamorig:LeftHandMiddle4_end"
],
"ring.L": [
"mixamorig:LeftHandRing1",
"mixamorig:LeftHandRing2",
"mixamorig:LeftHandRing3",
"mixamorig:LeftHandRing4",
"mixamorig:LeftHandRing4_end"
],
"pinky.L": [
"mixamorig:LeftHandPinky1",
"mixamorig:LeftHandPinky2",
"mixamorig:LeftHandPinky3",
"mixamorig:LeftHandPinky4",
"mixamorig:LeftHandPinky4_end"
],
"thumb.R": [
"mixamorig:RightHandThumb1",
"mixamorig:RightHandThumb2",
"mixamorig:RightHandThumb3",
"mixamorig:RightHandThumb4",
"mixamorig:RightHandThumb4_end"
],
"index.R": [
"mixamorig:RightHandIndex1",
"mixamorig:RightHandIndex2",
"mixamorig:RightHandIndex3",
"mixamorig:RightHandIndex4",
"mixamorig:RightHandIndex4_end"
],
"middle.R": [
"mixamorig:RightHandMiddle1",
"mixamorig:RightHandMiddle2",
"mixamorig:RightHandMiddle3",
"mixamorig:RightHandMiddle4",
"mixamorig:RightHandMiddle4_end"
],
"ring.R": [
"mixamorig:RightHandRing1",
"mixamorig:RightHandRing2",
"mixamorig:RightHandRing3",
"mixamorig:RightHandRing4",
"mixamorig:RightHandRing4_end"
],
"pinky.R": [
"mixamorig:RightHandPinky1",
"mixamorig:RightHandPinky2",
"mixamorig:RightHandPinky3",
"mixamorig:RightHandPinky4",
"mixamorig:RightHandPinky4_end"
]
},
"chains": [],
"twist": [],
"surfaces": [
{
"mesh": "Body",
"surface": 0,
"material": "N00_000_00_Body_00_SKIN (Instance)",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 5452,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 1,
"material": "N00_000_00_HairBack_00_HAIR (Instance)",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 760,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 2,
"material": "N00_002_03_Tops_01_CLOTH (Instance)",
"class": "cloth",
"detail": "garment",
"why": "material name says 'garment'",
"verts": 908,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 3,
"material": "N00_001_01_Shoes_01_CLOTH (Instance)",
"class": "cloth",
"detail": "garment",
"why": "material name says 'garment'",
"verts": 390,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 4,
"material": "N00_000_00_FaceMouth_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 1053,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 5,
"material": "N00_000_00_EyeIris_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 214,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 6,
"material": "N00_000_00_EyeHighlight_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 38,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 7,
"material": "N00_000_00_Face_00_SKIN (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 2266,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 8,
"material": "N00_000_00_EyeWhite_00_EYE (Instance)",
"class": "body",
"detail": "eyes",
"why": "material name says 'eyes'",
"verts": 290,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 9,
"material": "N00_000_00_FaceBrow_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 52,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 10,
"material": "N00_000_00_FaceEyeline_00_FACE (Instance)",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 126,
"textured": false,
"chain_share": {}
},
{
"mesh": "Body",
"surface": 11,
"material": "N00_000_Hair_00_HAIR (Instance)",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 7355,
"textured": false,
"chain_share": {}
}
],
"colliders": [
{
"bone": "mixamorig:Hips",
"child": "mixamorig:Spine",
"from": 0.0,
"radius_head": 0.1341,
"radius_tail": 0.1341,
"radius": 0.1341,
"lid": true
},
{
"bone": "mixamorig:LeftUpLeg",
"child": "mixamorig:LeftLeg",
"from": 0.1,
"radius_head": 0.0985,
"radius_tail": 0.0605,
"radius": 0.0605
},
{
"bone": "mixamorig:RightUpLeg",
"child": "mixamorig:RightLeg",
"from": 0.1,
"radius_head": 0.0985,
"radius_tail": 0.0605,
"radius": 0.0605
},
{
"bone": "mixamorig:LeftLeg",
"child": "mixamorig:LeftFoot",
"from": 0.1,
"radius_head": 0.1172,
"radius_tail": 0.0507,
"radius": 0.0507
},
{
"bone": "mixamorig:RightLeg",
"child": "mixamorig:RightFoot",
"from": 0.1,
"radius_head": 0.1172,
"radius_tail": 0.0507,
"radius": 0.0507
}
],
"weights_authored": true,
"driven_bones": [
"mixamorig:Head",
"mixamorig:Hips",
"mixamorig:LeftArm",
"mixamorig:LeftFoot",
"mixamorig:LeftForeArm",
"mixamorig:LeftHand",
"mixamorig:LeftLeg",
"mixamorig:LeftShoulder",
"mixamorig:LeftToeBase",
"mixamorig:LeftUpLeg",
"mixamorig:Neck",
"mixamorig:RightArm",
"mixamorig:RightFoot",
"mixamorig:RightForeArm",
"mixamorig:RightHand",
"mixamorig:RightLeg",
"mixamorig:RightShoulder",
"mixamorig:RightToeBase",
"mixamorig:RightUpLeg",
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2"
]
}
Binary file not shown.
@@ -0,0 +1,10 @@
{
"name": "3D Anime Female Adventurer Character Model",
"version": "1.2",
"author": "Dawn to Dusk Games",
"author_url": "https://dawn-to-dusk-games.itch.io/",
"license": "CC0 1.0 Universal",
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
"source_url": "https://dawn-to-dusk-games.itch.io/3d-anime-female-adventurer",
"changes": "Outfit 01 retargeted to the Papaya-Shooter animation library; game-native mecha equipment added at runtime."
}
File diff suppressed because it is too large Load Diff
+64 -9
View File
@@ -36,8 +36,10 @@ render_mode unshaded, fog_disabled, depth_draw_never, depth_test_disabled, cull_
// away from the viewer) inks faintly. That is what an animator does — the
// outside line of a shape is heavy, the contact line inside it is light.
uniform sampler2D screen_tex : hint_screen_texture, filter_linear;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest;
uniform sampler2D screen_tex : hint_screen_texture, filter_linear, repeat_disable;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest, repeat_disable;
uniform sampler2D normal_roughness_tex : hint_normal_roughness_texture,
filter_nearest, repeat_disable;
group_uniforms ink;
uniform vec4 ink_color : source_color = vec4(0.224, 0.196, 0.310, 1.0);
@@ -163,8 +165,7 @@ vec3 painted_sky(vec3 dir) {
// Linear view depth, with the sky pinned to a finite value so the second
// difference across a silhouette is large but never infinite.
float linear_depth(vec2 uv, mat4 inv_proj) {
float d = texture(depth_tex, uv).r;
float linear_depth(vec2 uv, float d, mat4 inv_proj) {
// Godot 4.3+ is reverse-Z: the far plane and the sky read exactly 0.
if (d <= 0.000001) {
return ink_sky_depth;
@@ -175,6 +176,18 @@ float linear_depth(vec2 uv, mat4 inv_proj) {
}
vec3 view_position(vec2 uv, float raw_depth, mat4 inv_proj) {
vec4 ndc = vec4(uv * 2.0 - 1.0, raw_depth, 1.0);
vec4 view = inv_proj * ndc;
return view.xyz / view.w;
}
vec3 screen_normal(vec2 uv) {
return normalize(texture(normal_roughness_tex, uv).xyz * 2.0 - 1.0);
}
vec3 apply_grade(vec3 c, vec2 uv) {
if (!grade_enabled) {
return c;
@@ -226,11 +239,15 @@ void fragment() {
}
vec2 t = px * ink_thickness;
float dc = linear_depth(uv, INV_PROJECTION_MATRIX);
float dl = linear_depth(uv - vec2(t.x, 0.0), INV_PROJECTION_MATRIX);
float dr = linear_depth(uv + vec2(t.x, 0.0), INV_PROJECTION_MATRIX);
float du = linear_depth(uv + vec2(0.0, t.y), INV_PROJECTION_MATRIX);
float dd = linear_depth(uv - vec2(0.0, t.y), INV_PROJECTION_MATRIX);
float raw_l = texture(depth_tex, uv - vec2(t.x, 0.0)).r;
float raw_r = texture(depth_tex, uv + vec2(t.x, 0.0)).r;
float raw_u = texture(depth_tex, uv + vec2(0.0, t.y)).r;
float raw_d = texture(depth_tex, uv - vec2(0.0, t.y)).r;
float dc = linear_depth(uv, raw_depth, INV_PROJECTION_MATRIX);
float dl = linear_depth(uv - vec2(t.x, 0.0), raw_l, INV_PROJECTION_MATRIX);
float dr = linear_depth(uv + vec2(t.x, 0.0), raw_r, INV_PROJECTION_MATRIX);
float du = linear_depth(uv + vec2(0.0, t.y), raw_u, INV_PROJECTION_MATRIX);
float dd = linear_depth(uv - vec2(0.0, t.y), raw_d, INV_PROJECTION_MATRIX);
// Second difference of linear depth, normalised by distance so that a given
// real-world crease inks with the same weight near and far.
@@ -245,6 +262,44 @@ void fragment() {
edge = max(edge, smoothstep(
ink_concave, ink_concave * 3.4, concave) * ink_concave_amount);
// Depth precision forms a staircase on a flat floor viewed almost edge-on.
// Its second difference looks like curvature even though every tapped point
// belongs to the same plane; beyond a camera-dependent distance the old test
// therefore inked the entire road as one dark band. Confirm that curvature
// with the normal/depth prepass before drawing it. A true edge either changes
// normal, leaves the centre tangent plane, or crosses from geometry to sky.
if (edge > 0.0001) {
float sky_edge = step(raw_depth, 0.000001)
!= step(raw_l, 0.000001) || step(raw_depth, 0.000001)
!= step(raw_r, 0.000001) || step(raw_depth, 0.000001)
!= step(raw_u, 0.000001) || step(raw_depth, 0.000001)
!= step(raw_d, 0.000001) ? 1.0 : 0.0;
float geometry_evidence = sky_edge;
if (raw_depth > 0.000001 && sky_edge < 0.5) {
vec3 nc = screen_normal(uv);
vec3 nl = screen_normal(uv - vec2(t.x, 0.0));
vec3 nr = screen_normal(uv + vec2(t.x, 0.0));
vec3 nu = screen_normal(uv + vec2(0.0, t.y));
vec3 nd = screen_normal(uv - vec2(0.0, t.y));
float normal_turn = max(max(1.0 - dot(nc, nl), 1.0 - dot(nc, nr)),
max(1.0 - dot(nc, nu), 1.0 - dot(nc, nd)));
vec3 pc = view_position(uv, raw_depth, INV_PROJECTION_MATRIX);
vec3 pl = view_position(uv - vec2(t.x, 0.0), raw_l, INV_PROJECTION_MATRIX);
vec3 pr = view_position(uv + vec2(t.x, 0.0), raw_r, INV_PROJECTION_MATRIX);
vec3 pu = view_position(uv + vec2(0.0, t.y), raw_u, INV_PROJECTION_MATRIX);
vec3 pd = view_position(uv - vec2(0.0, t.y), raw_d, INV_PROJECTION_MATRIX);
float off_plane = max(max(abs(dot(nc, normalize(pl - pc))),
abs(dot(nc, normalize(pr - pc)))),
max(abs(dot(nc, normalize(pu - pc))),
abs(dot(nc, normalize(pd - pc)))));
float normal_evidence = smoothstep(0.006, 0.045, normal_turn);
float plane_evidence = smoothstep(0.012, 0.080, off_plane);
geometry_evidence = max(normal_evidence, plane_evidence);
}
edge *= geometry_evidence;
}
// Fade on the nearest real surface involved in the edge, not on dc: a sky
// pixel beside a nearby roof would otherwise be measured at 900 m and its
// half of the silhouette would fade out while the roof's half did not.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://ce3kjbbl1niw8
+23 -1
View File
@@ -19,6 +19,7 @@ const FOOT_SOCKETS := [
var _flames: Array[Node3D] = []
var _burst_left := 0.0
var _dash_active := false
var _wall_glide_active := false
var _dash_tail := 0.0
var _render_enabled := true
var _exhaust_direction := Vector3.DOWN
@@ -110,10 +111,29 @@ func set_dash(active: bool, movement_local: Vector2) -> void:
_exhaust_direction = (-move.normalized() + Vector3.DOWN * 0.20).normalized()
func set_wall_glide(active: bool, movement_local: Vector2) -> void:
_wall_glide_active = active
if not active:
return
var move := Vector3(movement_local.x, 0.0, -movement_local.y)
if move.length_squared() < 0.01:
move = Vector3.FORWARD
# Continuous thrust opposes travel with only a slight downward cant. This
# reads as vector jets carrying the pilot along the wall, not a jump burst.
_exhaust_direction = (-move.normalized() + Vector3.DOWN * 0.08).normalized()
func get_flame_count() -> int:
return _flames.size()
func get_flame_world_positions() -> Array[Vector3]:
var result: Array[Vector3] = []
for flame in _flames:
result.append(flame.global_position)
return result
func get_exhaust_direction() -> Vector3:
return _exhaust_direction
@@ -124,7 +144,7 @@ func _process(delta: float) -> void:
_burst_left = maxf(0.0, _burst_left - delta)
_dash_tail = maxf(0.0, _dash_tail - delta)
var dash_visible := _dash_active or _dash_tail > 0.0
var active := dash_visible or _burst_left > 0.0
var active := dash_visible or _burst_left > 0.0 or _wall_glide_active
visible = _render_enabled and active
if not visible:
return
@@ -135,6 +155,8 @@ func _process(delta: float) -> void:
_exhaust_direction = Vector3(0.0, -1.0, 0.16).normalized()
var pulse := 0.90 + sin(_time * 54.0) * 0.08 + sin(_time * 31.0) * 0.04
if _wall_glide_active:
pulse *= 1.32
if _burst_left > 0.0 and not _dash_active:
pulse *= clampf(_burst_left / 0.08, 0.15, 1.0)
for i in _flames.size():
+2
View File
@@ -8,6 +8,8 @@ class_name PlayerSkin
@export var skin_name: String = "Default"
@export var description: String = ""
@export var model_path: String = "" # Path to .glb file, empty = use procedural
## Optional runtime armour/nozzle layer installed on the authored human rig.
@export var mecha_theme: String = ""
@export var color_tint: Color = Color(0.2, 0.4, 0.8) # For procedural model
@export var is_unlocked: bool = true
+14 -1
View File
@@ -27,6 +27,19 @@ const CANONICAL := [
]
## Godot sanitises ':' to '_' while turning glTF nodes into scene names. The
## sidecar intentionally keeps the source bone spelling so Blender can verify
## it against the exported GLB, therefore every runtime consumer must resolve
## through this bridge instead of assuming those two representations match.
static func find_imported_bone(skel: Skeleton3D, authored_name: String) -> int:
if authored_name == "":
return -1
var bone := skel.find_bone(authored_name)
if bone < 0 and authored_name.contains(":"):
bone = skel.find_bone(authored_name.replace(":", "_"))
return bone
## Resolve `names` against a skeleton, given the sidecar's role table.
##
## The spine needs its own handling because a rig that kept its own skeleton
@@ -52,7 +65,7 @@ static func resolve(skel: Skeleton3D, roles: Dictionary,
# Canonical names are the role keys with the DEF- prefix, so the limbs,
# hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(String(n).trim_prefix("DEF-"), n))
var b := skel.find_bone(actual)
var b := find_imported_bone(skel, actual)
if b < 0:
b = skel.find_bone(String(n))
idx[n] = b
+47 -7
View File
@@ -3,7 +3,8 @@ extends Node
## Autoload: manages all available player skins.
##
## Skins come from two places:
## 1. Built-in color tints for the procedural model (registered below).
## 1. Built-in signature characters (registered below). These are the
## authored Sakura/Akiba defaults and may layer equipment over a GLB rig.
## 2. assets/characters/skins/skins.json — written automatically by
## tools/pipeline.py for every imported character. No code changes needed
## to add a new model: run the pipeline, restart the game, it's there.
@@ -14,6 +15,7 @@ extends Node
signal skin_changed(skin_id: String)
const SKINS_JSON := "res://assets/characters/skins/skins.json"
const LOCAL_SKINS_JSON := "res://assets/characters/skins/skins.local.json"
const SELECTION_CFG := "user://skin_selection.cfg"
var skins: Dictionary = {}
@@ -23,14 +25,46 @@ var active_skin_id: String = "default"
func _ready() -> void:
_register_builtin_skins()
_load_skins_json()
_load_skin_registry(LOCAL_SKINS_JSON, false)
_load_selection()
# Fold the old global character choice into legacy loadouts, then derive the
# live skin from the active loadout from this point onward.
var loadout_manager := get_node_or_null("/root/LoadoutManager")
if loadout_manager:
loadout_manager.migrate_legacy_skin(active_skin_id)
var loadout_skin: String = loadout_manager.get_active_skin_id()
if skins.has(loadout_skin) and skins[loadout_skin].is_unlocked:
active_skin_id = loadout_skin
print("SkinManager: %d skins available" % skins.size())
func _register_builtin_skins() -> void:
_add_color_skin("default", "Default", "Standard issue", Color(0.2, 0.4, 0.8))
_add_color_skin("red_team", "Red Team", "Red team colors", Color(0.8, 0.2, 0.2))
_add_color_skin("forest", "Forest", "Forest camouflage", Color(0.2, 0.6, 0.2))
_add_mecha_skin("default", "Sakura Vanguard",
"HD anime shrine-runner in a petal-armored vector-thrust frame",
"res://assets/characters/skins/sakura_pilot_hd.glb", "sakura",
Color("efe7e8"), Color("e86f98"), Color("343a61"), Color("ffabc8"))
_add_mecha_skin("akiba_pulse", "Akiba Pulse",
"HD anime district pilot in a graphite neon cyber-mecha frame",
"res://assets/characters/skins/akiba_pilot_hd.glb", "akiba",
Color("272c3e"), Color("20c9d8"), Color("171a28"), Color("ff4eb8"))
func _add_mecha_skin(id: String, display_name: String, description: String,
model: String, theme: String, sleeve: Color, accent: Color,
glove: Color, nail: Color) -> void:
var skin := PlayerSkin.new()
skin.skin_name = display_name
skin.description = description
skin.model_path = model
skin.set("mecha_theme", theme)
skin.viewmodel_sleeve_color = sleeve
skin.viewmodel_accent_color = accent
skin.viewmodel_glove_color = glove
skin.viewmodel_nail_color = nail
skin.viewmodel_hand_style = "fingerless"
skin.viewmodel_sleeve_ratio = 0.72
skin.is_unlocked = true
skins[id] = skin
func _add_color_skin(id: String, display_name: String, description: String, tint: Color) -> void:
@@ -48,15 +82,20 @@ func _add_color_skin(id: String, display_name: String, description: String, tint
func _load_skins_json() -> void:
if not FileAccess.file_exists(SKINS_JSON):
_load_skin_registry(SKINS_JSON, true)
func _load_skin_registry(path: String, warn_if_malformed: bool) -> void:
if not FileAccess.file_exists(path):
return
var file := FileAccess.open(SKINS_JSON, FileAccess.READ)
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return
var data = JSON.parse_string(file.get_as_text())
file.close()
if not data is Dictionary or not data.has("skins"):
push_warning("SkinManager: skins.json is malformed")
if warn_if_malformed:
push_warning("SkinManager: %s is malformed" % path)
return
for entry in data["skins"]:
if not entry is Dictionary or not entry.has("id"):
@@ -65,6 +104,7 @@ func _load_skins_json() -> void:
skin.skin_name = entry.get("name", entry["id"])
skin.description = entry.get("description", "")
skin.model_path = entry.get("model", "")
skin.set("mecha_theme", entry.get("mecha_theme", ""))
skin.is_unlocked = entry.get("unlocked", true)
var viewmodel = entry.get("viewmodel", {})
if viewmodel is Dictionary:
+222 -48
View File
@@ -1,6 +1,8 @@
extends Node3D
class_name SkinnedPlayerModel
const MECHA_KIT_SCRIPT := preload("res://characters/anime_mecha_kit.gd")
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
## with a Mixamo-compatible skeleton and the canonical animation set.
##
@@ -18,6 +20,7 @@ class_name SkinnedPlayerModel
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@export var mecha_theme: String = ""
@export var first_person_mode: bool = false
@export var shadows_only: bool = false
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
@@ -89,20 +92,21 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
"RunForward", "RunBackward", "RunLeft", "RunRight", "Dance",
"EmoteStretch", "EmoteCall", "EmoteYes", "EmoteNo", "PistolIdle"]
const BLEND_TIME := 0.32
const BLEND_TIME := 0.36
## Per-clip blend overrides. Reaction moves still need to read as instant, but
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
## drifts across their thresholds, and that is where hard cuts were most
## visible.
const BLEND_TIMES := {
"Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16,
"Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24,
"Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28,
"Slide": 0.30, "Death": 0.34, "Throw": 0.20, "PistolReload": 0.30,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28,
"WallRunLeft": 0.18, "WallRunRight": 0.18,
"WallCling": 0.28, "Grapple": 0.28,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32,
"WallRunLeft": 0.26, "WallRunRight": 0.26,
"WallCling": 0.32, "Grapple": 0.32,
}
const GROUND_STATE_BLEND := 0.30
## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter.
@@ -120,11 +124,14 @@ var loaded: bool = false
## performance's forward axis with gameplay travel; it never rotates bones or
## manufactures poses.
var _motion_root: Node3D
var _wall_run_velocity_world := Vector3.ZERO
var _wall_glide_velocity_world := Vector3.ZERO
var _motion_yaw := 0.0
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
## True only for the ignored local evaluation builds made from the copyrighted
## pilot reference. Shipping characters retain the original jet-glide fallback.
var _has_titanfall_motion_reference := false
var _weapon_attachment: BoneAttachment3D
var _grapple_attachment: BoneAttachment3D
var _grapple_shoulder_attachment: BoneAttachment3D
@@ -138,6 +145,7 @@ var _rig_info: Dictionary = {}
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var _mecha_kit: AnimeMechaKit
var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks.
@@ -189,6 +197,12 @@ var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _target_wall: float = 0.0
var _loco_blend_target := Vector2.ZERO
var _loco_blend_visual := Vector2.ZERO
var _loco_scale_target := 1.0
var _loco_scale_visual := 1.0
var _crouch_blend_target := 0.0
var _crouch_blend_visual := 0.0
var _cur_ads: float = 0.0
var _cur_wall: float = 0.0
var _owner_visible: bool = false
@@ -206,15 +220,23 @@ func load_model(path: String) -> void:
skeleton = null
animation_player = null
_motion_root = null
_wall_run_velocity_world = Vector3.ZERO
_wall_glide_velocity_world = Vector3.ZERO
_motion_yaw = 0.0
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
_loco_scale_visual = 1.0
_crouch_blend_target = 0.0
_crouch_blend_visual = 0.0
loaded = false
_resolved_clips.clear()
_current_clip = ""
_has_titanfall_motion_reference = false
_current_state_node = ""
_weapon_attachment = null
_grapple_attachment = null
_grapple_shoulder_attachment = null
_mecha_kit = null
# Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST
# character's sidecar.
@@ -240,6 +262,7 @@ func load_model(path: String) -> void:
else:
_ensure_meshes_bound(scene)
_rig_info = _load_rig_info(path)
_apply_rig_animation_speeds()
# `weights_authored` is MEASURED at build time, not inferred from which
# pipeline branch ran: a model that arrives unrigged still gets a
# sidecar, and its nearest-bone weights still need the repair below.
@@ -303,6 +326,12 @@ func load_model(path: String) -> void:
# what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces)
if skeleton and mecha_theme != "":
_apply_mecha_base_suit(scene)
_mecha_kit = MECHA_KIT_SCRIPT.new()
_mecha_kit.name = "AnimeMechaKit"
add_child(_mecha_kit)
_mecha_kit.setup(skeleton, _rig_info.get("roles", {}), mecha_theme)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
@@ -319,6 +348,42 @@ func load_model(path: String) -> void:
_play_ground_locomotion(0.0, false)
## The imported garment is the actual zero-clearance synthetic skin. Recoloring
## that authored, skinned surface is what makes the robotics follow every curve
## and deformation; the procedural kit then only has to add seams and tools.
func _apply_mecha_base_suit(scene: Node) -> void:
if mecha_theme != "sakura" or _surfaces == null:
return
for mesh_node in scene.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := mesh_node as MeshInstance3D
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_index in mesh_instance.mesh.get_surface_count():
var source := mesh_instance.mesh.surface_get_material(surface_index) as BaseMaterial3D
if source == null:
continue
var resolved: Array = _surfaces.resolve(
mesh_instance.name, surface_index, source)
var surface_class := str(resolved[0])
var is_garment := surface_class == SkinSurfaces.CLOTH
# Sakura's import keeps the face on material "Head" and the remaining
# anatomy on material "Body". Turning only that second surface into the
# membrane closes the waist, thigh, arm and hand gaps without tinting her
# face or eyes.
var is_body_membrane := surface_class == SkinSurfaces.BODY \
and source.resource_name.to_lower() == "body"
if not is_garment and not is_body_membrane:
continue
var current := mesh_instance.get_surface_override_material(surface_index)
if current is ShaderMaterial:
var membrane := (current as ShaderMaterial).duplicate(true) as ShaderMaterial
membrane.set_shader_parameter("albedo_color",
Color("303852") if is_garment else Color("3b4563"))
membrane.set_shader_parameter("shadow_color", Color("171b31"))
membrane.set_shader_parameter("mid_tone", 0.86)
membrane.set_shader_parameter("rim_strength", 0.13)
mesh_instance.set_surface_override_material(surface_index, membrane)
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
@@ -414,6 +479,30 @@ func _cloth_hulls(scene: Node) -> Dictionary:
return out
## Character-specific authored gait speeds, measured from source root motion
## before it is stripped for gameplay. This keeps native clips at their
## original cadence instead of imposing the generic library's timing.
func _apply_rig_animation_speeds() -> void:
var speeds: Dictionary = _rig_info.get("animation_speeds", {})
if speeds.is_empty():
return
walk_anim_reference_speed = maxf(
float(speeds.get("walk", walk_anim_reference_speed)), 0.01)
run_anim_reference_speed = maxf(
float(speeds.get("run", run_anim_reference_speed)), 0.01)
sprint_anim_reference_speed = maxf(
float(speeds.get("sprint", sprint_anim_reference_speed)), 0.01)
strafe_walk_anim_reference_speed = maxf(
float(speeds.get("strafe_walk", strafe_walk_anim_reference_speed)), 0.01)
strafe_run_anim_reference_speed = maxf(
float(speeds.get("strafe_run", strafe_run_anim_reference_speed)), 0.01)
print("SkinnedPlayerModel: native gait speeds walk=%.2f run=%.2f sprint=%.2f strafe=%.2f/%.2f m/s" % [
walk_anim_reference_speed, run_anim_reference_speed,
sprint_anim_reference_speed, strafe_walk_anim_reference_speed,
strafe_run_anim_reference_speed,
])
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
@@ -475,6 +564,8 @@ func _ensure_meshes_bound(scene: Node) -> void:
## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void:
var available := animation_player.get_animation_list()
_has_titanfall_motion_reference = \
_find_clip(available, "ReviewWallHangFront") != ""
for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate)
@@ -576,8 +667,8 @@ func _setup_anim_tree(scene: Node) -> void:
var upper := AnimationNodeOneShot.new()
# The upper-body one-shot pops in and out over the locomotion clip, so its
# own fades matter as much as the locomotion cross-fade.
upper.fadein_time = 0.14
upper.fadeout_time = 0.22
upper.fadein_time = 0.20
upper.fadeout_time = 0.30
upper.filter_enabled = true
_upper_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
@@ -740,9 +831,13 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
"slide":
clip = "Slide"
"wall_run":
# Separate authored performances: the contact leg and balancing arm
# are different on each side. This is never a renamed ground run.
clip = "WallRunRight" if _target_wall > 0.0 else "WallRunLeft"
# The jets still own propulsion, but the authored pilot pack supplies a
# distinct compact wall-performance for each side. This is body motion,
# not gameplay root motion or procedural feet planted against the wall.
if _has_titanfall_motion_reference:
clip = "WallRunRight" if _target_wall < 0.0 else "WallRunLeft"
else:
clip = "Grapple"
"wall_cling":
clip = "WallCling"
"wall_climb":
@@ -769,11 +864,9 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
var scale := 1.0
if clip == "Slide":
scale = clampf(speed / run_anim_reference_speed, 0.75, 1.8)
elif clip in ["WallRunLeft", "WallRunRight"]:
scale = clampf(speed / wall_run_anim_reference_speed, 0.85, 1.65)
elif clip == "Grapple":
# Grapple is a held, authored flight pose. Its cadence does not
# represent footsteps and must not accelerate with pull velocity.
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
# Grapple is held, and the wall clips are compact propulsion loops.
# Neither should become a frantic treadmill at high traversal speed.
scale = 1.0
elif clip == "Dash":
scale = clampf(speed / 13.0, 0.75, 1.8)
@@ -790,22 +883,25 @@ func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none.
## Used only to release the wall-side weapon hand; the clip owns body motion.
## Wall side during a wall glide: -1 wall on left, +1 wall on right, 0 none.
func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## Gameplay velocity for aligning the authored lateral wall-run performance.
## Uisco supplies the body lean, contact leg, arm balance, and cadence. This
## supplies only the wall-tangent coordinate frame that an offline clip cannot
## know.
## Gameplay velocity aligns the held glide silhouette with the live wall
## tangent. The mechanic still reports `wall_run` internally for save/network
## compatibility, but the character presentation is entirely propulsion-based.
func set_wall_glide_motion(velocity_world: Vector3) -> void:
_wall_glide_velocity_world = velocity_world
## Compatibility entry point for older controller scenes and debug tools.
func set_wall_run_motion(velocity_world: Vector3) -> void:
_wall_run_velocity_world = velocity_world
set_wall_glide_motion(velocity_world)
## Regression telemetry: the authored model's forward direction in world space.
func wall_run_forward_debug() -> Vector3:
func wall_glide_forward_debug() -> Vector3:
if not is_instance_valid(_motion_root):
return Vector3.ZERO
var source_forward := Vector3.FORWARD if facing_flip else Vector3.BACK
@@ -814,6 +910,32 @@ func wall_run_forward_debug() -> Vector3:
).normalized()
func wall_run_forward_debug() -> Vector3:
return wall_glide_forward_debug()
func set_helmet_closed(closed: bool, immediate: bool = false) -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("set_helmet_closed"):
_mecha_kit.set_helmet_closed(closed, immediate)
func toggle_helmet() -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("toggle_helmet"):
_mecha_kit.toggle_helmet()
func helmet_closed_debug() -> bool:
return _mecha_kit.helmet_closed() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_closed") \
else false
func helmet_progress_debug() -> float:
return _mecha_kit.helmet_progress_debug() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_progress_debug") \
else 0.0
## Live grapple mechanics layered over the authored airborne performance.
## The clip supplies weight and secondary motion; gameplay supplies the one
## constraint an authored fixed pose cannot know: the actual cable direction.
@@ -876,6 +998,8 @@ var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
_update_motion_orientation(delta)
_update_mecha_nozzles()
_update_animation_smoothing(delta)
if not _pose_mod:
return
_hold_weapon_still()
@@ -926,11 +1050,9 @@ func _process(delta: float) -> void:
"slide":
hold_l = 0.0 # trailing arm braces the ground
"wall_run":
# The wall-side arm reaches for the wall.
if _cur_wall > 0.05:
hold_r = 0.0
elif _cur_wall < -0.05:
hold_l = 0.0
# A glide is stabilized by the suit; neither hand needs to touch
# the wall, so weapon posture can remain coherent.
pass
"grapple":
hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r
@@ -949,14 +1071,14 @@ func _update_motion_orientation(delta: float) -> void:
var wanted_yaw := 0.0
if _pose_mod and _pose_mod.state == "wall_run":
var local_velocity := global_transform.basis.inverse() \
* _wall_run_velocity_world
* _wall_glide_velocity_world
local_velocity.y = 0.0
if local_velocity.length_squared() > 0.01:
var source_forward := Vector3.FORWARD \
if facing_flip else Vector3.BACK
wanted_yaw = source_forward.signed_angle_to(
local_velocity.normalized(), Vector3.UP)
var blend := 1.0 - exp(-14.0 * delta)
var blend := 1.0 - exp(-8.5 * delta)
_motion_yaw = lerp_angle(_motion_yaw, wanted_yaw, blend)
_motion_root.rotation.y = _motion_yaw
@@ -990,21 +1112,45 @@ func _update_cloth_lod(delta: float) -> void:
## even though gameplay acceleration reached running speed in one physics frame.
## That is the visible "sliding, then slow walk, then run" failure. Here the
## BlendSpace position is physical metres/second on this call, so the feet react
## on the same frame as the body.
## immediately but ease over a few rendered frames instead of teleporting from
## one gait/direction pose to another.
func _update_animation_smoothing(delta: float) -> void:
if not _anim_tree:
return
var current_speed := _loco_blend_visual.length()
var target_speed := _loco_blend_target.length()
var reversing := current_speed > 0.2 and target_speed > 0.2 \
and _loco_blend_visual.dot(_loco_blend_target) < 0.0
var response := 10.0 if target_speed > current_speed else 6.5
if reversing:
response = 5.5
var position_weight := 1.0 - exp(-response * delta)
_loco_blend_visual = _loco_blend_visual.lerp(
_loco_blend_target, position_weight)
_crouch_blend_visual = lerpf(_crouch_blend_visual,
_crouch_blend_target, 1.0 - exp(-8.0 * delta))
_loco_scale_visual = lerpf(_loco_scale_visual,
_loco_scale_target, 1.0 - exp(-7.0 * delta))
_anim_tree.set("parameters/loco_blend/blend_position", _loco_blend_visual)
_anim_tree.set("parameters/crouch_blend/blend_position", _crouch_blend_visual)
if _current_state_node in ["Locomotion", "CrouchLocomotion"]:
_anim_tree.set("parameters/loco_scale/scale", _loco_scale_visual)
func _play_ground_locomotion(speed: float, crouched: bool) -> void:
if not _anim_tree:
return
var state_node := "CrouchLocomotion" if crouched else "Locomotion"
if _current_state_node != state_node:
_state_trans.xfade_time = 0.16
_state_trans.xfade_time = GROUND_STATE_BLEND
_anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node
if crouched:
var authored_speed := minf(speed, crouch_anim_reference_speed)
_anim_tree.set("parameters/crouch_blend/blend_position", authored_speed)
_anim_tree.set("parameters/loco_scale/scale",
maxf(1.0, speed / maxf(crouch_anim_reference_speed, 0.01)))
_crouch_blend_target = authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(crouch_anim_reference_speed, 0.01))
_current_clip = _resolved_clips.get(
"CrouchWalk" if speed > 0.1 else "Crouch", "")
return
@@ -1028,10 +1174,9 @@ func _play_ground_locomotion(speed: float, crouched: bool) -> void:
)
var authored_limit := 1.0 / maxf(inverse_limit, 0.01)
var authored_speed := minf(speed, authored_limit)
_anim_tree.set("parameters/loco_blend/blend_position",
direction * authored_speed)
_anim_tree.set("parameters/loco_scale/scale",
maxf(1.0, speed / maxf(authored_limit, 0.01)))
_loco_blend_target = direction * authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(authored_limit, 0.01))
var canonical := "Idle"
if speed > 0.1:
@@ -1069,7 +1214,7 @@ func get_brake_debug() -> float:
return 0.0
## Physical velocity currently driving the authored ground BlendSpace.
## Smoothed visual velocity currently driving the authored ground BlendSpace.
func locomotion_blend_debug() -> Vector2:
if not _anim_tree:
return Vector2.ZERO
@@ -1077,7 +1222,11 @@ func locomotion_blend_debug() -> Vector2:
return value if value is Vector2 else Vector2.ZERO
## Velocity represented by the authored stride after playback-rate calibration.
func locomotion_blend_target_debug() -> Vector2:
return _loco_blend_target
## Visual velocity represented by the authored stride after playback-rate calibration.
## The raw BlendSpace position stops at the source clip's measured velocity;
## TimeScale covers faster gameplay without changing the pose or foot phase.
func locomotion_effective_speed_debug() -> float:
@@ -1091,7 +1240,7 @@ func locomotion_effective_speed_debug() -> float:
## Animated world-space anchors for the two shoulder and two foot jets.
## Offsets use the skeleton frame, not each limb's twisting frame, so the
## nozzles stay on the character's back/heels through every authored clip.
func get_jet_socket_world_positions() -> Array[Vector3]:
func _animated_jet_socket_world_positions() -> Array[Vector3]:
if not skeleton:
return []
var roles_and_fallbacks := [
@@ -1118,6 +1267,31 @@ func get_jet_socket_world_positions() -> Array[Vector3]:
return sockets
func _update_mecha_nozzles() -> void:
if not is_instance_valid(_mecha_kit) or not skeleton:
return
var sockets := _animated_jet_socket_world_positions()
if sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
func get_jet_socket_world_positions() -> Array[Vector3]:
var sockets := _animated_jet_socket_world_positions()
if is_instance_valid(_mecha_kit) and sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
return _mecha_kit.nozzle_world_positions()
return sockets
func mecha_nozzle_count_debug() -> int:
return _mecha_kit.nozzle_count() if is_instance_valid(_mecha_kit) else 0
func mecha_detail_part_count_debug() -> int:
return _mecha_kit.detail_part_count_debug() \
if is_instance_valid(_mecha_kit) else 0
## Every canonical clip this character resolved to something real.
##
## Canonical rather than raw, because the raw names differ per character — one
@@ -1442,7 +1616,7 @@ func _role_bone(role: String, fallbacks: Array) -> int:
var roles: Dictionary = _rig_info.get("roles", {})
var actual := String(roles.get(role, ""))
if actual != "" and skeleton:
var b := skeleton.find_bone(actual)
var b := RigRoles.find_imported_bone(skeleton, actual)
if b >= 0:
return b
return _find_bone(fallbacks)
@@ -1688,8 +1862,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t)
# The authored clip owns cadence and weight. Grappling adds the physical
# pendulum frame before the arm constraint is solved.
# Authored clips own the full silhouette. Traversal adds only constraints
# an offline clip cannot know, such as the live grapple cable.
_apply_grapple_body(skel, delta)
if absf(aim_pitch) > 0.01 and not grapple_active:
_apply_aim_pitch(skel)
+166
View File
@@ -0,0 +1,166 @@
extends SceneTree
## Rendered character-art gate for the focused Sakura second-skin pilot.
## Captures open/deploying/closed helmet states and verifies that active jet
## flames emerge from all four integrated vector-jet mouths.
var _scene: Node3D
var _camera: Camera3D
var _models: Array[SkinnedPlayerModel] = []
var _jets: Array[PlayerJetVFX] = []
var _out_dir := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
DirAccess.make_dir_recursive_absolute(_out_dir)
_run()
func _run() -> void:
_scene = Node3D.new()
root.add_child(_scene)
current_scene = _scene
_build_studio()
_add_pilot("default", "sakura",
"res://assets/characters/skins/sakura_pilot_hd.glb", 0.0)
for _i in 24:
await process_frame
for model in _models:
model.update_state("ground", 0.0, false)
model.set_locomotion(0.0, 1.0, 0.0)
_camera.global_position = Vector3(0.0, 1.05, -3.2)
_camera.look_at(Vector3(0.0, 0.92, 0.0), Vector3.UP)
await _capture("pilot_open")
_camera.global_position = Vector3(0.0, 1.58, -1.15)
_camera.look_at(Vector3(0.0, 1.57, 0.0), Vector3.UP)
await _capture("helmet_open_closeup")
_models[0].set_helmet_closed(true)
for _i in 5:
await process_frame
await _capture("pilot_helmet_deploy")
for _i in 40:
await process_frame
await _capture("pilot_helmet_closed")
_camera.global_position = Vector3(0.0, 1.08, 3.2)
_camera.look_at(Vector3(0.0, 0.92, 0.0), Vector3.UP)
await _capture("pilot_rear")
for jet in _jets:
jet.burst_double_jump()
for _i in 4:
await process_frame
await _capture("pilot_thrusters")
_models[0].set_wall_side(-1.0)
_models[0].set_wall_glide_motion(Vector3.RIGHT * 13.0)
for _i in 24:
_models[0].update_state("wall_run", 13.0, false)
_jets[0].set_wall_glide(true, Vector2.RIGHT)
await process_frame
_camera.global_position = Vector3(2.4, 1.45, -2.7)
_camera.look_at(Vector3(0.0, 1.0, 0.0), Vector3.UP)
await _capture("pilot_wall_glide")
var failed := false
if not _models[0].helmet_closed_debug() \
or _models[0].helmet_progress_debug() < 0.98:
push_error("ANIME_MECHA_CAPTURE helmet failed to finish deployment")
failed = true
for model_index in _models.size():
var model := _models[model_index]
var resolved_roles: Array[String] = []
var missing_roles: Array[String] = []
for role in ["hips", "shoulder.L", "shoulder.R", "upper_arm.L",
"upper_arm.R", "forearm.L", "forearm.R", "thigh.L",
"thigh.R", "shin.L", "shin.R", "foot.L", "foot.R"]:
if model._role_bone(role, []) >= 0:
resolved_roles.append(role)
else:
missing_roles.append(role)
print("ANIME_MECHA_CAPTURE %s: %d detail parts, %d nozzles, " % [
model.skin_id, model.mecha_detail_part_count_debug(),
model.mecha_nozzle_count_debug()], "resolved=", resolved_roles,
" missing=", missing_roles)
if not missing_roles.is_empty():
var bone_names: Array[String] = []
if model.skeleton:
for bone_index in model.skeleton.get_bone_count():
bone_names.append(model.skeleton.get_bone_name(bone_index))
print("ANIME_MECHA_CAPTURE unresolved sidecar roles=",
model._rig_info.get("roles", {}), " skeleton bones=", bone_names)
if model.mecha_detail_part_count_debug() < 180:
push_error("ANIME_MECHA_CAPTURE insufficient hardware detail on %s: %d parts" % [
model.skin_id, model.mecha_detail_part_count_debug()])
failed = true
if model.mecha_nozzle_count_debug() != 4:
push_error("ANIME_MECHA_CAPTURE missing four visible nozzles on %s" % model.skin_id)
failed = true
var nozzles := model.get_jet_socket_world_positions()
var flames := _jets[model_index].get_flame_world_positions()
if nozzles.size() != 4 or flames.size() != 4:
push_error("ANIME_MECHA_CAPTURE socket count mismatch on %s" % model.skin_id)
failed = true
continue
for nozzle_index in 4:
if nozzles[nozzle_index].distance_to(flames[nozzle_index]) > 0.001:
push_error("ANIME_MECHA_CAPTURE flame %d misses %s nozzle" % [
nozzle_index, model.skin_id])
failed = true
print("ANIME_MECHA_CAPTURE %s" % ("FAIL" if failed else "PASS"))
quit(1 if failed else 0)
func _build_studio() -> void:
var world := WorldEnvironment.new()
var environment := Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color("b8c0cc")
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color("e8edf6")
environment.ambient_light_energy = 1.15
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
world.environment = environment
_scene.add_child(world)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-42.0, -28.0, 0.0)
sun.light_energy = 1.9
_scene.add_child(sun)
_camera = Camera3D.new()
_camera.fov = 38.0
_scene.add_child(_camera)
_camera.current = true
func _add_pilot(id: String, theme: String, path: String, x: float) -> void:
var model := SkinnedPlayerModel.new()
model.skin_id = id
model.mecha_theme = theme
model.model_path = path
model.position.x = x
_scene.add_child(model)
# Static art QA does not need the expensive full-quality hair/cloth solver;
# gameplay and the picker still use their normal distance-driven LOD.
if model._spring_mod:
model._spring_mod.lod = 3
model._spring_mod = null
_models.append(model)
var jet := PlayerJetVFX.new()
jet.name = "JetVFX_%s" % id
_scene.add_child(jet)
jet.set_visual_model(model)
jet.set_render_enabled(true)
_jets.append(jet)
func _capture(tag: String) -> void:
for _i in 5:
await process_frame
var path := _out_dir.path_join(tag + ".png")
root.get_texture().get_image().save_png(path)
print("ANIME_MECHA_CAPTURE saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://ctjjlkxakx4ss
+30 -20
View File
@@ -1,11 +1,10 @@
extends SceneTree
## Does the escape menu's character picker actually work?
## Does character selection work as part of every loadout?
##
## It is built entirely in code, in an autoload, over a paused tree — three
## things that each hide their own class of mistake and none of which a compile
## check catches. So: open it, walk every entry, and assert that each one
## selects, describes itself, and builds a real model with a real skeleton.
## The character picker used to be a separate global screen. It now belongs to
## each loadout, so this checks the full-screen editor, saved character fields,
## selected-item summary, and animated preview together.
##
## godot --headless --path . -s res://debug/character_picker_check.gd
@@ -22,29 +21,41 @@ func _init() -> void:
_done()
return
_check(menu.character_btn != null, "Character button exists on the pause menu")
_check(menu.character_list != null, "Character list exists")
_check(menu.character_editor != null, "Character screen exists")
menu._show_character()
menu.visible = true
menu._show_loadouts()
await process_frame
_check(menu.character_editor.visible, "Character screen shows")
_check(menu.loadout_editor.visible, "Loadout editor shows")
_check(not menu.main_vbox.visible, "Main pause list hides behind it")
_check(menu.loadout_editor.anchor_right == 1.0 and menu.loadout_editor.anchor_bottom == 1.0,
"Loadout editor is anchored full-screen")
_check(menu.loadout_list_vbox.get_child_count() == 5, "All five loadouts are shown")
_check(menu.skin_opt != null and menu.skin_opt.item_count > 0,
"Character selector is inside the loadout editor")
# Autoload singletons are not resolvable as identifiers from a `-s` SceneTree
# script — it is compiled before they register — so reach it by path.
var skin_mgr = root.get_node("SkinManager")
var loadout_mgr = root.get_node("LoadoutManager")
for loadout_index in loadout_mgr.loadouts.size():
var loadout: Dictionary = loadout_mgr.loadouts[loadout_index]
_check(loadout.has("skin"), "'%s' stores its own character" % loadout.get("name", "Loadout"))
menu._edit_loadout(loadout_index)
_check(menu._selected_metadata(menu.skin_opt) == str(loadout.get("skin", "default")),
"'%s' opens with its saved character selected" % loadout.get("name", "Loadout"))
_check(menu.loadout_summary.text.contains(menu._weapon_name(str(loadout.get("primary_1", "none")))),
"'%s' summary displays its selected items" % loadout.get("name", "Loadout"))
var count: int = menu.character_list.item_count
var count: int = menu.skin_opt.item_count
_check(count > 0, "Roster is not empty (%d entries)" % count)
var seen_glb := 0
for i in count:
var id: String = menu.character_list.get_item_metadata(i)
menu._on_character_selected(i)
var id: String = menu.skin_opt.get_item_metadata(i)
menu.skin_opt.select(i)
menu._on_skin_option_selected(i)
await process_frame
await process_frame
_check(menu.character_desc.text != "", "'%s' has a description line" % id)
_check(menu.loadout_summary.text.contains(skin_mgr.get_skin(id).skin_name),
"'%s' appears in the selected-item summary" % id)
var skin = skin_mgr.get_skin(id)
var expects_model: bool = skin.model_path != "" \
@@ -78,12 +89,11 @@ func _init() -> void:
_check(await _pose_moves(model),
"'%s' preview skeleton is actually animating" % id)
_check(seen_glb >= 6, "every shipping GLB skin previewed (%d)" % seen_glb)
_check(seen_glb > 0, "shipping GLB character previews were built (%d)" % seen_glb)
# Back out, and make sure the turntable stops costing frames.
menu._show_main_menu()
await process_frame
_check(not menu.character_editor.visible, "Back returns to the pause list")
_check(not menu.loadout_editor.visible, "Back closes the loadout editor")
_check(menu.main_vbox.visible, "Pause list is showing again")
_done()
@@ -119,5 +129,5 @@ func _check(ok: bool, what: String) -> void:
func _done() -> void:
print("\n=== CHARACTER PICKER ===\nFailures: %d" % _fails)
print("\n=== LOADOUT CHARACTER SELECTOR ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+4 -1
View File
@@ -5,6 +5,7 @@ extends SceneTree
var _frames := 0
var _out_dir := "."
var _skin_id := "default"
var _player: Node = null
var _model: Node = null
var _jets: PlayerJetVFX = null
@@ -17,6 +18,8 @@ func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if not args.is_empty():
_out_dir = args[0]
if args.size() > 1:
_skin_id = args[1]
if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir)
var err := DirAccess.make_dir_recursive_absolute(_out_dir)
@@ -32,7 +35,7 @@ func _process(_delta: float) -> bool:
if _frames == 35:
var skins = root.get_node_or_null("SkinManager")
if skins:
skins.set_active_skin("miku")
skins.set_active_skin(_skin_id)
var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH)
+1 -15
View File
@@ -33,22 +33,8 @@ func _run() -> void:
_expect(dash_exhaust.z > 0.90, "forward dash exhaust must point backward")
_expect(dash_exhaust.y < 0.0, "dash exhaust keeps a slight downward cant")
var humanoid := HumanoidModel.new()
host.add_child(humanoid)
await process_frame
humanoid.set_locomotion(0.0, -1.0, 0.0)
humanoid.update_state("ground", 9.0)
humanoid._process(0.1)
_expect(humanoid.root_pivot.rotation.x > 0.0,
"fallback model must lean backward while backpedalling")
humanoid.update_state("ground", 5.0)
humanoid._process(0.1)
_expect(humanoid._brake_left > 0.0, "deceleration must arm the braking pose")
_expect(absf(humanoid.thigh_r_pivot.rotation.x) > 0.05,
"braking pose must plant a lead foot")
if _failures.is_empty():
print("JET_POSE_TEST PASS: four flames, directional thrust, reverse lean, brake pose")
print("JET_POSE_TEST PASS: four flames and directional thrust")
quit(0)
else:
for failure in _failures:
+18 -13
View File
@@ -18,7 +18,8 @@ var _camera: Camera3D = null
var _motion_frame := 0
var _stop_frame := -1
var _worst_run_stride_error := 0.0
var _worst_stop_stride := 0.0
var _first_stop_stride := 0.0
var _settled_stop_stride := 0.0
var _failures: Array[String] = []
@@ -62,8 +63,9 @@ func _process(_delta: float) -> bool:
if _motion_frame <= RUN_FRAMES:
_model.set_locomotion(0.0, 1.0, 0.0)
_model.update_state("ground", 9.0, false)
_worst_run_stride_error = maxf(_worst_run_stride_error,
absf(_model.locomotion_effective_speed_debug() - 9.0))
if _motion_frame > 30:
_worst_run_stride_error = maxf(_worst_run_stride_error,
absf(_model.locomotion_effective_speed_debug() - 9.0))
if _motion_frame == RUN_FRAMES - 1:
_snap("loco_stop_00_run")
else:
@@ -73,28 +75,31 @@ func _process(_delta: float) -> bool:
_stop_frame += 1
_model.set_locomotion(0.0, 0.0, 0.0)
_model.update_state("ground", 0.0, false)
_worst_stop_stride = maxf(_worst_stop_stride,
_model.locomotion_effective_speed_debug())
if _stop_frame == 0:
_first_stop_stride = _model.locomotion_effective_speed_debug()
_settled_stop_stride = _model.locomotion_effective_speed_debug()
_model._process(DT)
if _stop_frame >= 0:
match _stop_frame:
3:
_snap("loco_stop_01_idle_blend")
9:
12:
_snap("loco_stop_02_idle_arrival")
20:
30:
_snap("loco_stop_03_idle")
50:
_snap("loco_stop_04_settled")
if _worst_run_stride_error > 0.02:
if _worst_run_stride_error > 0.20:
_failures.append("run stride diverged from physical speed")
if _worst_stop_stride > 0.02:
_failures.append("authored stride did not stop immediately")
if _first_stop_stride < 4.0:
_failures.append("run pose snapped to idle on the stop frame")
if _settled_stop_stride > 0.5:
_failures.append("authored stride did not ease into idle")
if absf(_model.get_brake_debug()) > 0.001:
_failures.append("procedural brake pose is still active")
_finish()
return true
return false
return false
@@ -145,8 +150,8 @@ func _snap(tag: String) -> void:
func _finish() -> void:
if _failures.is_empty() and _stop_frame >= 50:
print("LOCO_STOP PASS: run error %.5f, stop stride %.5f, procedural brake %.5f" % [
_worst_run_stride_error, _worst_stop_stride,
print("LOCO_STOP PASS: run error %.5f, stop ease %.5f -> %.5f, procedural brake %.5f" % [
_worst_run_stride_error, _first_stop_stride, _settled_stop_stride,
_model.get_brake_debug()])
quit(0)
else:
+8 -8
View File
@@ -1,6 +1,6 @@
extends SceneTree
## Photograph every character in the picker, one shot each.
## Photograph every character in the loadout selector, one shot each.
##
## The picker builds a real SkinnedPlayerModel per character, so this is the
## cheapest full-roster visual check there is: it catches a rig whose idle pose
@@ -56,7 +56,7 @@ func _process(_delta: float) -> bool:
quit(1)
return true
_menu.visible = true
_menu._show_character()
_menu._show_loadouts()
_next()
return false
if (_frames - 150) % SETTLE == 0:
@@ -67,11 +67,11 @@ func _process(_delta: float) -> bool:
func _next() -> void:
_index += 1
while _index < _menu.character_list.item_count:
var id: String = _menu.character_list.get_item_metadata(_index)
while _index < _menu.skin_opt.item_count:
var id: String = _menu.skin_opt.get_item_metadata(_index)
# Colour-tint skins have no model; there is nothing to photograph.
_menu.character_list.select(_index)
_menu._on_character_selected(_index)
_menu.skin_opt.select(_index)
_menu._on_skin_option_selected(_index)
if _menu._preview_model != null:
_posing = id
return
@@ -88,8 +88,8 @@ func _shoot() -> void:
# Crop to the preview panel — the rest of the frame is the level behind it.
var w := img.get_width()
var h := img.get_height()
img = img.get_region(Rect2i(int(w * 0.44), int(h * 0.15),
int(w * 0.32), int(h * 0.68)))
img = img.get_region(Rect2i(int(w * 0.735), int(h * 0.145),
int(w * 0.245), int(h * 0.82)))
var path := "%s/roster_%s.png" % [_out_dir, id]
var save_error := img.save_png(path)
if save_error == OK:
+12
View File
@@ -49,6 +49,18 @@ const VIEWS := [
["sidewalk_corner", Vector3(13.0, 10.0, 7.0), Vector3(5.9, 0.2, 14.1), 52.0],
["street_sign", Vector3(3.5, 1.8, -18.0), Vector3(8.15, 2.0, -21.85), 46.0],
["bikes", Vector3(20.0, 2.0, 8.0), Vector3(32.0, 1.2, 13.0), 55.0],
# Grazing-angle regression views for the fullscreen depth-ink pass. A flat
# ground plane must not turn into a dark wedge as the camera approaches the
# horizon; these three pitches keep that failure visible and reproducible.
["ground_grazing", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 70.0],
["ground_shallow", Vector3(0.0, 1.7, 62.0), Vector3(0.0, -5.0, -70.0), 70.0],
["ground_normal", Vector3(0.0, 1.7, 62.0), Vector3(0.0, -28.0, -70.0), 70.0],
["ground_zoomed", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 35.0],
["ground_wide", Vector3(0.0, 1.7, 62.0), Vector3(0.0, 1.7, -70.0), 95.0],
["shopping_band", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 70.0],
["shopping_band_zoomed", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 35.0],
["shopping_band_wide", Vector3(-4.0, 1.7, -30.0), Vector3(80.0, 1.7, -30.0), 95.0],
["park_ground_grazing", Vector3(34.0, 1.7, -65.0), Vector3(90.0, 1.7, -65.0), 70.0],
]
var _out_dir := "."
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## Regression audit for the grazing-angle dark band that the ink post-process
## used to paint across flat roads and walls. The sampled view reproduces the
## original report exactly; both distant road samples must remain close to an
## unobstructed road sample nearer the camera.
const MAP := "res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
const FAR_ROAD_UVS := [Vector2(0.46875, 0.52778), Vector2(0.4948, 0.537)]
const REFERENCE_ROAD_UV := Vector2(0.5, 0.62)
const MIN_LUMINANCE_RATIO := 0.75
func _sample(image: Image, uv: Vector2) -> Color:
var x := clampi(roundi(uv.x * image.get_width()), 0, image.get_width() - 1)
var y := clampi(roundi(uv.y * image.get_height()), 0, image.get_height() - 1)
return image.get_pixel(x, y)
func _initialize() -> void:
_run()
func _run() -> void:
var level := (load(MAP) as PackedScene).instantiate()
root.add_child(level)
for _i in 120:
await process_frame
var camera := Camera3D.new()
camera.fov = 70.0
camera.far = 1200.0
level.add_child(camera)
camera.current = true
camera.global_position = Vector3(-4.0, 1.7, -30.0)
camera.look_at(Vector3(80.0, 1.7, -30.0), Vector3.UP)
for _i in 6:
await process_frame
var image := root.get_texture().get_image()
var reference_luma := _sample(image, REFERENCE_ROAD_UV).get_luminance()
var minimum_far_luma := 1.0
for uv in FAR_ROAD_UVS:
minimum_far_luma = minf(minimum_far_luma, _sample(image, uv).get_luminance())
var ratio := minimum_far_luma / maxf(reference_luma, 0.001)
var failed := ratio < MIN_LUMINANCE_RATIO
if failed:
push_error("GROUND_BAND_AUDIT dark distant road: far/reference=%.3f (minimum %.3f)" % [
ratio, MIN_LUMINANCE_RATIO])
else:
print("GROUND_BAND_AUDIT PASS far/reference=%.3f" % ratio)
var args := OS.get_cmdline_user_args()
if not args.is_empty():
var output := args[0]
if not output.is_absolute_path():
output = ProjectSettings.globalize_path(output)
DirAccess.make_dir_recursive_absolute(output.get_base_dir())
image.save_png(output)
print("GROUND_BAND_AUDIT saved ", output)
level.queue_free()
for _i in 6:
await process_frame
quit(1 if failed else 0)
+1
View File
@@ -0,0 +1 @@
uid://bij2lmy28usm3
+1
View File
@@ -281,6 +281,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
+77 -63
View File
@@ -1,27 +1,27 @@
extends SceneTree
## Regression for the original "slide, then walk, then run" locomotion bug.
##
## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb]
##
## Ground acceleration is the same exponential response as state_ground.gd.
## The test proves that the authored BlendSpace receives that physical speed on
## the same frame, including the first high-acceleration frame and the stop
## frame. It also proves that the removed procedural lean/brake layer stays off.
## Regression for natural authored locomotion transitions.
## Gameplay velocity remains responsive, but the visual BlendSpace must ease
## into acceleration, direction changes and idle instead of teleporting the
## skeleton between unrelated poses on one frame.
const RESPONSE := 60.0
const TOP := 11.0
const DT := 1.0 / 60.0
const RUN_FRAMES := 24
const RUN_FRAMES := 48
const STOP_FRAMES := 60
var _frames := 0
var _model: SkinnedPlayerModel = null
var _model: SkinnedPlayerModel
var _speed := 0.0
var _first_speed := -1.0
var _first_stride := -1.0
var _worst_error := 0.0
var _stop_stride := -1.0
var _manual_tick := false
var _first_speed := 0.0
var _first_stride := 0.0
var _settled_run_stride := 0.0
var _first_stop_stride := 0.0
var _mid_stop_stride := 0.0
var _settled_stop_stride := 0.0
var _largest_stride_step := 0.0
var _previous_stride := 0.0
var _foot_bone := -1
var _foot_rotation_start := Quaternion.IDENTITY
var _foot_rotation_delta := 0.0
@@ -29,8 +29,8 @@ var _foot_rotation_delta := 0.0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/miku.glb"
var path: String = args[0] if not args.is_empty() \
else "res://assets/characters/skins/kiyoko.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
@@ -43,26 +43,26 @@ func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
if not _manual_tick:
if _frames == 8:
_model.set_process(false)
_manual_tick = true
if _model._spring_mod:
_model._spring_mod.lod = 3
_model._spring_mod = null
_foot_bone = _model._role_bone("foot.L",
["foot.L", "LeftFoot", "Left ankle"])
var sample := _frames - 8
if sample < RUN_FRAMES:
var response := 1.0 - exp(-RESPONSE * DT)
_speed = lerpf(_speed, TOP, response)
_speed = lerpf(_speed, TOP, 1.0 - exp(-RESPONSE * DT))
else:
_speed = 0.0
# Same order as the live controller: direction first, then physical speed.
_model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0)
_model.update_state("ground", _speed, false)
_model._process(DT)
var stride := _model.locomotion_effective_speed_debug()
_worst_error = maxf(_worst_error, absf(stride - _speed))
_largest_stride_step = maxf(_largest_stride_step, absf(stride - _previous_stride))
_previous_stride = stride
if _foot_bone >= 0:
var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone)
if sample == 1:
@@ -70,46 +70,60 @@ func _process(_delta: float) -> bool:
elif sample > 1 and sample < RUN_FRAMES:
_foot_rotation_delta = maxf(_foot_rotation_delta,
_foot_rotation_start.angle_to(foot_rotation))
if sample == 0:
_first_speed = _speed
_first_stride = stride
if sample == RUN_FRAMES:
_stop_stride = stride
if sample > RUN_FRAMES + 2:
print("\n=== velocity-driven locomotion ===")
print(" first frame: body %.3f m/s, authored stride %.3f m/s" \
% [_first_speed, _first_stride])
print(" worst stride/speed error: %.5f m/s" % _worst_error)
print(" stop-frame stride: %.5f m/s" % _stop_stride)
print(" animated foot rotation delta: %.5f rad" % _foot_rotation_delta)
print(" procedural lean %.5f, brake %.5f" \
% [_model.get_lean_debug(), _model.get_brake_debug()])
var failures: Array[String] = []
if _first_speed < 6.0:
failures.append("test did not reproduce high first-frame acceleration")
if absf(_first_stride - _first_speed) > 0.02:
failures.append("legs did not receive first-frame physical speed")
if _worst_error > 0.02:
failures.append("authored stride diverged from physical speed")
if _stop_stride > 0.02:
failures.append("legs did not reach Idle on the stop frame")
if _foot_bone < 0:
failures.append("could not resolve a foot bone")
elif _foot_rotation_delta < 0.03:
failures.append("runtime foot pose stayed static")
if absf(_model.get_lean_debug()) > 0.02:
failures.append("procedural locomotion lean is still active")
if absf(_model.get_brake_debug()) > 0.02:
failures.append("procedural stop pose is still active")
if failures.is_empty():
print("TRANSITION_CHECK PASS: same-frame, authored locomotion\n")
quit(0)
else:
for failure in failures:
push_error("TRANSITION_CHECK FAIL: " + failure)
quit(1)
elif sample == RUN_FRAMES - 1:
_settled_run_stride = stride
elif sample == RUN_FRAMES:
_first_stop_stride = stride
elif sample == RUN_FRAMES + 12:
_mid_stop_stride = stride
elif sample == RUN_FRAMES + STOP_FRAMES:
_settled_stop_stride = stride
_finish()
return true
return false
func _finish() -> void:
_model.update_state("dash", 13.0, false)
var dash_crossfade := _model._state_trans.xfade_time
_model.update_state("ground", 0.0, false)
var ground_crossfade := _model._state_trans.xfade_time
print("\n=== NATURAL LOCOMOTION TRANSITIONS ===")
print(" first frame body %.3f, visual stride %.3f" % [_first_speed, _first_stride])
print(" settled run %.3f, stop %.3f -> %.3f -> %.3f" % [
_settled_run_stride, _first_stop_stride, _mid_stop_stride,
_settled_stop_stride])
print(" largest visual stride step %.3f m/s" % _largest_stride_step)
print(" traversal/ground crossfades %.3f / %.3f s" % [
dash_crossfade, ground_crossfade])
var failures: Array[String] = []
if _first_speed < 6.0:
failures.append("test did not reproduce high gameplay acceleration")
if _first_stride <= 0.15 or _first_stride >= _first_speed * 0.65:
failures.append("acceleration visual either froze or snapped on frame one")
if absf(_settled_run_stride - TOP) > 0.30:
failures.append("visual stride did not converge to physical run speed")
if _first_stop_stride < _settled_run_stride * 0.65:
failures.append("run pose snapped too close to idle on the stop frame")
if _mid_stop_stride >= _first_stop_stride or _mid_stop_stride < 0.5:
failures.append("run-to-idle recovery is not a progressive ease")
if _settled_stop_stride > 0.30:
failures.append("run-to-idle recovery did not settle")
if _largest_stride_step > 2.5:
failures.append("a rendered frame contains an excessive gait jump")
if dash_crossfade < 0.19 or ground_crossfade < 0.28:
failures.append("full-body state crossfades are still too abrupt")
if _foot_bone < 0 or _foot_rotation_delta < 0.03:
failures.append("authored foot animation did not move")
if failures.is_empty():
print("TRANSITION_CHECK PASS: responsive gameplay, eased animation\n")
quit(0)
else:
for failure in failures:
push_error("TRANSITION_CHECK FAIL: " + failure)
quit(1)
+9 -2
View File
@@ -11,7 +11,8 @@ extends SceneTree
## This is the failure mode the test exists to catch: the old lateral cycle was
## technically "matched" at 5 m/s but exploded to 6.1x playback in gameplay.
const SPEED := 11.0
const SETTLE_FRAMES := 18
const DT := 1.0 / 60.0
const SETTLE_FRAMES := 45
const SAMPLE_FRAMES := 24
const CASES := [
["forward", 0.0, 1.0, "Sprint"],
@@ -48,6 +49,10 @@ func _run() -> void:
_expect(false, "the authored character loads")
_done()
return
if model._spring_mod:
model._spring_mod.lod = 3
model._spring_mod = null
model.set_process(false)
var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
_expect(foot >= 0, "a foot bone resolves")
@@ -57,13 +62,14 @@ func _run() -> void:
for _frame in SETTLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false)
model._process(DT)
await process_frame
var blend := model.locomotion_blend_debug()
_expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999,
"%s drives the authored BlendSpace in the physical direction"
% String(test_case[0]))
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.02,
_expect(absf(model.locomotion_effective_speed_debug() - SPEED) < 0.25,
"%s authored stride matches %.1f m/s"
% [String(test_case[0]), SPEED])
var expected_clip := String(test_case[3])
@@ -77,6 +83,7 @@ func _run() -> void:
for _frame in SAMPLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false)
model._process(DT)
await process_frame
greatest = maxf(greatest, start.angle_to(
model.skeleton.get_bone_pose_rotation(foot)))
+27 -21
View File
@@ -1,16 +1,17 @@
extends SceneTree
## Regression gate for authored wall running and physical grapple posing.
## Regression gate for jet-propelled wall traversal and physical grapple posing.
##
## Wall running must select side-specific authored performances, never a ground
## run under another name. Grapple must keep its authored airborne base while
## Wall traversal must select the authored side-specific pilot performances,
## keep their source timing independent of gameplay speed, and face along the
## live wall tangent. Jets still own propulsion; the animation owns silhouette.
## Grapple must keep its authored airborne base while
## the live arm constraint lands the palm on the cable ray and body alignment
## responds to pendulum energy.
const SETTLE_FRAMES := 90
const SAMPLE_FRAMES := 30
const WALL_RUN_SPEED := 13.0
const WALL_RUN_REFERENCE_SPEED := 8.25
const WALL_GLIDE_SPEED := 13.0
var _failures := 0
@@ -48,27 +49,27 @@ func _run() -> void:
"hips and both hands resolve")
model.set_wall_side(-1.0)
model.set_wall_run_motion(Vector3(13.0, 0.0, 0.0))
await _settle(model, "wall_run", WALL_RUN_SPEED)
model.set_wall_glide_motion(Vector3(13.0, 0.0, 0.0))
await _settle(model, "wall_run", WALL_GLIDE_SPEED)
_expect(model.current_clip_debug() == "WallRunLeft",
"left wall selects the authored WallRunLeft performance")
"left wall traversal selects its authored pilot performance")
var wall_scale := float(model._anim_tree.get("parameters/loco_scale/scale"))
_expect(absf(wall_scale - WALL_RUN_SPEED / WALL_RUN_REFERENCE_SPEED) < 0.02,
"wall-run cadence tracks %.1f m/s (%.3fx)"
% [WALL_RUN_SPEED, wall_scale])
_expect(absf(wall_scale - 1.0) < 0.02,
"wall traversal keeps the authored source timing (%.3fx)"
% wall_scale)
var wall_foot_motion := await _sample_foot_motion(
model, foot, "wall_run", WALL_RUN_SPEED)
_expect(wall_foot_motion > 0.30,
"wall-run has a full wall-stride leg cycle (%.3f rad)"
model, foot, "wall_run", WALL_GLIDE_SPEED)
_expect(wall_foot_motion > 0.12,
"wall traversal retains its authored propulsion cycle (%.3f rad motion)"
% wall_foot_motion)
var wall_forward := model.wall_run_forward_debug()
var wall_forward := model.wall_glide_forward_debug()
_expect(wall_forward.dot(Vector3.RIGHT) > 0.97,
"wall-run model faces along wall-tangent velocity (dot %.3f)"
"wall-glide model faces along wall-tangent velocity (dot %.3f)"
% wall_forward.dot(Vector3.RIGHT))
model.set_wall_side(1.0)
await _settle(model, "wall_run", WALL_RUN_SPEED)
await _settle(model, "wall_run", WALL_GLIDE_SPEED)
_expect(model.current_clip_debug() == "WallRunRight",
"right wall selects the authored WallRunRight performance")
"right wall traversal selects its authored pilot performance")
_expect(not model.clip_names_debug().has("WallRun"),
"the relabelled ground-run WallRun clip is absent")
@@ -98,7 +99,8 @@ func _run() -> void:
model.set_locomotion(0.0, -1.0, 0.0)
await _settle(model, "ground", 8.25)
var spine_names: Array = model._rig_info.get("roles", {}).get("spine", [])
var first_spine := model.skeleton.find_bone(String(spine_names[0])) \
var first_spine := RigRoles.find_imported_bone(
model.skeleton, String(spine_names[0])) \
if not spine_names.is_empty() else -1
_expect(first_spine >= 0, "the first spine bone resolves")
if first_spine >= 0:
@@ -113,8 +115,12 @@ func _run() -> void:
var hips_delta := hips_pose * hips_rest.inverse()
var spine_delta := spine_pose * spine_rest.inverse()
var abdomen_twist := hips_delta.angle_to(spine_delta)
_expect(abdomen_twist < 1.45,
"backward capture keeps hips and abdomen coherent (%.3f rad)"
# The Titanfall combat-backpedal deliberately keeps the weapon-facing
# chest counter-rotated over the travelling hips. The old broken capture
# was a full 180-degree lower-body reversal; preserve the authored turn
# while still rejecting that corkscrew.
_expect(abdomen_twist < 2.25,
"backward capture keeps the authored torso counter-turn (%.3f rad)"
% abdomen_twist)
var anchor := model.global_position + Vector3(3.0, 7.0, -6.0)
+3 -3
View File
@@ -11,7 +11,7 @@ extends SceneTree
##
## godot --path . -s res://debug/ui_capture.gd -- <output_dir>
##
## Writes shot_menu.png, shot_pause.png, shot_character.png and
## Writes shot_menu.png, shot_pause.png, shot_loadouts.png and
## shot_settings.png.
var _frames := 0
@@ -41,10 +41,10 @@ func _process(_delta: float) -> bool:
_open("_show_main_menu")
215:
_shot("pause")
_open("_show_character")
_open("_show_loadouts")
320:
# Long enough for the model to load and the springs to settle.
_shot("character")
_shot("loadouts")
_open("_show_settings")
340:
_shot("settings")
+1 -1
View File
@@ -12,7 +12,7 @@ export_filter="all_resources"
; so without this filter an exported build ships characters with no secondary
; motion at all — or no character.
include_filter="*.glb, *.json"
exclude_filter=""
exclude_filter="*grace_howard*, *skins.local.json"
export_path="./Papaya-Shooter.exe"
patches=PackedStringArray()
patch_delta_encoding=false
+33
View File
@@ -73,6 +73,7 @@ var weapon_db = {
var active_loadout_index: int = 0
var loadouts: Array = []
var _legacy_skin_indices: Array[int] = []
func _ready() -> void:
_ensure_default_loadouts()
@@ -83,6 +84,7 @@ func _ensure_default_loadouts() -> void:
for i in range(5):
loadouts.append({
"name": "Loadout " + str(i + 1),
"skin": "default",
"primary_1": "double_barrel_shotgun",
"primary_2": "none",
"special": "none",
@@ -94,6 +96,32 @@ func get_active_loadout() -> Dictionary:
return loadouts[active_loadout_index]
return loadouts[0]
func get_active_skin_id() -> String:
return str(get_active_loadout().get("skin", "default"))
func set_active_loadout(index: int) -> bool:
if index < 0 or index >= loadouts.size():
return false
active_loadout_index = index
save_loadouts()
return true
## Older saves stored character choice in SkinManager's separate config. Copy
## that choice into every legacy loadout once, then the loadout becomes the
## authoritative place for both equipment and character.
func migrate_legacy_skin(skin_id: String) -> void:
if _legacy_skin_indices.is_empty():
return
var migrated := skin_id if not skin_id.is_empty() else "default"
for index in _legacy_skin_indices:
if index >= 0 and index < loadouts.size():
loadouts[index]["skin"] = migrated
_legacy_skin_indices.clear()
save_loadouts()
func save_loadouts() -> void:
var save_data = {
"active_index": active_loadout_index,
@@ -106,6 +134,7 @@ func save_loadouts() -> void:
file.close()
func load_loadouts() -> void:
_legacy_skin_indices.clear()
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file:
@@ -126,6 +155,10 @@ func load_loadouts() -> void:
var sl = saved_loadouts[i]
if sl is Dictionary:
if sl.has("name"): loadouts[i]["name"] = sl["name"]
if sl.has("skin"):
loadouts[i]["skin"] = sl["skin"]
else:
_legacy_skin_indices.append(i)
if sl.has("primary_1"): loadouts[i]["primary_1"] = sl["primary_1"]
if sl.has("primary_2"): loadouts[i]["primary_2"] = sl["primary_2"]
if sl.has("special"): loadouts[i]["special"] = sl["special"]
+18 -3
View File
@@ -88,7 +88,8 @@ var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-glide bank)
var synced_helmet_closed: bool = false
var synced_is_dancing: bool = false # dance emote (B), shown on the model
## Which of the five routines in DanceRoutines is playing. Replicated, so other
## players see the emote that was actually chosen rather than always the first.
@@ -285,6 +286,7 @@ func _update_jet_vfx(state: String, move_dir: Vector2) -> void:
_jet_vfx.set_visual_model(get_visual_model())
_jet_vfx.set_render_enabled(not is_multiplayer_authority() or third_person)
_jet_vfx.set_dash(state == "dash", move_dir)
_jet_vfx.set_wall_glide(state == "wall_run", move_dir)
## Swap the visual model to match a skin id. GLB skins replace the procedural
## model entirely; color skins tint the procedural model.
@@ -305,7 +307,9 @@ func _apply_skin_model(skin_id: String) -> void:
if has_model:
var model := SkinnedPlayerModel.new()
model.name = "SkinnedModel"
model.skin_id = skin_id
model.model_path = skin.model_path
model.mecha_theme = str(skin.get("mecha_theme"))
# Owner gets the first-person body view (head hidden, fully animated);
# everyone else sees the full third-person model.
model.first_person_mode = is_multiplayer_authority()
@@ -937,6 +941,9 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
if Input.is_action_just_pressed("toggle_helmet"):
synced_helmet_closed = not synced_helmet_closed
# Emote (B): HOLD to open the radial dial and point at a dance,
# release to commit. A tap too short to have aimed anything just
# toggles the last one, which is exactly what the button did before
@@ -1064,8 +1071,12 @@ func _physics_process(_delta: float) -> void:
# otherwise the first movement frame still samples last frame's Idle.
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_wall_run_motion"):
if visual.has_method("set_wall_glide_motion"):
visual.set_wall_glide_motion(velocity)
elif visual.has_method("set_wall_run_motion"):
visual.set_wall_run_motion(velocity)
if visual.has_method("set_helmet_closed"):
visual.set_helmet_closed(synced_helmet_closed)
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing, synced_dance_index)
@@ -1156,8 +1167,12 @@ func _process(delta: float) -> void:
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_wall_run_motion"):
if visual.has_method("set_wall_glide_motion"):
visual.set_wall_glide_motion(synced_velocity)
elif visual.has_method("set_wall_run_motion"):
visual.set_wall_run_motion(synced_velocity)
if visual.has_method("set_helmet_closed"):
visual.set_helmet_closed(synced_helmet_closed)
visual.update_state(
synced_movement_state,
synced_movement_speed,
+5
View File
@@ -118,6 +118,11 @@ emote={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":66,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
toggle_helmet={
"deadzone": 0.0,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":72,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
+1
View File
@@ -153,6 +153,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
@@ -86,6 +86,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
+19 -7
View File
@@ -9,7 +9,12 @@ full one-time character build.
Usage:
blender --background --python tools/inject_wallrun.py -- \
<character.glb> <_wallrun.glb> <output.glb>
<character.glb> <animation_library.glb> <output.glb> [ClipName ...]
With no clip names it keeps the original wall-run behavior. Supplying names
also makes this the small post-export repair used for libraries that Blender's
first, very large NLA export occasionally omits (notably RunBackward on some
Auto-Rig Pro characters).
"""
import math
import os
@@ -25,13 +30,20 @@ from retarget_pose import authored_world_rotation, build_segment_pairs
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 3:
if len(argv) < 3:
print(__doc__)
sys.exit(1)
CHARACTER, LIBRARY, OUTPUT = argv
CHARACTER, LIBRARY, OUTPUT = argv[:3]
UP = Vector((0.0, 0.0, 1.0))
CLIPS = ("WallRunLeft", "WallRunRight")
RAW_CLIPS = tuple(argv[3:]) or ("WallRunLeft", "WallRunRight")
# ``Source=Destination`` lets the compact repair give a source action its
# canonical runtime name (used by strafe_back-cycle -> RunBackward).
CLIP_SPECS = tuple(
tuple(raw.split("=", 1)) if "=" in raw else (raw, raw)
for raw in RAW_CLIPS
)
CLIPS = tuple(destination for _source, destination in CLIP_SPECS)
def find_armature(objects):
@@ -270,10 +282,10 @@ def main():
scale = target_h / src_h if src_h > 1e-5 else 1.0
by_name = {action.name.split(".")[0]: action for action in new_actions}
for clip in CLIPS:
source = by_name.get(clip)
for source_name, clip in CLIP_SPECS:
source = by_name.get(source_name)
if source is None:
raise RuntimeError(f"{LIBRARY}: missing {clip}")
raise RuntimeError(f"{LIBRARY}: missing {source_name}")
baked = retarget_action(
target_arm,
target_roles,
+20
View File
@@ -220,6 +220,26 @@ def main() -> None:
cmd.append("--keep-root-motion")
run(cmd, "Retarget animation library (Blender)")
# Blender's large NLA export can omit actions imported from the final
# animation libraries on some FBX/Auto-Rig Pro and Mixamo rigs. Re-inject
# the three critical late-library clips in compact passes. Besides making
# the build deterministic, this prevents backward movement and wall-running
# from silently falling through to unrelated ground/fall animations.
injector = os.path.join(TOOLS, "inject_wallrun.py")
wall_library = os.path.join(args.anim_dir, "_wallrun.glb")
backward_library = os.path.join(args.anim_dir, "_directional.glb")
wall_fixed = os.path.join(STAGING, f"{name}_wall_motion.glb")
fully_fixed = os.path.join(STAGING, f"{name}_required_motion.glb")
run([blender, "--background", "--python", injector, "--",
final_path, wall_library, wall_fixed,
"WallRunLeft", "WallRunRight"],
"Guarantee authored wall-run clips (Blender)")
run([blender, "--background", "--python", injector, "--",
wall_fixed, backward_library, fully_fixed,
"strafe_back-cycle=RunBackward"],
"Guarantee authored backward locomotion (Blender)")
shutil.copyfile(fully_fixed, final_path)
# 3. Gate on the checks that encode every way this has gone wrong before.
run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"),
"--", final_path], "Verify the built character (Blender)")
+14 -5
View File
@@ -123,7 +123,13 @@ LIBRARY_V2_CLIP_MAP = {
# Keep the useful authored front/back steps, but label them honestly.
DIRECTIONAL_CLIP_MAP = {
"strafe_front-cycle": "StrafeWalkForward",
"strafe_back-cycle": "StrafeWalkBackward",
# The same coherent back-step is cadence-scaled for running. This source
# keeps pelvis and abdomen in one frame; the previous CMU capture folded
# the torso on the anime rigs.
"strafe_back-cycle": [
("StrafeWalkBackward", 0.0),
("RunBackward", 0.0),
],
}
# Mesh2Motion's CC0 human add-on library supplies authored forward and lateral
@@ -145,9 +151,7 @@ WALLRUN_CLIP_MAP = {
"WallRunRight": "WallRunRight",
}
CMU_CLIP_MAP = {
"RunBackward": "RunBackward",
}
CMU_CLIP_MAP = {}
UP = Vector((0.0, 0.0, 1.0))
@@ -1192,7 +1196,12 @@ def _weights_look_authored(meshes, roles):
bleeding += 1
if total == 0:
return False
authored = bleeding / total < 0.02 and four / total < 0.5
# Use the same cross-leg ceiling as verify_character. A source can be
# beautifully hand-painted overall yet still carry enough opposite-leg
# weight to fold during backward/side steps. Mark that case for the
# runtime's geometry-aware SkinLegRepair instead of exempting it merely
# because the rest of the weighting looks authored.
authored = bleeding / total < 0.005 and four / total < 0.5
print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
f"{four / total * 100:.0f}% at four influences — "
f"{'authored' if authored else 'solved, runtime repair stays on'}")
+167 -10
View File
@@ -129,6 +129,13 @@ def is_cosmetic(name: str) -> bool:
for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True
# Mixamo-style face bones often put the side first and glue the
# cosmetic role behind it: ``LeftEye`` / ``RightEye``. Without
# this mirror case the spine walker follows the eye above Head and
# resolves a facial joint as the anatomical head.
prefix = t[:-len(c)] if t.endswith(c) else ""
if prefix in ("l", "r", "left", "right"):
return True
return False
@@ -149,6 +156,39 @@ def is_segment_of(name: str, all_names: set) -> bool:
return any(strip_gltf_suffix(n) == stem for n in all_names)
def is_twist_helper(name: str) -> bool:
"""True for a deformation helper that must not own an anatomy role.
Auto-Rig Pro exports the actual upper/lower limb as ``*_stretch`` and a
sibling ``*_twist`` bone for roll distribution. The old resolver saw the
longer word "forearm" in the twist helper first and drove that helper as
the whole limb, leaving the elbow chain folded. A twist bone is never the
joint itself, regardless of naming convention.
"""
return "twist" in tokens(name) or "roll" in tokens(name)
def is_attachment_helper(name: str) -> bool:
"""True for armor/IK/control bones whose name embeds an anatomy word.
Several animation libraries ship their visible equipment in the same
armature. Names such as ``footcover.L``, ``kneepad.R``,
``forearmslider.R`` and ``ik_hand_r`` all matched a body role just as well
as the real foot/knee/forearm/hand. Set iteration then made the winner
arbitrary, which is why lateral movement folded and the wall-run drove
armor controls instead of limbs.
"""
stem = canon(name)
helpers = ("cover", "pad", "plate", "slider", "piston", "adjust",
"scaler", "cannon", "module", "target", "control", "helper",
"leaf", "end")
if any(helper in stem for helper in helpers):
return True
ts = tokens(name)
return any(token in ("ik", "ikt", "ctrl") for token in ts) \
or stem.startswith("ik") or stem.endswith("ik") or stem.endswith("ikt")
class RigRoles:
"""Resolved anatomy of one armature."""
@@ -162,11 +202,78 @@ class RigRoles:
self.hips = None
self.head = None
self.neck = None
self._seed_mmd_standard_limbs()
self._resolve_limbs()
self._fill_limbs_by_anatomy()
self._resolve_spine()
self._override_mmd_standard_trunk()
self._resolve_fingers()
# ---------------------------------------------------------- standard MMD
def _exact_bone(self, *authored_names):
"""Return an exact source bone, allowing only glTF's numeric suffix.
Japanese MMD anatomy names do not transliterate into the English token
families used by Mixamo, Rigify and VRoid. They are nevertheless a
stable published convention, so exact matching is safer than trying to
infer them through MMD's hundreds of IK, physics and adjustment bones.
"""
for authored in authored_names:
for actual in self.names:
if strip_gltf_suffix(actual) == authored:
return actual
return None
def _seed_mmd_standard_limbs(self):
"""Seed the canonical body roles used by standard Japanese MMD rigs."""
# Require multiple unmistakable MMD body joints before applying the
# table. A prop that happens to contain one Japanese label must not be
# mistaken for a humanoid skeleton.
if not (self._exact_bone("左腕", "腕.L")
and self._exact_bone("右腕", "腕.R")
and self._exact_bone("左足", "足.L")
and self._exact_bone("右足", "足.R")):
return
names = {
("shoulder", "L"): ("左肩", "肩.L"),
("upper_arm", "L"): ("左腕", "腕.L"),
("forearm", "L"): ("左ひじ", "ひじ.L"),
("hand", "L"): ("左手首", "手首.L"),
("shoulder", "R"): ("右肩", "肩.R"),
("upper_arm", "R"): ("右腕", "腕.R"),
("forearm", "R"): ("右ひじ", "ひじ.R"),
("hand", "R"): ("右手首", "手首.R"),
("thigh", "L"): ("左足", "足.L"),
("shin", "L"): ("左ひざ", "ひざ.L"),
("foot", "L"): ("左足首", "足首.L"),
("toe", "L"): ("左つま先", "つま先.L"),
("thigh", "R"): ("右足", "足.R"),
("shin", "R"): ("右ひざ", "ひざ.R"),
("foot", "R"): ("右足首", "足首.R"),
("toe", "R"): ("右つま先", "つま先.R"),
}
for role, authored in names.items():
actual = self._exact_bone(*authored)
if actual:
self.limb[role] = actual
def _override_mmd_standard_trunk(self):
"""Keep the spine walker out of MMD eye, hair and control chains."""
hips = self._exact_bone("下半身")
head = self._exact_bone("")
upper = self._exact_bone("上半身")
if not (hips and head and upper):
return
self.hips = hips
self.spine = []
for authored in ("上半身", "上半身1", "上半身2", "", ""):
actual = self._exact_bone(authored)
if actual and actual not in self.spine:
self.spine.append(actual)
self.neck = self._exact_bone("")
self.head = head
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
@@ -192,23 +299,40 @@ class RigRoles:
"""
claims = []
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
if (is_cosmetic(name) or is_segment_of(name, self.names)
or is_twist_helper(name) or is_attachment_helper(name)):
continue
side = side_of(name)
if side is None:
continue
stem = canon(name)
for role in LIMB_ORDER:
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role]
if alt.replace("_", "") in stem]
# Mixamo glues the parent role into every finger name
# (``LeftHandIndex3``). A substring-only resolver therefore
# let an arbitrarily ordered fingertip claim ``hand.L/R`` and
# made all hand-relative equipment/IK disappear at runtime.
# Finger segments are descendants of a hand, never the hand.
if role == "hand" and any(
digit_stem in stem
for digit_stems in DIGITS.values()
for digit_stem in digit_stems):
continue
alternatives = [alt.replace("_", "")
for alt in LIMB_ROLES[role]]
hits = [(len(alt), alternative_index)
for alternative_index, alt in enumerate(alternatives)
if alt in stem]
if hits:
claims.append((len(max(hits, key=len)), role, side, name))
hit_length, alternative_index = max(
hits, key=lambda hit: (hit[0], -hit[1]))
claims.append((hit_length, alternative_index,
role, side, name))
# Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles
# equally well still resolves the way the list intends.
order = {role: i for i, role in enumerate(LIMB_ORDER)}
claims.sort(key=lambda c: (-c[0], order[c[1]]))
claims.sort(key=lambda c: (-c[0], c[1], order[c[2]]))
used = set()
for _, role, side, name in claims:
for _, _alternative, role, side, name in claims:
if (role, side) in self.limb or name in used:
continue
self.limb[(role, side)] = name
@@ -234,7 +358,8 @@ class RigRoles:
b = b.parent
if b is None or b.name not in self.names:
break
if is_cosmetic(b.name) or is_segment_of(b.name, self.names):
if (is_cosmetic(b.name) or is_segment_of(b.name, self.names)
or is_twist_helper(b.name) or is_attachment_helper(b.name)):
continue # a twist segment is not a joint of its own
chain.append(b.name)
# chain is now [shin, thigh, hips, ...] going up from the foot.
@@ -298,7 +423,14 @@ class RigRoles:
blocked = set(self.limb.values())
def candidate(name):
if name in blocked or is_cosmetic(name):
if name in blocked or is_cosmetic(name) or is_attachment_helper(name):
return False
# Exporters commonly append terminal marker joints such as
# ``HeadTop_End``. They carry no anatomy or skin and must not turn
# the marker into the resolved head (which lengthens every neck
# retarget and produces the characteristic folded torso).
if ("end" in tokens(name) or "leaf" in tokens(name)
or canon(name).endswith("core")):
return False
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
for t in tokens(name))
@@ -329,8 +461,33 @@ class RigRoles:
if not best:
return
self.hips = best[0]
self.spine = best[1:]
# Auto-Rig Pro exports ``c_traj -> root -> spine_01`` and hangs both
# thighs from that weighted ``root``. In that specific layout root is
# the pelvis, not a disposable scene control. Promote it only when the
# trunk walk actually starts on a named spine bone; this deliberately
# does not promote the animation libraries' ``root -> hips -> spine``.
base = self.bones[best[0]]
parent = base.parent
base_tokens = tokens(base.name)
thighs = [self.limb.get(("thigh", side)) for side in ("L", "R")]
def descends_from(name, ancestor):
bone = self.bones.get(name)
while bone is not None:
if bone == ancestor:
return True
bone = bone.parent
return False
promote_parent = (
parent is not None
and any("spine" in token for token in base_tokens)
and any(token in ("root", "pelvis", "hips")
for token in tokens(parent.name))
and all(name and descends_from(name, parent) for name in thighs)
)
self.hips = parent.name if promote_parent else best[0]
self.spine = best if promote_parent else best[1:]
if self.spine:
self.head = self.spine[-1]
named = [n for n in self.spine if "neck" in tokens(n)]
+11 -3
View File
@@ -241,13 +241,21 @@ if _roles:
# ------------------------------------------------------------------- skeleton
def is_rootish(b):
return b is None or any(t in b.name.lower()
for t in ("root", "master", "armature"))
if b is None:
return True
# Some production rigs call their weighted pelvis simply ``root``. The
# role sidecar is the anatomical authority; treating that pelvis as a
# disposable scene root falsely reports every attached limb as orphaned.
if b.name == SIDECAR_INFO.get("roles", {}).get("hips"):
return False
return any(t in b.name.lower() for t in ("root", "master", "armature"))
orphans = [b.name for b in arm.data.bones
if is_rootish(b.parent) and not is_rootish(b)]
check(len(orphans) <= 1, "every chain is attached to the body",
root_names = {bone.name for bone in arm.data.bones}
root_allowance = 2 if {"全ての親", "操作中心"}.issubset(root_names) else 1
check(len(orphans) <= root_allowance, "every chain is attached to the body",
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
# ----------------------------------------------------------------------- clips
+1 -24
View File
@@ -221,7 +221,6 @@ func _build_home() -> Control:
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(spacer)
_build_skin_row(col)
return screen
@@ -620,29 +619,6 @@ func _add_button(parent: Container, text: String, callback: Callable) -> void:
parent.add_child(btn)
func _build_skin_row(parent: Container) -> void:
var skin_mgr := get_node_or_null("/root/SkinManager")
if skin_mgr == null:
return
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
parent.add_child(row)
row.add_child(UITheme.caption("Character", 22))
var opt := OptionButton.new()
opt.custom_minimum_size = Vector2(250, 46)
var ids: Array = skin_mgr.get_skin_ids()
ids.sort()
for i in ids.size():
opt.add_item(skin_mgr.get_skin(ids[i]).skin_name, i)
opt.set_item_metadata(i, ids[i])
if ids[i] == skin_mgr.active_skin_id:
opt.select(i)
opt.item_selected.connect(func(idx: int):
skin_mgr.set_active_skin(opt.get_item_metadata(idx)))
row.add_child(opt)
func _play_music() -> void:
# Menu music is scene-owned, so it stops the moment a level loads.
var path := "res://assets/sounds/music_menu_premium.ogg"
@@ -714,6 +690,7 @@ func _build_diorama() -> void:
if skin and skin.model_path != "":
var model := SkinnedPlayerModel.new()
model.skin_id = skin_mgr.active_skin_id
model.mecha_theme = str(skin.get("mecha_theme"))
model.position = Vector3(0.35, 0, 0)
model.rotation_degrees.y = 205.0
world.add_child(model)
+263 -204
View File
@@ -4,17 +4,13 @@ var bg: ColorRect
var main_vbox: VBoxContainer
var resume_btn: Button
var respawn_btn: Button
var character_btn: Button
var loadouts_btn: Button
var settings_btn: Button
var return_btn: Button
var exit_btn: Button
# Character picker
var character_editor: Control
var character_list: ItemList
# Character preview integrated into the loadout screen.
var character_desc: Label
var character_back_btn: Button
var _preview_viewport: SubViewport
var _preview_model: SkinnedPlayerModel
var _preview_pivot: Node3D
@@ -29,6 +25,9 @@ var primary1_opt: OptionButton
var primary2_opt: OptionButton
var special_opt: OptionButton
var melee_opt: OptionButton
var skin_opt: OptionButton
var loadout_summary: Label
var edit_title_label: Label
var save_loadout_btn: Button
var back_to_main_btn: Button
var editing_index: int = -1
@@ -164,11 +163,6 @@ func _build_ui() -> void:
respawn_btn.custom_minimum_size = Vector2(260, 52)
main_vbox.add_child(respawn_btn)
character_btn = Button.new()
character_btn.text = "Character"
character_btn.custom_minimum_size = Vector2(260, 52)
main_vbox.add_child(character_btn)
loadouts_btn = Button.new()
loadouts_btn.text = "Loadouts"
loadouts_btn.custom_minimum_size = Vector2(260, 52)
@@ -189,77 +183,126 @@ func _build_ui() -> void:
exit_btn.custom_minimum_size = Vector2(260, 52)
main_vbox.add_child(exit_btn)
_build_character_editor()
# ── Loadout Editor Root ──
loadout_editor = Control.new()
loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.add_child(loadout_editor)
var editor_center = CenterContainer.new()
editor_center.set_anchors_preset(Control.PRESET_FULL_RECT)
loadout_editor.add_child(editor_center)
var screen_fill := ColorRect.new()
screen_fill.color = Color(0.025, 0.030, 0.060, 0.96)
screen_fill.set_anchors_preset(Control.PRESET_FULL_RECT)
loadout_editor.add_child(screen_fill)
var editor_hbox = HBoxContainer.new()
editor_hbox.add_theme_constant_override("separation", 40)
editor_center.add_child(editor_hbox)
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
for side in ["left", "right"]:
margin.add_theme_constant_override("margin_" + side, 42)
margin.add_theme_constant_override("margin_top", 30)
margin.add_theme_constant_override("margin_bottom", 30)
loadout_editor.add_child(margin)
var left_vbox = VBoxContainer.new()
editor_hbox.add_child(left_vbox)
var list_title = Label.new()
list_title.text = "Your Loadouts"
list_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
left_vbox.add_child(list_title)
loadout_list_vbox = VBoxContainer.new()
left_vbox.add_child(loadout_list_vbox)
var screen_vbox := VBoxContainer.new()
screen_vbox.add_theme_constant_override("separation", 22)
margin.add_child(screen_vbox)
var header := HBoxContainer.new()
screen_vbox.add_child(header)
header.add_child(UITheme.title("LOADOUTS", 54))
var header_spacer := Control.new()
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(header_spacer)
back_to_main_btn = Button.new()
back_to_main_btn.text = "Back to Menu"
back_to_main_btn.custom_minimum_size = Vector2(0, 40)
left_vbox.add_child(back_to_main_btn)
back_to_main_btn.text = "Back"
back_to_main_btn.custom_minimum_size = Vector2(170, 46)
header.add_child(back_to_main_btn)
screen_vbox.add_child(UITheme.divider(0.82))
edit_panel = PanelContainer.new()
var editor_hbox := HBoxContainer.new()
editor_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL
editor_hbox.add_theme_constant_override("separation", 22)
screen_vbox.add_child(editor_hbox)
var left_vbox := VBoxContainer.new()
left_vbox.custom_minimum_size = Vector2(380, 0)
left_vbox.add_theme_constant_override("separation", 12)
editor_hbox.add_child(left_vbox)
left_vbox.add_child(UITheme.heading("YOUR LOADOUTS", 24))
var loadout_scroll := ScrollContainer.new()
loadout_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
left_vbox.add_child(loadout_scroll)
loadout_list_vbox = VBoxContainer.new()
loadout_list_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
loadout_list_vbox.add_theme_constant_override("separation", 10)
loadout_scroll.add_child(loadout_list_vbox)
edit_panel = UITheme.card()
edit_panel.custom_minimum_size = Vector2(500, 0)
edit_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
editor_hbox.add_child(edit_panel)
var edit_vbox = VBoxContainer.new()
edit_vbox.add_theme_constant_override("separation", 10)
edit_panel.add_child(edit_vbox)
var edit_title = Label.new()
edit_title.text = "Edit Loadout"
edit_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
edit_vbox.add_child(edit_title)
var edit_margin := MarginContainer.new()
edit_margin.add_theme_constant_override("margin_left", 24)
edit_margin.add_theme_constant_override("margin_right", 24)
edit_margin.add_theme_constant_override("margin_top", 20)
edit_margin.add_theme_constant_override("margin_bottom", 20)
edit_panel.add_child(edit_margin)
var edit_vbox := VBoxContainer.new()
edit_vbox.add_theme_constant_override("separation", 12)
edit_margin.add_child(edit_vbox)
edit_title_label = UITheme.heading("EDIT LOADOUT", 28)
edit_vbox.add_child(edit_title_label)
edit_vbox.add_child(UITheme.divider(0.72))
edit_vbox.add_child(UITheme.caption("LOADOUT NAME", 16))
name_edit = LineEdit.new()
name_edit.placeholder_text = "Loadout Name"
name_edit.custom_minimum_size = Vector2(250, 0)
name_edit.custom_minimum_size = Vector2(0, 44)
edit_vbox.add_child(name_edit)
edit_vbox.add_child(_create_label("Primary 1:"))
var options_grid := GridContainer.new()
options_grid.columns = 2
options_grid.add_theme_constant_override("h_separation", 16)
options_grid.add_theme_constant_override("v_separation", 12)
edit_vbox.add_child(options_grid)
options_grid.add_child(_create_label("Character / Skin"))
skin_opt = OptionButton.new()
skin_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(skin_opt)
options_grid.add_child(_create_label("Primary 1"))
primary1_opt = OptionButton.new()
edit_vbox.add_child(primary1_opt)
edit_vbox.add_child(_create_label("Primary 2:"))
primary1_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(primary1_opt)
options_grid.add_child(_create_label("Primary 2"))
primary2_opt = OptionButton.new()
edit_vbox.add_child(primary2_opt)
edit_vbox.add_child(_create_label("Special:"))
primary2_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(primary2_opt)
options_grid.add_child(_create_label("Special"))
special_opt = OptionButton.new()
edit_vbox.add_child(special_opt)
var melee_lbl = Label.new()
melee_lbl.text = "Melee"
edit_vbox.add_child(melee_lbl)
special_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(special_opt)
options_grid.add_child(_create_label("Melee"))
melee_opt = OptionButton.new()
edit_vbox.add_child(melee_opt)
melee_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(melee_opt)
edit_vbox.add_child(UITheme.caption("SELECTED ITEMS", 16))
loadout_summary = Label.new()
loadout_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
loadout_summary.custom_minimum_size = Vector2(0, 92)
loadout_summary.add_theme_font_size_override("font_size", 18)
edit_vbox.add_child(loadout_summary)
var edit_spacer := Control.new()
edit_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
edit_vbox.add_child(edit_spacer)
save_loadout_btn = Button.new()
save_loadout_btn.text = "Save Loadout"
save_loadout_btn.custom_minimum_size = Vector2(0, 40)
save_loadout_btn.custom_minimum_size = Vector2(0, 48)
edit_vbox.add_child(save_loadout_btn)
var preview_card := UITheme.card()
preview_card.custom_minimum_size = Vector2(440, 0)
editor_hbox.add_child(preview_card)
_build_loadout_character_preview(preview_card)
# ── Settings Editor Root ──
settings_editor = Control.new()
settings_editor.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -721,150 +764,63 @@ func _build_ui() -> void:
# per-class outlines, the same cloth and hair on springs. A thumbnail would go
# stale the first time a character was re-imported.
const PREVIEW_SIZE := Vector2i(520, 640)
const PREVIEW_SIZE := Vector2i(390, 560)
func _build_character_editor() -> void:
character_editor = Control.new()
character_editor.set_anchors_preset(Control.PRESET_FULL_RECT)
character_editor.visible = false
bg.add_child(character_editor)
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
character_editor.add_child(center)
var row = HBoxContainer.new()
row.add_theme_constant_override("separation", 34)
center.add_child(row)
# Left: the roster
var left = VBoxContainer.new()
left.add_theme_constant_override("separation", 14)
left.custom_minimum_size = Vector2(340, 0)
row.add_child(left)
var heading := UITheme.title("CHARACTER", 52)
left.add_child(heading)
left.add_child(UITheme.divider(0.28))
character_list = ItemList.new()
character_list.custom_minimum_size = Vector2(340, 380)
character_list.auto_height = false
left.add_child(character_list)
character_desc = UITheme.heading("", 20)
func _build_loadout_character_preview(parent: PanelContainer) -> void:
var preview_margin := MarginContainer.new()
preview_margin.add_theme_constant_override("margin_left", 18)
preview_margin.add_theme_constant_override("margin_right", 18)
preview_margin.add_theme_constant_override("margin_top", 18)
preview_margin.add_theme_constant_override("margin_bottom", 18)
parent.add_child(preview_margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
preview_margin.add_child(column)
column.add_child(UITheme.heading("CHARACTER PREVIEW", 24))
character_desc = UITheme.caption("", 17)
character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
character_desc.custom_minimum_size = Vector2(340, 60)
left.add_child(character_desc)
character_desc.custom_minimum_size = Vector2(0, 48)
column.add_child(character_desc)
character_back_btn = Button.new()
character_back_btn.text = "Back"
character_back_btn.custom_minimum_size = Vector2(0, 46)
left.add_child(character_back_btn)
# Right: the character, in a real 3D viewport
var frame := UITheme.card()
row.add_child(frame)
var vp_container = SubViewportContainer.new()
var vp_container := SubViewportContainer.new()
vp_container.stretch = true
vp_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE)
frame.add_child(vp_container)
column.add_child(vp_container)
_preview_viewport = SubViewport.new()
_preview_viewport.own_world_3d = true
_preview_viewport.size = PREVIEW_SIZE
_preview_viewport.msaa_3d = Viewport.MSAA_4X
_preview_viewport.transparent_bg = true
# The tree is paused while this menu is up. Without this the preview would
# be a still frame: no turntable, and — worse — no spring solver, so the
# character's hair and skirt would hang in their bind pose.
_preview_viewport.process_mode = Node.PROCESS_MODE_ALWAYS
vp_container.add_child(_preview_viewport)
var world = Node3D.new()
var world := Node3D.new()
_preview_viewport.add_child(world)
var env = WorldEnvironment.new()
var env := WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
# Three-point-ish lighting, warm key and cool fill, which is what makes a
# cel-shaded character read as rounded instead of as a sticker.
var key = DirectionalLight3D.new()
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-28, 38, 0)
key.light_color = Color(1.0, 0.93, 0.84)
key.light_energy = 1.6
world.add_child(key)
var fill = DirectionalLight3D.new()
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-16, -128, 0)
fill.light_color = Color(0.48, 0.62, 1.0)
fill.light_energy = 0.55
world.add_child(fill)
_preview_pivot = Node3D.new()
# Turned to face the camera. SkinnedPlayerModel yaws itself 180° on load
# because glTF forward is +Z and players face -Z, which is right in a level
# and means a preview camera sitting on +Z gets the back of the character's
# head. The turntable starts from here.
_preview_pivot.rotation_degrees.y = 180.0
world.add_child(_preview_pivot)
var camera = Camera3D.new()
var camera := Camera3D.new()
camera.position = Vector3(0, 1.05, 2.65)
camera.rotation_degrees = Vector3(-4, 0, 0)
camera.fov = 38.0
world.add_child(camera)
func _show_character() -> void:
main_vbox.visible = false
loadout_editor.visible = false
settings_editor.visible = false
character_editor.visible = true
_populate_character_list()
func _populate_character_list() -> void:
character_list.clear()
var active: String = SkinManager.active_skin_id
var selected := -1
for id in SkinManager.get_skin_ids():
var skin = SkinManager.get_skin(id)
if not skin.is_unlocked:
continue
var idx := character_list.add_item(skin.skin_name)
character_list.set_item_metadata(idx, id)
if id == active:
selected = idx
if selected >= 0:
character_list.select(selected)
_on_character_selected(selected)
elif character_list.item_count > 0:
character_list.select(0)
_on_character_selected(0)
## Selecting a character equips it immediately — there is no separate confirm.
##
## A picker that needs a second click to take effect makes you compare a preview
## against a memory of the last one. Applying on selection means the character
## behind the menu changes as you arrow through the list, which IS the
## comparison. Nothing here is destructive, so there is nothing to confirm.
func _on_character_selected(index: int) -> void:
if index < 0 or index >= character_list.item_count:
return
var id: String = character_list.get_item_metadata(index)
var skin = SkinManager.get_skin(id)
character_desc.text = skin.description if skin.description != "" else skin.skin_name
_load_preview(id, skin)
SkinManager.set_active_skin(id)
var player := _local_player()
if player and player.has_method("set_skin"):
player.set_skin(id)
## Build the turntable model for one skin. A colour-tint skin has no GLB, so the
## preview is emptied rather than left showing whoever was selected before it.
func _load_preview(id: String, skin) -> void:
@@ -885,6 +841,7 @@ func _load_preview(id: String, skin) -> void:
var model := SkinnedPlayerModel.new()
model.model_path = skin.model_path
model.skin_id = id
model.mecha_theme = str(skin.get("mecha_theme"))
# Not first-person and not shadows-only: this is the one place the local
# player is supposed to see their own character in full.
model.first_person_mode = false
@@ -892,13 +849,14 @@ func _load_preview(id: String, skin) -> void:
model.process_mode = Node.PROCESS_MODE_ALWAYS
_preview_pivot.add_child(model)
_preview_model = model
_refresh_preview_weapon()
func _process(delta: float) -> void:
# Turntable. Only while the picker is actually on screen — the rest of the
# time this node is an invisible autoload and has no business spending
# frames.
if character_editor and character_editor.visible and is_instance_valid(_preview_pivot):
if loadout_editor and loadout_editor.visible and is_instance_valid(_preview_pivot):
_preview_pivot.rotate_y(delta * 0.45)
@@ -962,9 +920,6 @@ func _create_label(text: String) -> Label:
func _connect_signals() -> void:
resume_btn.pressed.connect(_resume)
respawn_btn.pressed.connect(_respawn)
character_btn.pressed.connect(_show_character)
character_back_btn.pressed.connect(_show_main_menu)
character_list.item_selected.connect(_on_character_selected)
loadouts_btn.pressed.connect(_show_loadouts)
settings_btn.pressed.connect(_show_settings)
return_btn.pressed.connect(_show_return_dialog)
@@ -972,6 +927,9 @@ func _connect_signals() -> void:
back_to_main_btn.pressed.connect(_show_main_menu)
settings_back_btn.pressed.connect(_show_main_menu)
save_loadout_btn.pressed.connect(_save_current_loadout)
skin_opt.item_selected.connect(_on_skin_option_selected)
for option in [primary1_opt, primary2_opt, special_opt, melee_opt]:
option.item_selected.connect(func(_index: int): _refresh_loadout_summary())
quit_dialog.confirmed.connect(_quit_game)
return_dialog.confirmed.connect(_return_to_main_menu)
@@ -993,15 +951,14 @@ func _show_main_menu() -> void:
main_vbox.visible = true
loadout_editor.visible = false
settings_editor.visible = false
character_editor.visible = false
func _show_loadouts() -> void:
main_vbox.visible = false
settings_editor.visible = false
character_editor.visible = false
loadout_editor.visible = true
edit_panel.visible = false
_populate_loadout_list()
var index := clampi(LoadoutManager.active_loadout_index, 0, LoadoutManager.loadouts.size() - 1)
_edit_loadout(index)
func _populate_video_ui() -> void:
if _display_mode_opt:
@@ -1032,7 +989,6 @@ func _populate_audio_ui() -> void:
func _show_settings() -> void:
main_vbox.visible = false
loadout_editor.visible = false
character_editor.visible = false
settings_editor.visible = true
_populate_keybindings()
_populate_video_ui()
@@ -1104,41 +1060,140 @@ func _populate_loadout_list() -> void:
c.queue_free()
for i in range(LoadoutManager.loadouts.size()):
var l = LoadoutManager.loadouts[i]
var hbox = HBoxContainer.new()
var loadout: Dictionary = LoadoutManager.loadouts[i]
var loadout_index := i
var is_active := loadout_index == LoadoutManager.active_loadout_index
var card := UITheme.card()
card.custom_minimum_size = Vector2(350, 138)
loadout_list_vbox.add_child(card)
var btn = Button.new()
var is_active = (i == LoadoutManager.active_loadout_index)
btn.text = l["name"] + (" (Active)" if is_active else "")
btn.custom_minimum_size = Vector2(200, 40)
btn.pressed.connect(func(): _edit_loadout(i))
hbox.add_child(btn)
var margin := MarginContainer.new()
for side in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 14)
card.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 7)
margin.add_child(content)
var equip_btn = Button.new()
var heading := UITheme.heading(
str(loadout.get("name", "Loadout")) + (" • ACTIVE" if is_active else ""), 21
)
content.add_child(heading)
var details := Label.new()
details.text = "%s\n%s · %s\n%s · %s" % [
_skin_name(str(loadout.get("skin", "default"))),
_weapon_name(str(loadout.get("primary_1", "none"))),
_weapon_name(str(loadout.get("primary_2", "none"))),
_weapon_name(str(loadout.get("special", "none"))),
_weapon_name(str(loadout.get("melee", "none"))),
]
details.modulate = Color(0.82, 0.86, 0.94)
content.add_child(details)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
content.add_child(actions)
var edit_btn := Button.new()
edit_btn.text = "Edit"
edit_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
edit_btn.pressed.connect(func(): _edit_loadout(loadout_index))
actions.add_child(edit_btn)
var equip_btn := Button.new()
equip_btn.text = "Equip"
equip_btn.disabled = is_active
equip_btn.pressed.connect(func(): _equip_loadout(i))
hbox.add_child(equip_btn)
loadout_list_vbox.add_child(hbox)
equip_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
equip_btn.pressed.connect(func(): _equip_loadout(loadout_index))
actions.add_child(equip_btn)
func _equip_loadout(idx: int) -> void:
LoadoutManager.active_loadout_index = idx
LoadoutManager.save_loadouts()
if not LoadoutManager.set_active_loadout(idx):
return
_apply_active_loadout_skin()
_populate_loadout_list()
_edit_loadout(idx)
func _edit_loadout(idx: int) -> void:
if idx < 0 or idx >= LoadoutManager.loadouts.size():
return
editing_index = idx
var l = LoadoutManager.loadouts[idx]
name_edit.text = l["name"]
_populate_options(primary1_opt, "primary", l["primary_1"])
_populate_options(primary2_opt, "primary", l["primary_2"])
_populate_options(special_opt, "special", l["special"])
if l.has("melee"):
_populate_options(melee_opt, "melee", l["melee"])
var loadout: Dictionary = LoadoutManager.loadouts[idx]
name_edit.text = str(loadout.get("name", "Loadout"))
edit_title_label.text = "EDIT " + name_edit.text.to_upper()
_populate_skin_options(str(loadout.get("skin", "default")))
_populate_options(primary1_opt, "primary", str(loadout.get("primary_1", "none")))
_populate_options(primary2_opt, "primary", str(loadout.get("primary_2", "none")))
_populate_options(special_opt, "special", str(loadout.get("special", "none")))
_populate_options(melee_opt, "melee", str(loadout.get("melee", "none")))
edit_panel.visible = true
_on_skin_option_selected(skin_opt.selected)
_refresh_loadout_summary()
func _populate_skin_options(current_id: String) -> void:
skin_opt.clear()
var skin_ids: Array = SkinManager.get_skin_ids()
skin_ids.sort()
var selected_index := 0
for id in skin_ids:
var skin = SkinManager.get_skin(id)
if skin == null or not skin.is_unlocked:
continue
var item_index := skin_opt.item_count
skin_opt.add_item(skin.skin_name)
skin_opt.set_item_metadata(item_index, id)
if id == current_id:
selected_index = item_index
if skin_opt.item_count > 0:
skin_opt.select(selected_index)
func _on_skin_option_selected(index: int) -> void:
if index < 0 or index >= skin_opt.item_count:
return
var skin_id := str(skin_opt.get_item_metadata(index))
var skin = SkinManager.get_skin(skin_id)
character_desc.text = skin.description if skin.description != "" else skin.skin_name
_load_preview(skin_id, skin)
_refresh_loadout_summary()
func _selected_metadata(option: OptionButton) -> String:
if option == null or option.selected < 0 or option.selected >= option.item_count:
return "none"
return str(option.get_item_metadata(option.selected))
func _weapon_name(id: String) -> String:
var weapon: Dictionary = LoadoutManager.weapon_db.get(id, LoadoutManager.weapon_db["none"])
return str(weapon.get("name", id))
func _skin_name(id: String) -> String:
var skin = SkinManager.get_skin(id)
return skin.skin_name if skin != null else id
func _refresh_loadout_summary() -> void:
if loadout_summary == null:
return
loadout_summary.text = "CHARACTER %s\nPRIMARY %s · %s\nSPECIAL %s\nMELEE %s" % [
_skin_name(_selected_metadata(skin_opt)),
_weapon_name(_selected_metadata(primary1_opt)),
_weapon_name(_selected_metadata(primary2_opt)),
_weapon_name(_selected_metadata(special_opt)),
_weapon_name(_selected_metadata(melee_opt)),
]
_refresh_preview_weapon()
func _refresh_preview_weapon() -> void:
if not is_instance_valid(_preview_model):
return
var weapon_id := _selected_metadata(primary1_opt)
if weapon_id == "none":
weapon_id = _selected_metadata(primary2_opt)
var weapon: Dictionary = LoadoutManager.weapon_db.get(weapon_id, LoadoutManager.weapon_db["none"])
_preview_model.set_weapon(str(weapon.get("script", "")))
func _apply_active_loadout_skin() -> void:
var skin_id := LoadoutManager.get_active_skin_id()
SkinManager.set_active_skin(skin_id)
var player := _local_player()
if player and player.has_method("set_skin"):
player.set_skin(skin_id)
func _populate_options(opt: OptionButton, category: String, current_id: String) -> void:
opt.clear()
@@ -1164,17 +1219,21 @@ func _populate_options(opt: OptionButton, category: String, current_id: String)
func _save_current_loadout() -> void:
if editing_index >= 0 and editing_index < LoadoutManager.loadouts.size():
var l = LoadoutManager.loadouts[editing_index]
var l: Dictionary = LoadoutManager.loadouts[editing_index]
if name_edit.text.strip_edges() != "":
l["name"] = name_edit.text.strip_edges()
name_edit.text = l["name"]
l["primary_1"] = primary1_opt.get_item_metadata(primary1_opt.get_selected_id())
l["primary_2"] = primary2_opt.get_item_metadata(primary2_opt.get_selected_id())
l["special"] = special_opt.get_item_metadata(special_opt.get_selected_id())
l["melee"] = melee_opt.get_item_metadata(melee_opt.get_selected_id())
l["skin"] = _selected_metadata(skin_opt)
l["primary_1"] = _selected_metadata(primary1_opt)
l["primary_2"] = _selected_metadata(primary2_opt)
l["special"] = _selected_metadata(special_opt)
l["melee"] = _selected_metadata(melee_opt)
LoadoutManager.save_loadouts()
if editing_index == LoadoutManager.active_loadout_index:
_apply_active_loadout_skin()
_populate_loadout_list()
edit_panel.visible = false
edit_title_label.text = "EDIT " + str(l["name"]).to_upper()
_refresh_loadout_summary()
func _show_exit_dialog() -> void:
quit_dialog.popup_centered()