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) # Asset pipeline staging (raw downloads, not game-ready)
assets/characters/incoming/ 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 # Misc
*LF* *LF*
tags 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 // 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. // 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 screen_tex : hint_screen_texture, filter_linear, repeat_disable;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest; 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; group_uniforms ink;
uniform vec4 ink_color : source_color = vec4(0.224, 0.196, 0.310, 1.0); 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 // Linear view depth, with the sky pinned to a finite value so the second
// difference across a silhouette is large but never infinite. // difference across a silhouette is large but never infinite.
float linear_depth(vec2 uv, mat4 inv_proj) { float linear_depth(vec2 uv, float d, mat4 inv_proj) {
float d = texture(depth_tex, uv).r;
// Godot 4.3+ is reverse-Z: the far plane and the sky read exactly 0. // Godot 4.3+ is reverse-Z: the far plane and the sky read exactly 0.
if (d <= 0.000001) { if (d <= 0.000001) {
return ink_sky_depth; 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) { vec3 apply_grade(vec3 c, vec2 uv) {
if (!grade_enabled) { if (!grade_enabled) {
return c; return c;
@@ -226,11 +239,15 @@ void fragment() {
} }
vec2 t = px * ink_thickness; vec2 t = px * ink_thickness;
float dc = linear_depth(uv, INV_PROJECTION_MATRIX); float raw_l = texture(depth_tex, uv - vec2(t.x, 0.0)).r;
float dl = linear_depth(uv - vec2(t.x, 0.0), INV_PROJECTION_MATRIX); float raw_r = texture(depth_tex, uv + vec2(t.x, 0.0)).r;
float dr = linear_depth(uv + vec2(t.x, 0.0), INV_PROJECTION_MATRIX); float raw_u = texture(depth_tex, uv + vec2(0.0, t.y)).r;
float du = linear_depth(uv + vec2(0.0, t.y), INV_PROJECTION_MATRIX); float raw_d = texture(depth_tex, uv - vec2(0.0, t.y)).r;
float dd = linear_depth(uv - vec2(0.0, t.y), INV_PROJECTION_MATRIX); 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 // Second difference of linear depth, normalised by distance so that a given
// real-world crease inks with the same weight near and far. // real-world crease inks with the same weight near and far.
@@ -245,6 +262,44 @@ void fragment() {
edge = max(edge, smoothstep( edge = max(edge, smoothstep(
ink_concave, ink_concave * 3.4, concave) * ink_concave_amount); 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 // 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 // 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. // 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 _flames: Array[Node3D] = []
var _burst_left := 0.0 var _burst_left := 0.0
var _dash_active := false var _dash_active := false
var _wall_glide_active := false
var _dash_tail := 0.0 var _dash_tail := 0.0
var _render_enabled := true var _render_enabled := true
var _exhaust_direction := Vector3.DOWN 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() _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: func get_flame_count() -> int:
return _flames.size() 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: func get_exhaust_direction() -> Vector3:
return _exhaust_direction return _exhaust_direction
@@ -124,7 +144,7 @@ func _process(delta: float) -> void:
_burst_left = maxf(0.0, _burst_left - delta) _burst_left = maxf(0.0, _burst_left - delta)
_dash_tail = maxf(0.0, _dash_tail - delta) _dash_tail = maxf(0.0, _dash_tail - delta)
var dash_visible := _dash_active or _dash_tail > 0.0 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 visible = _render_enabled and active
if not visible: if not visible:
return return
@@ -135,6 +155,8 @@ func _process(delta: float) -> void:
_exhaust_direction = Vector3(0.0, -1.0, 0.16).normalized() _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 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: if _burst_left > 0.0 and not _dash_active:
pulse *= clampf(_burst_left / 0.08, 0.15, 1.0) pulse *= clampf(_burst_left / 0.08, 0.15, 1.0)
for i in _flames.size(): for i in _flames.size():
+2
View File
@@ -8,6 +8,8 @@ class_name PlayerSkin
@export var skin_name: String = "Default" @export var skin_name: String = "Default"
@export var description: String = "" @export var description: String = ""
@export var model_path: String = "" # Path to .glb file, empty = use procedural @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 color_tint: Color = Color(0.2, 0.4, 0.8) # For procedural model
@export var is_unlocked: bool = true @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. ## 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 ## 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, # Canonical names are the role keys with the DEF- prefix, so the limbs,
# hips, neck and head all map straight through. # hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(String(n).trim_prefix("DEF-"), n)) 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: if b < 0:
b = skel.find_bone(String(n)) b = skel.find_bone(String(n))
idx[n] = b idx[n] = b
+47 -7
View File
@@ -3,7 +3,8 @@ extends Node
## Autoload: manages all available player skins. ## Autoload: manages all available player skins.
## ##
## Skins come from two places: ## 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 ## 2. assets/characters/skins/skins.json — written automatically by
## tools/pipeline.py for every imported character. No code changes needed ## 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. ## 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) signal skin_changed(skin_id: String)
const SKINS_JSON := "res://assets/characters/skins/skins.json" 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" const SELECTION_CFG := "user://skin_selection.cfg"
var skins: Dictionary = {} var skins: Dictionary = {}
@@ -23,14 +25,46 @@ var active_skin_id: String = "default"
func _ready() -> void: func _ready() -> void:
_register_builtin_skins() _register_builtin_skins()
_load_skins_json() _load_skins_json()
_load_skin_registry(LOCAL_SKINS_JSON, false)
_load_selection() _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()) print("SkinManager: %d skins available" % skins.size())
func _register_builtin_skins() -> void: func _register_builtin_skins() -> void:
_add_color_skin("default", "Default", "Standard issue", Color(0.2, 0.4, 0.8)) _add_mecha_skin("default", "Sakura Vanguard",
_add_color_skin("red_team", "Red Team", "Red team colors", Color(0.8, 0.2, 0.2)) "HD anime shrine-runner in a petal-armored vector-thrust frame",
_add_color_skin("forest", "Forest", "Forest camouflage", Color(0.2, 0.6, 0.2)) "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: 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: 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 return
var file := FileAccess.open(SKINS_JSON, FileAccess.READ) var file := FileAccess.open(path, FileAccess.READ)
if not file: if not file:
return return
var data = JSON.parse_string(file.get_as_text()) var data = JSON.parse_string(file.get_as_text())
file.close() file.close()
if not data is Dictionary or not data.has("skins"): 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 return
for entry in data["skins"]: for entry in data["skins"]:
if not entry is Dictionary or not entry.has("id"): 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.skin_name = entry.get("name", entry["id"])
skin.description = entry.get("description", "") skin.description = entry.get("description", "")
skin.model_path = entry.get("model", "") skin.model_path = entry.get("model", "")
skin.set("mecha_theme", entry.get("mecha_theme", ""))
skin.is_unlocked = entry.get("unlocked", true) skin.is_unlocked = entry.get("unlocked", true)
var viewmodel = entry.get("viewmodel", {}) var viewmodel = entry.get("viewmodel", {})
if viewmodel is Dictionary: if viewmodel is Dictionary:
+222 -48
View File
@@ -1,6 +1,8 @@
extends Node3D extends Node3D
class_name SkinnedPlayerModel 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) ## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
## with a Mixamo-compatible skeleton and the canonical animation set. ## 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. ## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = "" @export var model_path: String = ""
@export var mecha_theme: String = ""
@export var first_person_mode: bool = false @export var first_person_mode: bool = false
@export var shadows_only: bool = false @export var shadows_only: bool = false
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z @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", "RunForward", "RunBackward", "RunLeft", "RunRight", "Dance",
"EmoteStretch", "EmoteCall", "EmoteYes", "EmoteNo", "PistolIdle"] "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 ## 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 ## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed ## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
## drifts across their thresholds, and that is where hard cuts were most ## drifts across their thresholds, and that is where hard cuts were most
## visible. ## visible.
const BLEND_TIMES := { const BLEND_TIMES := {
"Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16, "Dash": 0.20, "Jump": 0.24, "Hit": 0.14, "Land": 0.28,
"Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24, "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, "Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.32,
"WallRunLeft": 0.18, "WallRunRight": 0.18, "WallRunLeft": 0.26, "WallRunRight": 0.26,
"WallCling": 0.28, "Grapple": 0.28, "WallCling": 0.32, "Grapple": 0.32,
} }
const GROUND_STATE_BLEND := 0.30
## Named gameplay actions -> (clip, lock seconds). Networked via the ## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter. ## 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 ## performance's forward axis with gameplay travel; it never rotates bones or
## manufactures poses. ## manufactures poses.
var _motion_root: Node3D var _motion_root: Node3D
var _wall_run_velocity_world := Vector3.ZERO var _wall_glide_velocity_world := Vector3.ZERO
var _motion_yaw := 0.0 var _motion_yaw := 0.0
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = "" 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 _weapon_attachment: BoneAttachment3D
var _grapple_attachment: BoneAttachment3D var _grapple_attachment: BoneAttachment3D
var _grapple_shoulder_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()`. ## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones var _spring_mod: SpringBones
var _mecha_kit: AnimeMechaKit
var is_holding_weapon: bool = false var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read ## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks. ## 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_fwd: float = 0.0
var _target_ads: float = 0.0 var _target_ads: float = 0.0
var _target_wall: 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_ads: float = 0.0
var _cur_wall: float = 0.0 var _cur_wall: float = 0.0
var _owner_visible: bool = false var _owner_visible: bool = false
@@ -206,15 +220,23 @@ func load_model(path: String) -> void:
skeleton = null skeleton = null
animation_player = null animation_player = null
_motion_root = null _motion_root = null
_wall_run_velocity_world = Vector3.ZERO _wall_glide_velocity_world = Vector3.ZERO
_motion_yaw = 0.0 _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 loaded = false
_resolved_clips.clear() _resolved_clips.clear()
_current_clip = "" _current_clip = ""
_has_titanfall_motion_reference = false
_current_state_node = "" _current_state_node = ""
_weapon_attachment = null _weapon_attachment = null
_grapple_attachment = null _grapple_attachment = null
_grapple_shoulder_attachment = null _grapple_shoulder_attachment = null
_mecha_kit = null
# Cleared, not left standing: a model with no skeleton never reaches the # Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST # branch that reloads these, and would otherwise be described by the LAST
# character's sidecar. # character's sidecar.
@@ -240,6 +262,7 @@ func load_model(path: String) -> void:
else: else:
_ensure_meshes_bound(scene) _ensure_meshes_bound(scene)
_rig_info = _load_rig_info(path) _rig_info = _load_rig_info(path)
_apply_rig_animation_speeds()
# `weights_authored` is MEASURED at build time, not inferred from which # `weights_authored` is MEASURED at build time, not inferred from which
# pipeline branch ran: a model that arrives unrigged still gets a # pipeline branch ran: a model that arrives unrigged still gets a
# sidecar, and its nearest-bone weights still need the repair below. # 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. # what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info) _surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces) 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: if animation_player:
_index_animations() _index_animations()
_setup_anim_tree(scene) _setup_anim_tree(scene)
@@ -319,6 +348,42 @@ func load_model(path: String) -> void:
_play_ground_locomotion(0.0, false) _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 ## 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. ## 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 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. ## 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 ## 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). ## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void: func _index_animations() -> void:
var available := animation_player.get_animation_list() var available := animation_player.get_animation_list()
_has_titanfall_motion_reference = \
_find_clip(available, "ReviewWallHangFront") != ""
for canonical in CLIP_FALLBACKS: for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]: for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate) var match_name := _find_clip(available, candidate)
@@ -576,8 +667,8 @@ func _setup_anim_tree(scene: Node) -> void:
var upper := AnimationNodeOneShot.new() var upper := AnimationNodeOneShot.new()
# The upper-body one-shot pops in and out over the locomotion clip, so its # 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. # own fades matter as much as the locomotion cross-fade.
upper.fadein_time = 0.14 upper.fadein_time = 0.20
upper.fadeout_time = 0.22 upper.fadeout_time = 0.30
upper.filter_enabled = true upper.filter_enabled = true
_upper_anim = AnimationNodeAnimation.new() _upper_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240)) 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": "slide":
clip = "Slide" clip = "Slide"
"wall_run": "wall_run":
# Separate authored performances: the contact leg and balancing arm # The jets still own propulsion, but the authored pilot pack supplies a
# are different on each side. This is never a renamed ground run. # distinct compact wall-performance for each side. This is body motion,
clip = "WallRunRight" if _target_wall > 0.0 else "WallRunLeft" # 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": "wall_cling":
clip = "WallCling" clip = "WallCling"
"wall_climb": "wall_climb":
@@ -769,11 +864,9 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
var scale := 1.0 var scale := 1.0
if clip == "Slide": if clip == "Slide":
scale = clampf(speed / run_anim_reference_speed, 0.75, 1.8) scale = clampf(speed / run_anim_reference_speed, 0.75, 1.8)
elif clip in ["WallRunLeft", "WallRunRight"]: elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
scale = clampf(speed / wall_run_anim_reference_speed, 0.85, 1.65) # Grapple is held, and the wall clips are compact propulsion loops.
elif clip == "Grapple": # Neither should become a frantic treadmill at high traversal speed.
# Grapple is a held, authored flight pose. Its cadence does not
# represent footsteps and must not accelerate with pull velocity.
scale = 1.0 scale = 1.0
elif clip == "Dash": elif clip == "Dash":
scale = clampf(speed / 13.0, 0.75, 1.8) 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) _target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall run: -1 wall on left, +1 wall on right, 0 none. ## Wall side during a wall glide: -1 wall on left, +1 wall on right, 0 none.
## Used only to release the wall-side weapon hand; the clip owns body motion.
func set_wall_side(side: float) -> void: func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0) _target_wall = clampf(side, -1.0, 1.0)
## Gameplay velocity for aligning the authored lateral wall-run performance. ## Gameplay velocity aligns the held glide silhouette with the live wall
## Uisco supplies the body lean, contact leg, arm balance, and cadence. This ## tangent. The mechanic still reports `wall_run` internally for save/network
## supplies only the wall-tangent coordinate frame that an offline clip cannot ## compatibility, but the character presentation is entirely propulsion-based.
## know. 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: 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. ## 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): if not is_instance_valid(_motion_root):
return Vector3.ZERO return Vector3.ZERO
var source_forward := Vector3.FORWARD if facing_flip else Vector3.BACK var source_forward := Vector3.FORWARD if facing_flip else Vector3.BACK
@@ -814,6 +910,32 @@ func wall_run_forward_debug() -> Vector3:
).normalized() ).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. ## Live grapple mechanics layered over the authored airborne performance.
## The clip supplies weight and secondary motion; gameplay supplies the one ## The clip supplies weight and secondary motion; gameplay supplies the one
## constraint an authored fixed pose cannot know: the actual cable direction. ## 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: func _process(delta: float) -> void:
_update_cloth_lod(delta) _update_cloth_lod(delta)
_update_motion_orientation(delta) _update_motion_orientation(delta)
_update_mecha_nozzles()
_update_animation_smoothing(delta)
if not _pose_mod: if not _pose_mod:
return return
_hold_weapon_still() _hold_weapon_still()
@@ -926,11 +1050,9 @@ func _process(delta: float) -> void:
"slide": "slide":
hold_l = 0.0 # trailing arm braces the ground hold_l = 0.0 # trailing arm braces the ground
"wall_run": "wall_run":
# The wall-side arm reaches for the wall. # A glide is stabilized by the suit; neither hand needs to touch
if _cur_wall > 0.05: # the wall, so weapon posture can remain coherent.
hold_r = 0.0 pass
elif _cur_wall < -0.05:
hold_l = 0.0
"grapple": "grapple":
hold_l = 0.0 # left hand rides the grapple line hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r _pose_mod.hold_r_target = hold_r
@@ -949,14 +1071,14 @@ func _update_motion_orientation(delta: float) -> void:
var wanted_yaw := 0.0 var wanted_yaw := 0.0
if _pose_mod and _pose_mod.state == "wall_run": if _pose_mod and _pose_mod.state == "wall_run":
var local_velocity := global_transform.basis.inverse() \ var local_velocity := global_transform.basis.inverse() \
* _wall_run_velocity_world * _wall_glide_velocity_world
local_velocity.y = 0.0 local_velocity.y = 0.0
if local_velocity.length_squared() > 0.01: if local_velocity.length_squared() > 0.01:
var source_forward := Vector3.FORWARD \ var source_forward := Vector3.FORWARD \
if facing_flip else Vector3.BACK if facing_flip else Vector3.BACK
wanted_yaw = source_forward.signed_angle_to( wanted_yaw = source_forward.signed_angle_to(
local_velocity.normalized(), Vector3.UP) 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_yaw = lerp_angle(_motion_yaw, wanted_yaw, blend)
_motion_root.rotation.y = _motion_yaw _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. ## even though gameplay acceleration reached running speed in one physics frame.
## That is the visible "sliding, then slow walk, then run" failure. Here the ## 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 ## 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: func _play_ground_locomotion(speed: float, crouched: bool) -> void:
if not _anim_tree: if not _anim_tree:
return return
var state_node := "CrouchLocomotion" if crouched else "Locomotion" var state_node := "CrouchLocomotion" if crouched else "Locomotion"
if _current_state_node != state_node: 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) _anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node _current_state_node = state_node
if crouched: if crouched:
var authored_speed := minf(speed, crouch_anim_reference_speed) var authored_speed := minf(speed, crouch_anim_reference_speed)
_anim_tree.set("parameters/crouch_blend/blend_position", authored_speed) _crouch_blend_target = authored_speed
_anim_tree.set("parameters/loco_scale/scale", _loco_scale_target = maxf(1.0,
maxf(1.0, speed / maxf(crouch_anim_reference_speed, 0.01))) speed / maxf(crouch_anim_reference_speed, 0.01))
_current_clip = _resolved_clips.get( _current_clip = _resolved_clips.get(
"CrouchWalk" if speed > 0.1 else "Crouch", "") "CrouchWalk" if speed > 0.1 else "Crouch", "")
return 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_limit := 1.0 / maxf(inverse_limit, 0.01)
var authored_speed := minf(speed, authored_limit) var authored_speed := minf(speed, authored_limit)
_anim_tree.set("parameters/loco_blend/blend_position", _loco_blend_target = direction * authored_speed
direction * authored_speed) _loco_scale_target = maxf(1.0,
_anim_tree.set("parameters/loco_scale/scale", speed / maxf(authored_limit, 0.01))
maxf(1.0, speed / maxf(authored_limit, 0.01)))
var canonical := "Idle" var canonical := "Idle"
if speed > 0.1: if speed > 0.1:
@@ -1069,7 +1214,7 @@ func get_brake_debug() -> float:
return 0.0 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: func locomotion_blend_debug() -> Vector2:
if not _anim_tree: if not _anim_tree:
return Vector2.ZERO return Vector2.ZERO
@@ -1077,7 +1222,11 @@ func locomotion_blend_debug() -> Vector2:
return value if value is Vector2 else Vector2.ZERO 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; ## The raw BlendSpace position stops at the source clip's measured velocity;
## TimeScale covers faster gameplay without changing the pose or foot phase. ## TimeScale covers faster gameplay without changing the pose or foot phase.
func locomotion_effective_speed_debug() -> float: 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. ## 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 ## 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. ## 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: if not skeleton:
return [] return []
var roles_and_fallbacks := [ var roles_and_fallbacks := [
@@ -1118,6 +1267,31 @@ func get_jet_socket_world_positions() -> Array[Vector3]:
return sockets 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. ## Every canonical clip this character resolved to something real.
## ##
## Canonical rather than raw, because the raw names differ per character — one ## 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 roles: Dictionary = _rig_info.get("roles", {})
var actual := String(roles.get(role, "")) var actual := String(roles.get(role, ""))
if actual != "" and skeleton: if actual != "" and skeleton:
var b := skeleton.find_bone(actual) var b := RigRoles.find_imported_bone(skeleton, actual)
if b >= 0: if b >= 0:
return b return b
return _find_bone(fallbacks) return _find_bone(fallbacks)
@@ -1688,8 +1862,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_hold_r = lerpf(_hold_r, hold_r_target, t) _hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t) _hold_l = lerpf(_hold_l, hold_l_target, t)
# The authored clip owns cadence and weight. Grappling adds the physical # Authored clips own the full silhouette. Traversal adds only constraints
# pendulum frame before the arm constraint is solved. # an offline clip cannot know, such as the live grapple cable.
_apply_grapple_body(skel, delta) _apply_grapple_body(skel, delta)
if absf(aim_pitch) > 0.01 and not grapple_active: if absf(aim_pitch) > 0.01 and not grapple_active:
_apply_aim_pitch(skel) _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 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 ## The character picker used to be a separate global screen. It now belongs to
## things that each hide their own class of mistake and none of which a compile ## each loadout, so this checks the full-screen editor, saved character fields,
## check catches. So: open it, walk every entry, and assert that each one ## selected-item summary, and animated preview together.
## selects, describes itself, and builds a real model with a real skeleton.
## ##
## godot --headless --path . -s res://debug/character_picker_check.gd ## godot --headless --path . -s res://debug/character_picker_check.gd
@@ -22,29 +21,41 @@ func _init() -> void:
_done() _done()
return return
_check(menu.character_btn != null, "Character button exists on the pause menu") menu.visible = true
_check(menu.character_list != null, "Character list exists") menu._show_loadouts()
_check(menu.character_editor != null, "Character screen exists")
menu._show_character()
await process_frame 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(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 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) _check(count > 0, "Roster is not empty (%d entries)" % count)
var seen_glb := 0 var seen_glb := 0
for i in count: for i in count:
var id: String = menu.character_list.get_item_metadata(i) var id: String = menu.skin_opt.get_item_metadata(i)
menu._on_character_selected(i) menu.skin_opt.select(i)
menu._on_skin_option_selected(i)
await process_frame await process_frame
await process_frame await process_frame
_check(menu.character_desc.text != "", "'%s' has a description line" % id) _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 skin = skin_mgr.get_skin(id)
var expects_model: bool = skin.model_path != "" \ var expects_model: bool = skin.model_path != "" \
@@ -78,12 +89,11 @@ func _init() -> void:
_check(await _pose_moves(model), _check(await _pose_moves(model),
"'%s' preview skeleton is actually animating" % id) "'%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() menu._show_main_menu()
await process_frame 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") _check(menu.main_vbox.visible, "Pause list is showing again")
_done() _done()
@@ -119,5 +129,5 @@ func _check(ok: bool, what: String) -> void:
func _done() -> 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) quit(1 if _fails > 0 else 0)
+4 -1
View File
@@ -5,6 +5,7 @@ extends SceneTree
var _frames := 0 var _frames := 0
var _out_dir := "." var _out_dir := "."
var _skin_id := "default"
var _player: Node = null var _player: Node = null
var _model: Node = null var _model: Node = null
var _jets: PlayerJetVFX = null var _jets: PlayerJetVFX = null
@@ -17,6 +18,8 @@ func _initialize() -> void:
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
if not args.is_empty(): if not args.is_empty():
_out_dir = args[0] _out_dir = args[0]
if args.size() > 1:
_skin_id = args[1]
if not _out_dir.is_absolute_path(): if not _out_dir.is_absolute_path():
_out_dir = ProjectSettings.globalize_path(_out_dir) _out_dir = ProjectSettings.globalize_path(_out_dir)
var err := DirAccess.make_dir_recursive_absolute(_out_dir) var err := DirAccess.make_dir_recursive_absolute(_out_dir)
@@ -32,7 +35,7 @@ func _process(_delta: float) -> bool:
if _frames == 35: if _frames == 35:
var skins = root.get_node_or_null("SkinManager") var skins = root.get_node_or_null("SkinManager")
if skins: if skins:
skins.set_active_skin("miku") skins.set_active_skin(_skin_id)
var network = root.get_node_or_null("NetworkManager") var network = root.get_node_or_null("NetworkManager")
if network and network.has_method("start_singleplayer_match"): if network and network.has_method("start_singleplayer_match"):
network.start_singleplayer_match(GameMode.DEATHMATCH) 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.z > 0.90, "forward dash exhaust must point backward")
_expect(dash_exhaust.y < 0.0, "dash exhaust keeps a slight downward cant") _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(): 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) quit(0)
else: else:
for failure in _failures: for failure in _failures:
+16 -11
View File
@@ -18,7 +18,8 @@ var _camera: Camera3D = null
var _motion_frame := 0 var _motion_frame := 0
var _stop_frame := -1 var _stop_frame := -1
var _worst_run_stride_error := 0.0 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] = [] var _failures: Array[String] = []
@@ -62,6 +63,7 @@ func _process(_delta: float) -> bool:
if _motion_frame <= RUN_FRAMES: if _motion_frame <= RUN_FRAMES:
_model.set_locomotion(0.0, 1.0, 0.0) _model.set_locomotion(0.0, 1.0, 0.0)
_model.update_state("ground", 9.0, false) _model.update_state("ground", 9.0, false)
if _motion_frame > 30:
_worst_run_stride_error = maxf(_worst_run_stride_error, _worst_run_stride_error = maxf(_worst_run_stride_error,
absf(_model.locomotion_effective_speed_debug() - 9.0)) absf(_model.locomotion_effective_speed_debug() - 9.0))
if _motion_frame == RUN_FRAMES - 1: if _motion_frame == RUN_FRAMES - 1:
@@ -73,28 +75,31 @@ func _process(_delta: float) -> bool:
_stop_frame += 1 _stop_frame += 1
_model.set_locomotion(0.0, 0.0, 0.0) _model.set_locomotion(0.0, 0.0, 0.0)
_model.update_state("ground", 0.0, false) _model.update_state("ground", 0.0, false)
_worst_stop_stride = maxf(_worst_stop_stride, if _stop_frame == 0:
_model.locomotion_effective_speed_debug()) _first_stop_stride = _model.locomotion_effective_speed_debug()
_settled_stop_stride = _model.locomotion_effective_speed_debug()
_model._process(DT) _model._process(DT)
if _stop_frame >= 0: if _stop_frame >= 0:
match _stop_frame: match _stop_frame:
3: 3:
_snap("loco_stop_01_idle_blend") _snap("loco_stop_01_idle_blend")
9: 12:
_snap("loco_stop_02_idle_arrival") _snap("loco_stop_02_idle_arrival")
20: 30:
_snap("loco_stop_03_idle") _snap("loco_stop_03_idle")
50: 50:
_snap("loco_stop_04_settled") _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") _failures.append("run stride diverged from physical speed")
if _worst_stop_stride > 0.02: if _first_stop_stride < 4.0:
_failures.append("authored stride did not stop immediately") _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: if absf(_model.get_brake_debug()) > 0.001:
_failures.append("procedural brake pose is still active") _failures.append("procedural brake pose is still active")
_finish() _finish()
return true return false
return false return false
@@ -145,8 +150,8 @@ func _snap(tag: String) -> void:
func _finish() -> void: func _finish() -> void:
if _failures.is_empty() and _stop_frame >= 50: if _failures.is_empty() and _stop_frame >= 50:
print("LOCO_STOP PASS: run error %.5f, stop stride %.5f, procedural brake %.5f" % [ print("LOCO_STOP PASS: run error %.5f, stop ease %.5f -> %.5f, procedural brake %.5f" % [
_worst_run_stride_error, _worst_stop_stride, _worst_run_stride_error, _first_stop_stride, _settled_stop_stride,
_model.get_brake_debug()]) _model.get_brake_debug()])
quit(0) quit(0)
else: else:
+8 -8
View File
@@ -1,6 +1,6 @@
extends SceneTree 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 ## 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 ## 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) quit(1)
return true return true
_menu.visible = true _menu.visible = true
_menu._show_character() _menu._show_loadouts()
_next() _next()
return false return false
if (_frames - 150) % SETTLE == 0: if (_frames - 150) % SETTLE == 0:
@@ -67,11 +67,11 @@ func _process(_delta: float) -> bool:
func _next() -> void: func _next() -> void:
_index += 1 _index += 1
while _index < _menu.character_list.item_count: while _index < _menu.skin_opt.item_count:
var id: String = _menu.character_list.get_item_metadata(_index) var id: String = _menu.skin_opt.get_item_metadata(_index)
# Colour-tint skins have no model; there is nothing to photograph. # Colour-tint skins have no model; there is nothing to photograph.
_menu.character_list.select(_index) _menu.skin_opt.select(_index)
_menu._on_character_selected(_index) _menu._on_skin_option_selected(_index)
if _menu._preview_model != null: if _menu._preview_model != null:
_posing = id _posing = id
return return
@@ -88,8 +88,8 @@ func _shoot() -> void:
# Crop to the preview panel — the rest of the frame is the level behind it. # Crop to the preview panel — the rest of the frame is the level behind it.
var w := img.get_width() var w := img.get_width()
var h := img.get_height() var h := img.get_height()
img = img.get_region(Rect2i(int(w * 0.44), int(h * 0.15), img = img.get_region(Rect2i(int(w * 0.735), int(h * 0.145),
int(w * 0.32), int(h * 0.68))) int(w * 0.245), int(h * 0.82)))
var path := "%s/roster_%s.png" % [_out_dir, id] var path := "%s/roster_%s.png" % [_out_dir, id]
var save_error := img.save_png(path) var save_error := img.save_png(path)
if save_error == OK: 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], ["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], ["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], ["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 := "." 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_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing") client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen. # Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_dance_index")
+69 -55
View File
@@ -1,27 +1,27 @@
extends SceneTree extends SceneTree
## Regression for the original "slide, then walk, then run" locomotion bug. ## Regression for natural authored locomotion transitions.
## ## Gameplay velocity remains responsive, but the visual BlendSpace must ease
## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb] ## into acceleration, direction changes and idle instead of teleporting the
## ## skeleton between unrelated poses on one frame.
## 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.
const RESPONSE := 60.0 const RESPONSE := 60.0
const TOP := 11.0 const TOP := 11.0
const DT := 1.0 / 60.0 const DT := 1.0 / 60.0
const RUN_FRAMES := 24 const RUN_FRAMES := 48
const STOP_FRAMES := 60
var _frames := 0 var _frames := 0
var _model: SkinnedPlayerModel = null var _model: SkinnedPlayerModel
var _speed := 0.0 var _speed := 0.0
var _first_speed := -1.0 var _first_speed := 0.0
var _first_stride := -1.0 var _first_stride := 0.0
var _worst_error := 0.0 var _settled_run_stride := 0.0
var _stop_stride := -1.0 var _first_stop_stride := 0.0
var _manual_tick := false 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_bone := -1
var _foot_rotation_start := Quaternion.IDENTITY var _foot_rotation_start := Quaternion.IDENTITY
var _foot_rotation_delta := 0.0 var _foot_rotation_delta := 0.0
@@ -29,8 +29,8 @@ var _foot_rotation_delta := 0.0
func _initialize() -> void: func _initialize() -> void:
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \ var path: String = args[0] if not args.is_empty() \
else "res://assets/characters/skins/miku.glb" else "res://assets/characters/skins/kiyoko.glb"
var scene := Node3D.new() var scene := Node3D.new()
root.add_child(scene) root.add_child(scene)
current_scene = scene current_scene = scene
@@ -43,26 +43,26 @@ func _process(_delta: float) -> bool:
_frames += 1 _frames += 1
if _frames < 8 or not _model.loaded: if _frames < 8 or not _model.loaded:
return false return false
if not _manual_tick: if _frames == 8:
_model.set_process(false) _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_bone = _model._role_bone("foot.L",
["foot.L", "LeftFoot", "Left ankle"]) ["foot.L", "LeftFoot", "Left ankle"])
var sample := _frames - 8 var sample := _frames - 8
if sample < RUN_FRAMES: if sample < RUN_FRAMES:
var response := 1.0 - exp(-RESPONSE * DT) _speed = lerpf(_speed, TOP, 1.0 - exp(-RESPONSE * DT))
_speed = lerpf(_speed, TOP, response)
else: else:
_speed = 0.0 _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.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0)
_model.update_state("ground", _speed, false) _model.update_state("ground", _speed, false)
_model._process(DT) _model._process(DT)
var stride := _model.locomotion_effective_speed_debug() 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: if _foot_bone >= 0:
var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone) var foot_rotation := _model.skeleton.get_bone_pose_rotation(_foot_bone)
if sample == 1: if sample == 1:
@@ -70,46 +70,60 @@ func _process(_delta: float) -> bool:
elif sample > 1 and sample < RUN_FRAMES: elif sample > 1 and sample < RUN_FRAMES:
_foot_rotation_delta = maxf(_foot_rotation_delta, _foot_rotation_delta = maxf(_foot_rotation_delta,
_foot_rotation_start.angle_to(foot_rotation)) _foot_rotation_start.angle_to(foot_rotation))
if sample == 0: if sample == 0:
_first_speed = _speed _first_speed = _speed
_first_stride = stride _first_stride = stride
if sample == RUN_FRAMES: elif sample == RUN_FRAMES - 1:
_stop_stride = stride _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
if sample > RUN_FRAMES + 2:
print("\n=== velocity-driven locomotion ===") func _finish() -> void:
print(" first frame: body %.3f m/s, authored stride %.3f m/s" \ _model.update_state("dash", 13.0, false)
% [_first_speed, _first_stride]) var dash_crossfade := _model._state_trans.xfade_time
print(" worst stride/speed error: %.5f m/s" % _worst_error) _model.update_state("ground", 0.0, false)
print(" stop-frame stride: %.5f m/s" % _stop_stride) var ground_crossfade := _model._state_trans.xfade_time
print(" animated foot rotation delta: %.5f rad" % _foot_rotation_delta) print("\n=== NATURAL LOCOMOTION TRANSITIONS ===")
print(" procedural lean %.5f, brake %.5f" \ print(" first frame body %.3f, visual stride %.3f" % [_first_speed, _first_stride])
% [_model.get_lean_debug(), _model.get_brake_debug()]) 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] = [] var failures: Array[String] = []
if _first_speed < 6.0: if _first_speed < 6.0:
failures.append("test did not reproduce high first-frame acceleration") failures.append("test did not reproduce high gameplay acceleration")
if absf(_first_stride - _first_speed) > 0.02: if _first_stride <= 0.15 or _first_stride >= _first_speed * 0.65:
failures.append("legs did not receive first-frame physical speed") failures.append("acceleration visual either froze or snapped on frame one")
if _worst_error > 0.02: if absf(_settled_run_stride - TOP) > 0.30:
failures.append("authored stride diverged from physical speed") failures.append("visual stride did not converge to physical run speed")
if _stop_stride > 0.02: if _first_stop_stride < _settled_run_stride * 0.65:
failures.append("legs did not reach Idle on the stop frame") failures.append("run pose snapped too close to idle on the stop frame")
if _foot_bone < 0: if _mid_stop_stride >= _first_stop_stride or _mid_stop_stride < 0.5:
failures.append("could not resolve a foot bone") failures.append("run-to-idle recovery is not a progressive ease")
elif _foot_rotation_delta < 0.03: if _settled_stop_stride > 0.30:
failures.append("runtime foot pose stayed static") failures.append("run-to-idle recovery did not settle")
if absf(_model.get_lean_debug()) > 0.02: if _largest_stride_step > 2.5:
failures.append("procedural locomotion lean is still active") failures.append("a rendered frame contains an excessive gait jump")
if absf(_model.get_brake_debug()) > 0.02: if dash_crossfade < 0.19 or ground_crossfade < 0.28:
failures.append("procedural stop pose is still active") 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(): if failures.is_empty():
print("TRANSITION_CHECK PASS: same-frame, authored locomotion\n") print("TRANSITION_CHECK PASS: responsive gameplay, eased animation\n")
quit(0) quit(0)
else: else:
for failure in failures: for failure in failures:
push_error("TRANSITION_CHECK FAIL: " + failure) push_error("TRANSITION_CHECK FAIL: " + failure)
quit(1) quit(1)
return true
return false
+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 ## 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. ## technically "matched" at 5 m/s but exploded to 6.1x playback in gameplay.
const SPEED := 11.0 const SPEED := 11.0
const SETTLE_FRAMES := 18 const DT := 1.0 / 60.0
const SETTLE_FRAMES := 45
const SAMPLE_FRAMES := 24 const SAMPLE_FRAMES := 24
const CASES := [ const CASES := [
["forward", 0.0, 1.0, "Sprint"], ["forward", 0.0, 1.0, "Sprint"],
@@ -48,6 +49,10 @@ func _run() -> void:
_expect(false, "the authored character loads") _expect(false, "the authored character loads")
_done() _done()
return 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"]) var foot := model._role_bone("foot.L", ["foot.L", "LeftFoot", "Left ankle"])
_expect(foot >= 0, "a foot bone resolves") _expect(foot >= 0, "a foot bone resolves")
@@ -57,13 +62,14 @@ func _run() -> void:
for _frame in SETTLE_FRAMES: for _frame in SETTLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0) model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false) model.update_state("ground", SPEED, false)
model._process(DT)
await process_frame await process_frame
var blend := model.locomotion_blend_debug() var blend := model.locomotion_blend_debug()
_expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999, _expect(blend.length() > 0.01 and blend.normalized().dot(direction) > 0.999,
"%s drives the authored BlendSpace in the physical direction" "%s drives the authored BlendSpace in the physical direction"
% String(test_case[0])) % 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" "%s authored stride matches %.1f m/s"
% [String(test_case[0]), SPEED]) % [String(test_case[0]), SPEED])
var expected_clip := String(test_case[3]) var expected_clip := String(test_case[3])
@@ -77,6 +83,7 @@ func _run() -> void:
for _frame in SAMPLE_FRAMES: for _frame in SAMPLE_FRAMES:
model.set_locomotion(direction.x, direction.y, 0.0) model.set_locomotion(direction.x, direction.y, 0.0)
model.update_state("ground", SPEED, false) model.update_state("ground", SPEED, false)
model._process(DT)
await process_frame await process_frame
greatest = maxf(greatest, start.angle_to( greatest = maxf(greatest, start.angle_to(
model.skeleton.get_bone_pose_rotation(foot))) model.skeleton.get_bone_pose_rotation(foot)))
+27 -21
View File
@@ -1,16 +1,17 @@
extends SceneTree 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 ## Wall traversal must select the authored side-specific pilot performances,
## run under another name. Grapple must keep its authored airborne base while ## 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 ## the live arm constraint lands the palm on the cable ray and body alignment
## responds to pendulum energy. ## responds to pendulum energy.
const SETTLE_FRAMES := 90 const SETTLE_FRAMES := 90
const SAMPLE_FRAMES := 30 const SAMPLE_FRAMES := 30
const WALL_RUN_SPEED := 13.0 const WALL_GLIDE_SPEED := 13.0
const WALL_RUN_REFERENCE_SPEED := 8.25
var _failures := 0 var _failures := 0
@@ -48,27 +49,27 @@ func _run() -> void:
"hips and both hands resolve") "hips and both hands resolve")
model.set_wall_side(-1.0) model.set_wall_side(-1.0)
model.set_wall_run_motion(Vector3(13.0, 0.0, 0.0)) model.set_wall_glide_motion(Vector3(13.0, 0.0, 0.0))
await _settle(model, "wall_run", WALL_RUN_SPEED) await _settle(model, "wall_run", WALL_GLIDE_SPEED)
_expect(model.current_clip_debug() == "WallRunLeft", _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")) 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, _expect(absf(wall_scale - 1.0) < 0.02,
"wall-run cadence tracks %.1f m/s (%.3fx)" "wall traversal keeps the authored source timing (%.3fx)"
% [WALL_RUN_SPEED, wall_scale]) % wall_scale)
var wall_foot_motion := await _sample_foot_motion( var wall_foot_motion := await _sample_foot_motion(
model, foot, "wall_run", WALL_RUN_SPEED) model, foot, "wall_run", WALL_GLIDE_SPEED)
_expect(wall_foot_motion > 0.30, _expect(wall_foot_motion > 0.12,
"wall-run has a full wall-stride leg cycle (%.3f rad)" "wall traversal retains its authored propulsion cycle (%.3f rad motion)"
% wall_foot_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, _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)) % wall_forward.dot(Vector3.RIGHT))
model.set_wall_side(1.0) 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", _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"), _expect(not model.clip_names_debug().has("WallRun"),
"the relabelled ground-run WallRun clip is absent") "the relabelled ground-run WallRun clip is absent")
@@ -98,7 +99,8 @@ func _run() -> void:
model.set_locomotion(0.0, -1.0, 0.0) model.set_locomotion(0.0, -1.0, 0.0)
await _settle(model, "ground", 8.25) await _settle(model, "ground", 8.25)
var spine_names: Array = model._rig_info.get("roles", {}).get("spine", []) 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 if not spine_names.is_empty() else -1
_expect(first_spine >= 0, "the first spine bone resolves") _expect(first_spine >= 0, "the first spine bone resolves")
if first_spine >= 0: if first_spine >= 0:
@@ -113,8 +115,12 @@ func _run() -> void:
var hips_delta := hips_pose * hips_rest.inverse() var hips_delta := hips_pose * hips_rest.inverse()
var spine_delta := spine_pose * spine_rest.inverse() var spine_delta := spine_pose * spine_rest.inverse()
var abdomen_twist := hips_delta.angle_to(spine_delta) var abdomen_twist := hips_delta.angle_to(spine_delta)
_expect(abdomen_twist < 1.45, # The Titanfall combat-backpedal deliberately keeps the weapon-facing
"backward capture keeps hips and abdomen coherent (%.3f rad)" # 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) % abdomen_twist)
var anchor := model.global_position + Vector3(3.0, 7.0, -6.0) 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> ## 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. ## shot_settings.png.
var _frames := 0 var _frames := 0
@@ -41,10 +41,10 @@ func _process(_delta: float) -> bool:
_open("_show_main_menu") _open("_show_main_menu")
215: 215:
_shot("pause") _shot("pause")
_open("_show_character") _open("_show_loadouts")
320: 320:
# Long enough for the model to load and the springs to settle. # Long enough for the model to load and the springs to settle.
_shot("character") _shot("loadouts")
_open("_show_settings") _open("_show_settings")
340: 340:
_shot("settings") _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 ; so without this filter an exported build ships characters with no secondary
; motion at all — or no character. ; motion at all — or no character.
include_filter="*.glb, *.json" include_filter="*.glb, *.json"
exclude_filter="" exclude_filter="*grace_howard*, *skins.local.json"
export_path="./Papaya-Shooter.exe" export_path="./Papaya-Shooter.exe"
patches=PackedStringArray() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
+33
View File
@@ -73,6 +73,7 @@ var weapon_db = {
var active_loadout_index: int = 0 var active_loadout_index: int = 0
var loadouts: Array = [] var loadouts: Array = []
var _legacy_skin_indices: Array[int] = []
func _ready() -> void: func _ready() -> void:
_ensure_default_loadouts() _ensure_default_loadouts()
@@ -83,6 +84,7 @@ func _ensure_default_loadouts() -> void:
for i in range(5): for i in range(5):
loadouts.append({ loadouts.append({
"name": "Loadout " + str(i + 1), "name": "Loadout " + str(i + 1),
"skin": "default",
"primary_1": "double_barrel_shotgun", "primary_1": "double_barrel_shotgun",
"primary_2": "none", "primary_2": "none",
"special": "none", "special": "none",
@@ -94,6 +96,32 @@ func get_active_loadout() -> Dictionary:
return loadouts[active_loadout_index] return loadouts[active_loadout_index]
return loadouts[0] 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: func save_loadouts() -> void:
var save_data = { var save_data = {
"active_index": active_loadout_index, "active_index": active_loadout_index,
@@ -106,6 +134,7 @@ func save_loadouts() -> void:
file.close() file.close()
func load_loadouts() -> void: func load_loadouts() -> void:
_legacy_skin_indices.clear()
if FileAccess.file_exists(SAVE_PATH): if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open(SAVE_PATH, FileAccess.READ) var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
if file: if file:
@@ -126,6 +155,10 @@ func load_loadouts() -> void:
var sl = saved_loadouts[i] var sl = saved_loadouts[i]
if sl is Dictionary: if sl is Dictionary:
if sl.has("name"): loadouts[i]["name"] = sl["name"] 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_1"): loadouts[i]["primary_1"] = sl["primary_1"]
if sl.has("primary_2"): loadouts[i]["primary_2"] = sl["primary_2"] if sl.has("primary_2"): loadouts[i]["primary_2"] = sl["primary_2"]
if sl.has("special"): loadouts[i]["special"] = sl["special"] 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_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false 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 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 ## 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. ## 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_visual_model(get_visual_model())
_jet_vfx.set_render_enabled(not is_multiplayer_authority() or third_person) _jet_vfx.set_render_enabled(not is_multiplayer_authority() or third_person)
_jet_vfx.set_dash(state == "dash", move_dir) _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 ## Swap the visual model to match a skin id. GLB skins replace the procedural
## model entirely; color skins tint the procedural model. ## model entirely; color skins tint the procedural model.
@@ -305,7 +307,9 @@ func _apply_skin_model(skin_id: String) -> void:
if has_model: if has_model:
var model := SkinnedPlayerModel.new() var model := SkinnedPlayerModel.new()
model.name = "SkinnedModel" model.name = "SkinnedModel"
model.skin_id = skin_id
model.model_path = skin.model_path 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); # Owner gets the first-person body view (head hidden, fully animated);
# everyone else sees the full third-person model. # everyone else sees the full third-person model.
model.first_person_mode = is_multiplayer_authority() 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"): if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person) 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, # 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 # release to commit. A tap too short to have aimed anything just
# toggles the last one, which is exactly what the button did before # 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. # otherwise the first movement frame still samples last frame's Idle.
if visual.has_method("set_wall_side"): if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.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) 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) visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing, synced_dance_index) 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) visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"): if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_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) visual.set_wall_run_motion(synced_velocity)
if visual.has_method("set_helmet_closed"):
visual.set_helmet_closed(synced_helmet_closed)
visual.update_state( visual.update_state(
synced_movement_state, synced_movement_state,
synced_movement_speed, 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) "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] [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_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing") client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen. # Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index") 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_crouching")
client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_helmet_closed")
client_rep_config.add_property(":synced_is_dancing") client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen. # Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_dance_index")
+19 -7
View File
@@ -9,7 +9,12 @@ full one-time character build.
Usage: Usage:
blender --background --python tools/inject_wallrun.py -- \ 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 math
import os import os
@@ -25,13 +30,20 @@ from retarget_pose import authored_world_rotation, build_segment_pairs
argv = sys.argv argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else [] argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 3: if len(argv) < 3:
print(__doc__) print(__doc__)
sys.exit(1) sys.exit(1)
CHARACTER, LIBRARY, OUTPUT = argv CHARACTER, LIBRARY, OUTPUT = argv[:3]
UP = Vector((0.0, 0.0, 1.0)) 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): def find_armature(objects):
@@ -270,10 +282,10 @@ def main():
scale = target_h / src_h if src_h > 1e-5 else 1.0 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} by_name = {action.name.split(".")[0]: action for action in new_actions}
for clip in CLIPS: for source_name, clip in CLIP_SPECS:
source = by_name.get(clip) source = by_name.get(source_name)
if source is None: if source is None:
raise RuntimeError(f"{LIBRARY}: missing {clip}") raise RuntimeError(f"{LIBRARY}: missing {source_name}")
baked = retarget_action( baked = retarget_action(
target_arm, target_arm,
target_roles, target_roles,
+20
View File
@@ -220,6 +220,26 @@ def main() -> None:
cmd.append("--keep-root-motion") cmd.append("--keep-root-motion")
run(cmd, "Retarget animation library (Blender)") 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. # 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"), run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"),
"--", final_path], "Verify the built character (Blender)") "--", 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. # Keep the useful authored front/back steps, but label them honestly.
DIRECTIONAL_CLIP_MAP = { DIRECTIONAL_CLIP_MAP = {
"strafe_front-cycle": "StrafeWalkForward", "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 # Mesh2Motion's CC0 human add-on library supplies authored forward and lateral
@@ -145,9 +151,7 @@ WALLRUN_CLIP_MAP = {
"WallRunRight": "WallRunRight", "WallRunRight": "WallRunRight",
} }
CMU_CLIP_MAP = { CMU_CLIP_MAP = {}
"RunBackward": "RunBackward",
}
UP = Vector((0.0, 0.0, 1.0)) UP = Vector((0.0, 0.0, 1.0))
@@ -1192,7 +1196,12 @@ def _weights_look_authored(meshes, roles):
bleeding += 1 bleeding += 1
if total == 0: if total == 0:
return False 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}%), " print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
f"{four / total * 100:.0f}% at four influences — " f"{four / total * 100:.0f}% at four influences — "
f"{'authored' if authored else 'solved, runtime repair stays on'}") 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: for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c): if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True 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 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) 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: class RigRoles:
"""Resolved anatomy of one armature.""" """Resolved anatomy of one armature."""
@@ -162,11 +202,78 @@ class RigRoles:
self.hips = None self.hips = None
self.head = None self.head = None
self.neck = None self.neck = None
self._seed_mmd_standard_limbs()
self._resolve_limbs() self._resolve_limbs()
self._fill_limbs_by_anatomy() self._fill_limbs_by_anatomy()
self._resolve_spine() self._resolve_spine()
self._override_mmd_standard_trunk()
self._resolve_fingers() 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 # ---------------------------------------------------------------- limbs
def _resolve_limbs(self): def _resolve_limbs(self):
@@ -192,23 +299,40 @@ class RigRoles:
""" """
claims = [] claims = []
for name in self.names: 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 continue
side = side_of(name) side = side_of(name)
if side is None: if side is None:
continue continue
stem = canon(name) stem = canon(name)
for role in LIMB_ORDER: for role in LIMB_ORDER:
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role] # Mixamo glues the parent role into every finger name
if alt.replace("_", "") in stem] # (``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: 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 # Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles
# equally well still resolves the way the list intends. # equally well still resolves the way the list intends.
order = {role: i for i, role in enumerate(LIMB_ORDER)} 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() 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: if (role, side) in self.limb or name in used:
continue continue
self.limb[(role, side)] = name self.limb[(role, side)] = name
@@ -234,7 +358,8 @@ class RigRoles:
b = b.parent b = b.parent
if b is None or b.name not in self.names: if b is None or b.name not in self.names:
break 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 continue # a twist segment is not a joint of its own
chain.append(b.name) chain.append(b.name)
# chain is now [shin, thigh, hips, ...] going up from the foot. # chain is now [shin, thigh, hips, ...] going up from the foot.
@@ -298,7 +423,14 @@ class RigRoles:
blocked = set(self.limb.values()) blocked = set(self.limb.values())
def candidate(name): 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 False
return not any(t in ("root", "master", "armature", "scene", "rootjoint") return not any(t in ("root", "master", "armature", "scene", "rootjoint")
for t in tokens(name)) for t in tokens(name))
@@ -329,8 +461,33 @@ class RigRoles:
if not best: if not best:
return return
self.hips = best[0] # Auto-Rig Pro exports ``c_traj -> root -> spine_01`` and hangs both
self.spine = best[1:] # 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: if self.spine:
self.head = self.spine[-1] self.head = self.spine[-1]
named = [n for n in self.spine if "neck" in tokens(n)] named = [n for n in self.spine if "neck" in tokens(n)]
+11 -3
View File
@@ -241,13 +241,21 @@ if _roles:
# ------------------------------------------------------------------- skeleton # ------------------------------------------------------------------- skeleton
def is_rootish(b): def is_rootish(b):
return b is None or any(t in b.name.lower() if b is None:
for t in ("root", "master", "armature")) 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 orphans = [b.name for b in arm.data.bones
if is_rootish(b.parent) and not is_rootish(b)] 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]}") f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
# ----------------------------------------------------------------------- clips # ----------------------------------------------------------------------- clips
+1 -24
View File
@@ -221,7 +221,6 @@ func _build_home() -> Control:
var spacer := Control.new() var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(spacer) col.add_child(spacer)
_build_skin_row(col)
return screen return screen
@@ -620,29 +619,6 @@ func _add_button(parent: Container, text: String, callback: Callable) -> void:
parent.add_child(btn) 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: func _play_music() -> void:
# Menu music is scene-owned, so it stops the moment a level loads. # Menu music is scene-owned, so it stops the moment a level loads.
var path := "res://assets/sounds/music_menu_premium.ogg" var path := "res://assets/sounds/music_menu_premium.ogg"
@@ -714,6 +690,7 @@ func _build_diorama() -> void:
if skin and skin.model_path != "": if skin and skin.model_path != "":
var model := SkinnedPlayerModel.new() var model := SkinnedPlayerModel.new()
model.skin_id = skin_mgr.active_skin_id model.skin_id = skin_mgr.active_skin_id
model.mecha_theme = str(skin.get("mecha_theme"))
model.position = Vector3(0.35, 0, 0) model.position = Vector3(0.35, 0, 0)
model.rotation_degrees.y = 205.0 model.rotation_degrees.y = 205.0
world.add_child(model) world.add_child(model)
+263 -204
View File
@@ -4,17 +4,13 @@ var bg: ColorRect
var main_vbox: VBoxContainer var main_vbox: VBoxContainer
var resume_btn: Button var resume_btn: Button
var respawn_btn: Button var respawn_btn: Button
var character_btn: Button
var loadouts_btn: Button var loadouts_btn: Button
var settings_btn: Button var settings_btn: Button
var return_btn: Button var return_btn: Button
var exit_btn: Button var exit_btn: Button
# Character picker # Character preview integrated into the loadout screen.
var character_editor: Control
var character_list: ItemList
var character_desc: Label var character_desc: Label
var character_back_btn: Button
var _preview_viewport: SubViewport var _preview_viewport: SubViewport
var _preview_model: SkinnedPlayerModel var _preview_model: SkinnedPlayerModel
var _preview_pivot: Node3D var _preview_pivot: Node3D
@@ -29,6 +25,9 @@ var primary1_opt: OptionButton
var primary2_opt: OptionButton var primary2_opt: OptionButton
var special_opt: OptionButton var special_opt: OptionButton
var melee_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 save_loadout_btn: Button
var back_to_main_btn: Button var back_to_main_btn: Button
var editing_index: int = -1 var editing_index: int = -1
@@ -164,11 +163,6 @@ func _build_ui() -> void:
respawn_btn.custom_minimum_size = Vector2(260, 52) respawn_btn.custom_minimum_size = Vector2(260, 52)
main_vbox.add_child(respawn_btn) 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 = Button.new()
loadouts_btn.text = "Loadouts" loadouts_btn.text = "Loadouts"
loadouts_btn.custom_minimum_size = Vector2(260, 52) loadouts_btn.custom_minimum_size = Vector2(260, 52)
@@ -189,77 +183,126 @@ func _build_ui() -> void:
exit_btn.custom_minimum_size = Vector2(260, 52) exit_btn.custom_minimum_size = Vector2(260, 52)
main_vbox.add_child(exit_btn) main_vbox.add_child(exit_btn)
_build_character_editor()
# ── Loadout Editor Root ── # ── Loadout Editor Root ──
loadout_editor = Control.new() loadout_editor = Control.new()
loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT) loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.add_child(loadout_editor) bg.add_child(loadout_editor)
var editor_center = CenterContainer.new() var screen_fill := ColorRect.new()
editor_center.set_anchors_preset(Control.PRESET_FULL_RECT) screen_fill.color = Color(0.025, 0.030, 0.060, 0.96)
loadout_editor.add_child(editor_center) screen_fill.set_anchors_preset(Control.PRESET_FULL_RECT)
loadout_editor.add_child(screen_fill)
var editor_hbox = HBoxContainer.new() var margin := MarginContainer.new()
editor_hbox.add_theme_constant_override("separation", 40) margin.set_anchors_preset(Control.PRESET_FULL_RECT)
editor_center.add_child(editor_hbox) 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() var screen_vbox := VBoxContainer.new()
editor_hbox.add_child(left_vbox) screen_vbox.add_theme_constant_override("separation", 22)
margin.add_child(screen_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 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 = Button.new()
back_to_main_btn.text = "Back to Menu" back_to_main_btn.text = "Back"
back_to_main_btn.custom_minimum_size = Vector2(0, 40) back_to_main_btn.custom_minimum_size = Vector2(170, 46)
left_vbox.add_child(back_to_main_btn) 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) editor_hbox.add_child(edit_panel)
var edit_vbox = VBoxContainer.new() var edit_margin := MarginContainer.new()
edit_vbox.add_theme_constant_override("separation", 10) edit_margin.add_theme_constant_override("margin_left", 24)
edit_panel.add_child(edit_vbox) edit_margin.add_theme_constant_override("margin_right", 24)
edit_margin.add_theme_constant_override("margin_top", 20)
var edit_title = Label.new() edit_margin.add_theme_constant_override("margin_bottom", 20)
edit_title.text = "Edit Loadout" edit_panel.add_child(edit_margin)
edit_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var edit_vbox := VBoxContainer.new()
edit_vbox.add_child(edit_title) 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 = LineEdit.new()
name_edit.placeholder_text = "Loadout Name" 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(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() primary1_opt = OptionButton.new()
edit_vbox.add_child(primary1_opt) primary1_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(primary1_opt)
edit_vbox.add_child(_create_label("Primary 2:")) options_grid.add_child(_create_label("Primary 2"))
primary2_opt = OptionButton.new() primary2_opt = OptionButton.new()
edit_vbox.add_child(primary2_opt) primary2_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(primary2_opt)
edit_vbox.add_child(_create_label("Special:")) options_grid.add_child(_create_label("Special"))
special_opt = OptionButton.new() special_opt = OptionButton.new()
edit_vbox.add_child(special_opt) special_opt.custom_minimum_size = Vector2(285, 42)
options_grid.add_child(special_opt)
var melee_lbl = Label.new() options_grid.add_child(_create_label("Melee"))
melee_lbl.text = "Melee"
edit_vbox.add_child(melee_lbl)
melee_opt = OptionButton.new() 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 = Button.new()
save_loadout_btn.text = "Save Loadout" 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) 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 Root ──
settings_editor = Control.new() settings_editor = Control.new()
settings_editor.set_anchors_preset(Control.PRESET_FULL_RECT) 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 # per-class outlines, the same cloth and hair on springs. A thumbnail would go
# stale the first time a character was re-imported. # 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() func _build_loadout_character_preview(parent: PanelContainer) -> void:
center.set_anchors_preset(Control.PRESET_FULL_RECT) var preview_margin := MarginContainer.new()
character_editor.add_child(center) preview_margin.add_theme_constant_override("margin_left", 18)
preview_margin.add_theme_constant_override("margin_right", 18)
var row = HBoxContainer.new() preview_margin.add_theme_constant_override("margin_top", 18)
row.add_theme_constant_override("separation", 34) preview_margin.add_theme_constant_override("margin_bottom", 18)
center.add_child(row) parent.add_child(preview_margin)
var column := VBoxContainer.new()
# Left: the roster column.add_theme_constant_override("separation", 12)
var left = VBoxContainer.new() preview_margin.add_child(column)
left.add_theme_constant_override("separation", 14) column.add_child(UITheme.heading("CHARACTER PREVIEW", 24))
left.custom_minimum_size = Vector2(340, 0) character_desc = UITheme.caption("", 17)
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)
character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
character_desc.custom_minimum_size = Vector2(340, 60) character_desc.custom_minimum_size = Vector2(0, 48)
left.add_child(character_desc) column.add_child(character_desc)
character_back_btn = Button.new() var vp_container := SubViewportContainer.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()
vp_container.stretch = true vp_container.stretch = true
vp_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE) vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE)
frame.add_child(vp_container) column.add_child(vp_container)
_preview_viewport = SubViewport.new() _preview_viewport = SubViewport.new()
_preview_viewport.own_world_3d = true _preview_viewport.own_world_3d = true
_preview_viewport.size = PREVIEW_SIZE _preview_viewport.size = PREVIEW_SIZE
_preview_viewport.msaa_3d = Viewport.MSAA_4X _preview_viewport.msaa_3d = Viewport.MSAA_4X
_preview_viewport.transparent_bg = true _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 _preview_viewport.process_mode = Node.PROCESS_MODE_ALWAYS
vp_container.add_child(_preview_viewport) vp_container.add_child(_preview_viewport)
var world = Node3D.new() var world := Node3D.new()
_preview_viewport.add_child(world) _preview_viewport.add_child(world)
var env := WorldEnvironment.new()
var env = WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset") env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env) world.add_child(env)
var key := DirectionalLight3D.new()
# 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()
key.rotation_degrees = Vector3(-28, 38, 0) key.rotation_degrees = Vector3(-28, 38, 0)
key.light_color = Color(1.0, 0.93, 0.84) key.light_color = Color(1.0, 0.93, 0.84)
key.light_energy = 1.6 key.light_energy = 1.6
world.add_child(key) world.add_child(key)
var fill := DirectionalLight3D.new()
var fill = DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-16, -128, 0) fill.rotation_degrees = Vector3(-16, -128, 0)
fill.light_color = Color(0.48, 0.62, 1.0) fill.light_color = Color(0.48, 0.62, 1.0)
fill.light_energy = 0.55 fill.light_energy = 0.55
world.add_child(fill) world.add_child(fill)
_preview_pivot = Node3D.new() _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 _preview_pivot.rotation_degrees.y = 180.0
world.add_child(_preview_pivot) world.add_child(_preview_pivot)
var camera := Camera3D.new()
var camera = Camera3D.new()
camera.position = Vector3(0, 1.05, 2.65) camera.position = Vector3(0, 1.05, 2.65)
camera.rotation_degrees = Vector3(-4, 0, 0) camera.rotation_degrees = Vector3(-4, 0, 0)
camera.fov = 38.0 camera.fov = 38.0
world.add_child(camera) 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 ## 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. ## preview is emptied rather than left showing whoever was selected before it.
func _load_preview(id: String, skin) -> void: func _load_preview(id: String, skin) -> void:
@@ -885,6 +841,7 @@ func _load_preview(id: String, skin) -> void:
var model := SkinnedPlayerModel.new() var model := SkinnedPlayerModel.new()
model.model_path = skin.model_path model.model_path = skin.model_path
model.skin_id = id 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 # Not first-person and not shadows-only: this is the one place the local
# player is supposed to see their own character in full. # player is supposed to see their own character in full.
model.first_person_mode = false model.first_person_mode = false
@@ -892,13 +849,14 @@ func _load_preview(id: String, skin) -> void:
model.process_mode = Node.PROCESS_MODE_ALWAYS model.process_mode = Node.PROCESS_MODE_ALWAYS
_preview_pivot.add_child(model) _preview_pivot.add_child(model)
_preview_model = model _preview_model = model
_refresh_preview_weapon()
func _process(delta: float) -> void: func _process(delta: float) -> void:
# Turntable. Only while the picker is actually on screen — the rest of the # 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 # time this node is an invisible autoload and has no business spending
# frames. # 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) _preview_pivot.rotate_y(delta * 0.45)
@@ -962,9 +920,6 @@ func _create_label(text: String) -> Label:
func _connect_signals() -> void: func _connect_signals() -> void:
resume_btn.pressed.connect(_resume) resume_btn.pressed.connect(_resume)
respawn_btn.pressed.connect(_respawn) 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) loadouts_btn.pressed.connect(_show_loadouts)
settings_btn.pressed.connect(_show_settings) settings_btn.pressed.connect(_show_settings)
return_btn.pressed.connect(_show_return_dialog) return_btn.pressed.connect(_show_return_dialog)
@@ -972,6 +927,9 @@ func _connect_signals() -> void:
back_to_main_btn.pressed.connect(_show_main_menu) back_to_main_btn.pressed.connect(_show_main_menu)
settings_back_btn.pressed.connect(_show_main_menu) settings_back_btn.pressed.connect(_show_main_menu)
save_loadout_btn.pressed.connect(_save_current_loadout) 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) quit_dialog.confirmed.connect(_quit_game)
return_dialog.confirmed.connect(_return_to_main_menu) return_dialog.confirmed.connect(_return_to_main_menu)
@@ -993,15 +951,14 @@ func _show_main_menu() -> void:
main_vbox.visible = true main_vbox.visible = true
loadout_editor.visible = false loadout_editor.visible = false
settings_editor.visible = false settings_editor.visible = false
character_editor.visible = false
func _show_loadouts() -> void: func _show_loadouts() -> void:
main_vbox.visible = false main_vbox.visible = false
settings_editor.visible = false settings_editor.visible = false
character_editor.visible = false
loadout_editor.visible = true loadout_editor.visible = true
edit_panel.visible = false
_populate_loadout_list() _populate_loadout_list()
var index := clampi(LoadoutManager.active_loadout_index, 0, LoadoutManager.loadouts.size() - 1)
_edit_loadout(index)
func _populate_video_ui() -> void: func _populate_video_ui() -> void:
if _display_mode_opt: if _display_mode_opt:
@@ -1032,7 +989,6 @@ func _populate_audio_ui() -> void:
func _show_settings() -> void: func _show_settings() -> void:
main_vbox.visible = false main_vbox.visible = false
loadout_editor.visible = false loadout_editor.visible = false
character_editor.visible = false
settings_editor.visible = true settings_editor.visible = true
_populate_keybindings() _populate_keybindings()
_populate_video_ui() _populate_video_ui()
@@ -1104,41 +1060,140 @@ func _populate_loadout_list() -> void:
c.queue_free() c.queue_free()
for i in range(LoadoutManager.loadouts.size()): for i in range(LoadoutManager.loadouts.size()):
var l = LoadoutManager.loadouts[i] var loadout: Dictionary = LoadoutManager.loadouts[i]
var hbox = HBoxContainer.new() 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 margin := MarginContainer.new()
var is_active = (i == LoadoutManager.active_loadout_index) for side in ["left", "right", "top", "bottom"]:
btn.text = l["name"] + (" (Active)" if is_active else "") margin.add_theme_constant_override("margin_" + side, 14)
btn.custom_minimum_size = Vector2(200, 40) card.add_child(margin)
btn.pressed.connect(func(): _edit_loadout(i)) var content := VBoxContainer.new()
hbox.add_child(btn) 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.text = "Equip"
equip_btn.disabled = is_active equip_btn.disabled = is_active
equip_btn.pressed.connect(func(): _equip_loadout(i)) equip_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(equip_btn) equip_btn.pressed.connect(func(): _equip_loadout(loadout_index))
actions.add_child(equip_btn)
loadout_list_vbox.add_child(hbox)
func _equip_loadout(idx: int) -> void: func _equip_loadout(idx: int) -> void:
LoadoutManager.active_loadout_index = idx if not LoadoutManager.set_active_loadout(idx):
LoadoutManager.save_loadouts() return
_apply_active_loadout_skin()
_populate_loadout_list() _populate_loadout_list()
_edit_loadout(idx)
func _edit_loadout(idx: int) -> void: func _edit_loadout(idx: int) -> void:
if idx < 0 or idx >= LoadoutManager.loadouts.size():
return
editing_index = idx editing_index = idx
var l = LoadoutManager.loadouts[idx] var loadout: Dictionary = LoadoutManager.loadouts[idx]
name_edit.text = l["name"] name_edit.text = str(loadout.get("name", "Loadout"))
edit_title_label.text = "EDIT " + name_edit.text.to_upper()
_populate_options(primary1_opt, "primary", l["primary_1"]) _populate_skin_options(str(loadout.get("skin", "default")))
_populate_options(primary2_opt, "primary", l["primary_2"]) _populate_options(primary1_opt, "primary", str(loadout.get("primary_1", "none")))
_populate_options(special_opt, "special", l["special"]) _populate_options(primary2_opt, "primary", str(loadout.get("primary_2", "none")))
if l.has("melee"): _populate_options(special_opt, "special", str(loadout.get("special", "none")))
_populate_options(melee_opt, "melee", l["melee"]) _populate_options(melee_opt, "melee", str(loadout.get("melee", "none")))
edit_panel.visible = true 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: func _populate_options(opt: OptionButton, category: String, current_id: String) -> void:
opt.clear() opt.clear()
@@ -1164,17 +1219,21 @@ func _populate_options(opt: OptionButton, category: String, current_id: String)
func _save_current_loadout() -> void: func _save_current_loadout() -> void:
if editing_index >= 0 and editing_index < LoadoutManager.loadouts.size(): 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() != "": if name_edit.text.strip_edges() != "":
l["name"] = name_edit.text.strip_edges() l["name"] = name_edit.text.strip_edges()
name_edit.text = l["name"] name_edit.text = l["name"]
l["primary_1"] = primary1_opt.get_item_metadata(primary1_opt.get_selected_id()) l["skin"] = _selected_metadata(skin_opt)
l["primary_2"] = primary2_opt.get_item_metadata(primary2_opt.get_selected_id()) l["primary_1"] = _selected_metadata(primary1_opt)
l["special"] = special_opt.get_item_metadata(special_opt.get_selected_id()) l["primary_2"] = _selected_metadata(primary2_opt)
l["melee"] = melee_opt.get_item_metadata(melee_opt.get_selected_id()) l["special"] = _selected_metadata(special_opt)
l["melee"] = _selected_metadata(melee_opt)
LoadoutManager.save_loadouts() LoadoutManager.save_loadouts()
if editing_index == LoadoutManager.active_loadout_index:
_apply_active_loadout_skin()
_populate_loadout_list() _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: func _show_exit_dialog() -> void:
quit_dialog.popup_centered() quit_dialog.popup_centered()