ani
This commit is contained in:
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
uid://ce3kjbbl1niw8
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ctjjlkxakx4ss
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,8 +63,9 @@ 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)
|
||||||
_worst_run_stride_error = maxf(_worst_run_stride_error,
|
if _motion_frame > 30:
|
||||||
absf(_model.locomotion_effective_speed_debug() - 9.0))
|
_worst_run_stride_error = maxf(_worst_run_stride_error,
|
||||||
|
absf(_model.locomotion_effective_speed_debug() - 9.0))
|
||||||
if _motion_frame == RUN_FRAMES - 1:
|
if _motion_frame == RUN_FRAMES - 1:
|
||||||
_snap("loco_stop_00_run")
|
_snap("loco_stop_00_run")
|
||||||
else:
|
else:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 := "."
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bij2lmy28usm3
|
||||||
@@ -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")
|
||||||
|
|||||||
+77
-63
@@ -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:
|
||||||
if sample > RUN_FRAMES + 2:
|
_first_stop_stride = stride
|
||||||
print("\n=== velocity-driven locomotion ===")
|
elif sample == RUN_FRAMES + 12:
|
||||||
print(" first frame: body %.3f m/s, authored stride %.3f m/s" \
|
_mid_stop_stride = stride
|
||||||
% [_first_speed, _first_stride])
|
elif sample == RUN_FRAMES + STOP_FRAMES:
|
||||||
print(" worst stride/speed error: %.5f m/s" % _worst_error)
|
_settled_stop_stride = stride
|
||||||
print(" stop-frame stride: %.5f m/s" % _stop_stride)
|
_finish()
|
||||||
print(" animated foot rotation delta: %.5f rad" % _foot_rotation_delta)
|
|
||||||
print(" procedural lean %.5f, brake %.5f" \
|
|
||||||
% [_model.get_lean_debug(), _model.get_brake_debug()])
|
|
||||||
|
|
||||||
var failures: Array[String] = []
|
|
||||||
if _first_speed < 6.0:
|
|
||||||
failures.append("test did not reproduce high first-frame acceleration")
|
|
||||||
if absf(_first_stride - _first_speed) > 0.02:
|
|
||||||
failures.append("legs did not receive first-frame physical speed")
|
|
||||||
if _worst_error > 0.02:
|
|
||||||
failures.append("authored stride diverged from physical speed")
|
|
||||||
if _stop_stride > 0.02:
|
|
||||||
failures.append("legs did not reach Idle on the stop frame")
|
|
||||||
if _foot_bone < 0:
|
|
||||||
failures.append("could not resolve a foot bone")
|
|
||||||
elif _foot_rotation_delta < 0.03:
|
|
||||||
failures.append("runtime foot pose stayed static")
|
|
||||||
if absf(_model.get_lean_debug()) > 0.02:
|
|
||||||
failures.append("procedural locomotion lean is still active")
|
|
||||||
if absf(_model.get_brake_debug()) > 0.02:
|
|
||||||
failures.append("procedural stop pose is still active")
|
|
||||||
|
|
||||||
if failures.is_empty():
|
|
||||||
print("TRANSITION_CHECK PASS: same-frame, authored locomotion\n")
|
|
||||||
quit(0)
|
|
||||||
else:
|
|
||||||
for failure in failures:
|
|
||||||
push_error("TRANSITION_CHECK FAIL: " + failure)
|
|
||||||
quit(1)
|
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
_model.update_state("dash", 13.0, false)
|
||||||
|
var dash_crossfade := _model._state_trans.xfade_time
|
||||||
|
_model.update_state("ground", 0.0, false)
|
||||||
|
var ground_crossfade := _model._state_trans.xfade_time
|
||||||
|
print("\n=== NATURAL LOCOMOTION TRANSITIONS ===")
|
||||||
|
print(" first frame body %.3f, visual stride %.3f" % [_first_speed, _first_stride])
|
||||||
|
print(" settled run %.3f, stop %.3f -> %.3f -> %.3f" % [
|
||||||
|
_settled_run_stride, _first_stop_stride, _mid_stop_stride,
|
||||||
|
_settled_stop_stride])
|
||||||
|
print(" largest visual stride step %.3f m/s" % _largest_stride_step)
|
||||||
|
print(" traversal/ground crossfades %.3f / %.3f s" % [
|
||||||
|
dash_crossfade, ground_crossfade])
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
if _first_speed < 6.0:
|
||||||
|
failures.append("test did not reproduce high gameplay acceleration")
|
||||||
|
if _first_stride <= 0.15 or _first_stride >= _first_speed * 0.65:
|
||||||
|
failures.append("acceleration visual either froze or snapped on frame one")
|
||||||
|
if absf(_settled_run_stride - TOP) > 0.30:
|
||||||
|
failures.append("visual stride did not converge to physical run speed")
|
||||||
|
if _first_stop_stride < _settled_run_stride * 0.65:
|
||||||
|
failures.append("run pose snapped too close to idle on the stop frame")
|
||||||
|
if _mid_stop_stride >= _first_stop_stride or _mid_stop_stride < 0.5:
|
||||||
|
failures.append("run-to-idle recovery is not a progressive ease")
|
||||||
|
if _settled_stop_stride > 0.30:
|
||||||
|
failures.append("run-to-idle recovery did not settle")
|
||||||
|
if _largest_stride_step > 2.5:
|
||||||
|
failures.append("a rendered frame contains an excessive gait jump")
|
||||||
|
if dash_crossfade < 0.19 or ground_crossfade < 0.28:
|
||||||
|
failures.append("full-body state crossfades are still too abrupt")
|
||||||
|
if _foot_bone < 0 or _foot_rotation_delta < 0.03:
|
||||||
|
failures.append("authored foot animation did not move")
|
||||||
|
if failures.is_empty():
|
||||||
|
print("TRANSITION_CHECK PASS: responsive gameplay, eased animation\n")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for failure in failures:
|
||||||
|
push_error("TRANSITION_CHECK FAIL: " + failure)
|
||||||
|
quit(1)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -295,4 +296,4 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
# the three level runtimes. The visible symptom was two ammo panels on screen at
|
# the three level runtimes. The visible symptom was two ammo panels on screen at
|
||||||
# once, in two different styles, overlapping in the bottom-right corner.
|
# once, in two different styles, overlapping in the bottom-right corner.
|
||||||
#
|
#
|
||||||
# A level owns the level.
|
# A level owns the level.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+274
-215
@@ -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,76 +183,125 @@ 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()
|
|
||||||
editor_hbox.add_theme_constant_override("separation", 40)
|
var margin := MarginContainer.new()
|
||||||
editor_center.add_child(editor_hbox)
|
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
for side in ["left", "right"]:
|
||||||
var left_vbox = VBoxContainer.new()
|
margin.add_theme_constant_override("margin_" + side, 42)
|
||||||
editor_hbox.add_child(left_vbox)
|
margin.add_theme_constant_override("margin_top", 30)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 30)
|
||||||
var list_title = Label.new()
|
loadout_editor.add_child(margin)
|
||||||
list_title.text = "Your Loadouts"
|
|
||||||
list_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
var screen_vbox := VBoxContainer.new()
|
||||||
left_vbox.add_child(list_title)
|
screen_vbox.add_theme_constant_override("separation", 22)
|
||||||
|
margin.add_child(screen_vbox)
|
||||||
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()
|
||||||
@@ -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()
|
||||||
@@ -1102,43 +1058,142 @@ func _get_key_name(action: String) -> String:
|
|||||||
func _populate_loadout_list() -> void:
|
func _populate_loadout_list() -> void:
|
||||||
for c in loadout_list_vbox.get_children():
|
for c in loadout_list_vbox.get_children():
|
||||||
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 btn = Button.new()
|
var card := UITheme.card()
|
||||||
var is_active = (i == LoadoutManager.active_loadout_index)
|
card.custom_minimum_size = Vector2(350, 138)
|
||||||
btn.text = l["name"] + (" (Active)" if is_active else "")
|
loadout_list_vbox.add_child(card)
|
||||||
btn.custom_minimum_size = Vector2(200, 40)
|
|
||||||
btn.pressed.connect(func(): _edit_loadout(i))
|
var margin := MarginContainer.new()
|
||||||
hbox.add_child(btn)
|
for side in ["left", "right", "top", "bottom"]:
|
||||||
|
margin.add_theme_constant_override("margin_" + side, 14)
|
||||||
var equip_btn = Button.new()
|
card.add_child(margin)
|
||||||
|
var content := VBoxContainer.new()
|
||||||
|
content.add_theme_constant_override("separation", 7)
|
||||||
|
margin.add_child(content)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user