Merge pull request 'Feat/14 movement overhaul' (#20) from feat/14-movement-overhaul into main
Reviewed-on: #20
@@ -47,6 +47,16 @@ user_settings/
|
||||
*.pidb
|
||||
*.userprefs
|
||||
|
||||
# Secrets
|
||||
.sketchfab_token
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
# Asset pipeline staging (raw downloads, not game-ready)
|
||||
assets/characters/incoming/
|
||||
|
||||
# Misc
|
||||
*LF*
|
||||
tags
|
||||
@@ -54,3 +64,6 @@ tags
|
||||
Papaya-Shooter.pck
|
||||
Papaya-Shooter.exe
|
||||
Papaya-Shooter.console.exe
|
||||
|
||||
# Raw downloaded asset packs (not game content; some paths exceed Windows limits)
|
||||
addons/lowpoly_map_gen/downloaded_assets/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
-------------------------------------------------------
|
||||
Join the Discord Server:
|
||||
https://discord.gg/vJqnRUYRfT
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Hatsune Miku",
|
||||
"uid": "34f3e7daa4c64c8a8000ae7f90b01ceb",
|
||||
"author": "Tigerar1",
|
||||
"author_url": "https://sketchfab.com/allanromanreyes",
|
||||
"license": "Free Standard",
|
||||
"license_slug": "free-st",
|
||||
"source_url": "https://sketchfab.com/3d-models/hatsune-miku-34f3e7daa4c64c8a8000ae7f90b01ceb"
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"skins": [
|
||||
{
|
||||
"id": "miku",
|
||||
"name": "Miku",
|
||||
"description": "Hatsune Miku \u2014 Virtual Idol",
|
||||
"model": "res://assets/characters/skins/miku.glb",
|
||||
"unlocked": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Anime-style radial speed lines. Fullscreen ColorRect overlay; `intensity`
|
||||
// (0..1) is driven by the player controller from horizontal speed + dash.
|
||||
// Lines live at the screen edges and jitter inward, center stays clear.
|
||||
|
||||
uniform float intensity : hint_range(0.0, 1.0) = 0.0;
|
||||
uniform vec4 line_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
|
||||
float hash(float n) {
|
||||
return fract(sin(n * 4310.17) * 43758.5453);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec2 uv = UV - vec2(0.5);
|
||||
uv.x *= 1.6; // widescreen: keep the clear zone round-ish
|
||||
float dist = length(uv);
|
||||
float ang = atan(uv.y, uv.x);
|
||||
|
||||
// Quantize the circle into spokes; each spoke gets a stable random phase.
|
||||
float spokes = 90.0;
|
||||
float id = floor((ang / 6.2831853 + 0.5) * spokes);
|
||||
float rnd = hash(id);
|
||||
|
||||
// Only some spokes draw, and they flicker over time.
|
||||
float alive = step(0.55, fract(rnd + floor(TIME * 9.0) * 0.13));
|
||||
|
||||
// Thin line across the spoke's angular width.
|
||||
float local = fract((ang / 6.2831853 + 0.5) * spokes);
|
||||
float line = smoothstep(0.5, 0.05, abs(local - 0.5)) ;
|
||||
|
||||
// Radial extent: start further out for weak intensity, reach inward as it grows.
|
||||
float start = mix(0.62, 0.34, intensity) + rnd * 0.12;
|
||||
float mask = smoothstep(start, start + 0.25, dist);
|
||||
|
||||
float a = intensity * alive * line * mask;
|
||||
COLOR = vec4(line_color.rgb, a * line_color.a * 0.55);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://l1bq145iht8
|
||||
@@ -0,0 +1,80 @@
|
||||
shader_type spatial;
|
||||
|
||||
// Cel/toon surface shader used across the game.
|
||||
// - Banded (stepped) diffuse with a tinted shadow color instead of black
|
||||
// - Soft rim light for silhouette pop
|
||||
// - Optional world-space triplanar albedo (level geometry: no UVs needed,
|
||||
// grid stays world-scaled) — otherwise standard UV sampling (characters)
|
||||
|
||||
uniform vec4 albedo_color : source_color = vec4(1.0);
|
||||
uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform bool has_texture = true;
|
||||
uniform bool use_triplanar = false;
|
||||
uniform float triplanar_tile = 2.0; // world units per texture tile
|
||||
|
||||
uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts
|
||||
uniform float band_softness : hint_range(0.001, 0.5) = 0.04;
|
||||
uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band
|
||||
uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint
|
||||
uniform float rim_strength : hint_range(0.0, 2.0) = 0.35;
|
||||
uniform float rim_width : hint_range(0.0, 1.0) = 0.65;
|
||||
uniform float specular_strength : hint_range(0.0, 1.0) = 0.25;
|
||||
uniform float specular_shininess : hint_range(1.0, 128.0) = 24.0;
|
||||
|
||||
varying vec3 world_pos;
|
||||
varying vec3 world_normal;
|
||||
|
||||
void vertex() {
|
||||
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||
}
|
||||
|
||||
vec3 sample_triplanar(vec3 p, vec3 n) {
|
||||
vec3 w = abs(n);
|
||||
w = pow(w, vec3(4.0));
|
||||
w /= (w.x + w.y + w.z);
|
||||
vec2 uv_x = p.zy / triplanar_tile;
|
||||
vec2 uv_y = p.xz / triplanar_tile;
|
||||
vec2 uv_z = p.xy / triplanar_tile;
|
||||
vec3 cx = texture(albedo_texture, uv_x).rgb;
|
||||
vec3 cy = texture(albedo_texture, uv_y).rgb;
|
||||
vec3 cz = texture(albedo_texture, uv_z).rgb;
|
||||
return cx * w.x + cy * w.y + cz * w.z;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec3 base = albedo_color.rgb;
|
||||
if (has_texture) {
|
||||
if (use_triplanar) {
|
||||
base *= sample_triplanar(world_pos, world_normal);
|
||||
} else {
|
||||
base *= texture(albedo_texture, UV).rgb;
|
||||
}
|
||||
}
|
||||
ALBEDO = base;
|
||||
ROUGHNESS = 1.0;
|
||||
SPECULAR = 0.0;
|
||||
|
||||
// Rim: brighten grazing angles for that inked-silhouette pop.
|
||||
float rim = 1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0);
|
||||
rim = smoothstep(1.0 - rim_width, 1.0, rim);
|
||||
EMISSION = base * rim * rim_strength;
|
||||
}
|
||||
|
||||
void light() {
|
||||
float ndotl = dot(NORMAL, LIGHT);
|
||||
// Shadowing folds into the band test so shadow edges band too.
|
||||
float lit = ndotl * ATTENUATION;
|
||||
float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit);
|
||||
float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit);
|
||||
// 3 tones: shadow tint -> base band (0.82) -> full light.
|
||||
float tone = mix(0.82, 1.0, mid);
|
||||
vec3 shade = mix(shadow_color.rgb, vec3(tone), band);
|
||||
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade;
|
||||
|
||||
// Stepped specular dot for glossy toon highlights.
|
||||
vec3 h = normalize(VIEW + LIGHT);
|
||||
float spec = pow(clamp(dot(NORMAL, h), 0.0, 1.0), specular_shininess);
|
||||
spec = smoothstep(0.5 - band_softness, 0.5 + band_softness, spec);
|
||||
SPECULAR_LIGHT += LIGHT_COLOR * spec * specular_strength * band;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://rm8cyl45cawk
|
||||
@@ -0,0 +1,17 @@
|
||||
shader_type spatial;
|
||||
render_mode cull_front, unshaded;
|
||||
|
||||
// Inverted-hull outline. Assign as material_overlay on a GeometryInstance3D:
|
||||
// the mesh renders a second time, grown along its normals with front faces
|
||||
// culled, leaving a colored shell visible only at the silhouette.
|
||||
|
||||
uniform vec4 outline_color : source_color = vec4(0.06, 0.05, 0.09, 1.0);
|
||||
uniform float outline_width : hint_range(0.0, 0.1) = 0.02;
|
||||
|
||||
void vertex() {
|
||||
VERTEX += NORMAL * outline_width;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
ALBEDO = outline_color.rgb;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn2biq23l8yuv
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,50 @@
|
||||
extends RefCounted
|
||||
class_name GLBLoader
|
||||
|
||||
## Loads a GLB file at runtime using GLTFDocument.
|
||||
## Usage:
|
||||
## var loader = GLBLoader.new()
|
||||
## var scene = loader.load("res://path/to/model.glb")
|
||||
## if scene:
|
||||
## get_tree().current_scene.add_child(scene)
|
||||
|
||||
static func load(glb_path: String) -> Node3D:
|
||||
print("GLBLoader: loading " + glb_path)
|
||||
|
||||
# Check if file exists
|
||||
if not FileAccess.file_exists(glb_path):
|
||||
print("GLBLoader: file not found: " + glb_path)
|
||||
return null
|
||||
|
||||
# Read file as bytes
|
||||
var file = FileAccess.open(glb_path, FileAccess.READ)
|
||||
if not file:
|
||||
print("GLBLoader: failed to open file")
|
||||
return null
|
||||
|
||||
var bytes = file.get_buffer(file.get_length())
|
||||
file.close()
|
||||
|
||||
print("GLBLoader: read %d bytes" % bytes.size())
|
||||
|
||||
# Parse with GLTFDocument
|
||||
var gltf = GLTFDocument.new()
|
||||
var state = GLTFState.new()
|
||||
|
||||
var err = gltf.append_from_buffer(bytes, "", state)
|
||||
if err != OK:
|
||||
print("GLBLoader: failed to parse GLB, error: %d" % err)
|
||||
return null
|
||||
|
||||
print("GLBLoader: parsed successfully (%d animations, %d meshes)" % [
|
||||
state.animations.size(), state.meshes.size()])
|
||||
|
||||
# Generate scene — remove_immutable_tracks=false preserves all bone
|
||||
# animation tracks (default true strips tracks where rest==pose → T-pose).
|
||||
var scene = gltf.generate_scene(state, 30, false, false)
|
||||
if not scene:
|
||||
print("GLBLoader: failed to generate scene")
|
||||
return null
|
||||
|
||||
print("GLBLoader: generated scene: %s" % scene.name)
|
||||
return scene
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb4cnikn41oot
|
||||
@@ -163,6 +163,66 @@ func _ready() -> void:
|
||||
calf_r.cast_shadow = shadow_setting
|
||||
calf_r_pivot.add_child(calf_r)
|
||||
|
||||
## Skin System
|
||||
var current_skin: PlayerSkin
|
||||
var skin_model: Node3D
|
||||
|
||||
func apply_skin(skin: PlayerSkin) -> void:
|
||||
current_skin = skin
|
||||
if skin.model_path != "" and skin.model_path != null:
|
||||
var loaded = load(skin.model_path)
|
||||
if loaded:
|
||||
_set_procedural_visible(false)
|
||||
if skin_model:
|
||||
skin_model.queue_free()
|
||||
skin_model = null
|
||||
skin_model = loaded.instantiate()
|
||||
if skin_model:
|
||||
skin_model.name = "SkinModel"
|
||||
# Fix orientation: Blender Z-up to Godot Y-up
|
||||
skin_model.rotation_degrees = Vector3(0, 0, 0)
|
||||
# Scale down if needed (Blender models can be large)
|
||||
skin_model.scale = Vector3(1, 1, 1)
|
||||
add_child(skin_model)
|
||||
print("HumanoidModel: applied skin '%s'" % skin.skin_name)
|
||||
return
|
||||
_set_procedural_visible(true)
|
||||
if skin_model:
|
||||
skin_model.queue_free()
|
||||
skin_model = null
|
||||
_apply_color(skin.color_tint)
|
||||
|
||||
func _set_procedural_visible(on: bool) -> void:
|
||||
for part in [torso, head, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
|
||||
if part:
|
||||
part.visible = on
|
||||
|
||||
## Show/hide the model to its OWNER (third-person toggle). off = shadows-only so
|
||||
## the local first-person camera doesn't see the body; on = fully visible.
|
||||
func set_owner_visible(on: bool) -> void:
|
||||
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
|
||||
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
_set_owner_shadow_recursive(self, mode)
|
||||
|
||||
func _set_owner_shadow_recursive(node: Node, mode: int) -> void:
|
||||
if node is GeometryInstance3D:
|
||||
node.cast_shadow = mode
|
||||
for child in node.get_children():
|
||||
_set_owner_shadow_recursive(child, mode)
|
||||
|
||||
func _apply_color(col: Color) -> void:
|
||||
var mat = StandardMaterial3D.new()
|
||||
mat.albedo_color = col
|
||||
mat.roughness = 0.8
|
||||
for part in [torso, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
|
||||
if part and part.mesh:
|
||||
part.mesh.material = mat
|
||||
if head and head.mesh:
|
||||
var hmat = StandardMaterial3D.new()
|
||||
hmat.albedo_color = Color(0.95, 0.85, 0.78)
|
||||
hmat.roughness = 0.8
|
||||
head.mesh.material = hmat
|
||||
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
current_state = state
|
||||
movement_speed = speed
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
extends Node3D
|
||||
class_name FPSCameraRig
|
||||
|
||||
## FPS camera rig — handles mouse look, head bob, FOV kick, wall-run tilt.
|
||||
## FPS camera rig — mouse look, head bob, FOV kick, wall-run tilt, plus
|
||||
## movement-feel feedback driven by MovementStateMachine events:
|
||||
## - landing dip scaled by impact speed
|
||||
## - pitch impulse (vault kick) with spring decay
|
||||
## - smooth crouch/slide eye height (follows the capsule's smoothed height)
|
||||
## - slide roll tilt and dash FOV punch
|
||||
## Attach as child of the CharacterBody3D player. Camera3D is a child of this node.
|
||||
|
||||
@export var sensitivity: float = 0.002
|
||||
@@ -16,16 +21,50 @@ var _target_fov: float = 90.0
|
||||
var _weapon_fov_override: float = -1.0
|
||||
var _step_offset_y: float = 0.0
|
||||
|
||||
# Movement-feel feedback state
|
||||
var _base_eye_y: float = 0.7
|
||||
var _land_dip: float = 0.0 # current downward dip (springs back to 0)
|
||||
var _pitch_impulse: float = 0.0 # extra camera pitch in radians, decays
|
||||
var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
|
||||
var _machine: MovementStateMachine = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
camera = get_node_or_null("Camera3D")
|
||||
_base_eye_y = position.y
|
||||
if camera and params:
|
||||
camera.fov = SettingsManager.world_fov
|
||||
_target_fov = SettingsManager.world_fov
|
||||
# Explicitly enable callbacks — set_script() at runtime doesn't auto-register them
|
||||
set_process_input(true)
|
||||
set_process(true)
|
||||
# The state machine may be added after us; hook events once the tree settles.
|
||||
call_deferred("_connect_machine")
|
||||
|
||||
|
||||
func _connect_machine() -> void:
|
||||
var player := get_parent()
|
||||
if not player:
|
||||
return
|
||||
var sm = player.get_node_or_null("MovementStateMachine")
|
||||
if sm and sm is MovementStateMachine:
|
||||
_machine = sm
|
||||
if not sm.movement_event.is_connected(_on_movement_event):
|
||||
sm.movement_event.connect(_on_movement_event)
|
||||
|
||||
|
||||
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
match ev:
|
||||
"land":
|
||||
var fall_speed: float = data.get("fall_speed", 0.0)
|
||||
var scale_p: float = params.land_dip_scale if params else 0.012
|
||||
var max_p: float = params.land_dip_max if params else 0.25
|
||||
_land_dip = clampf(fall_speed * scale_p, 0.0, max_p)
|
||||
"dash":
|
||||
_dash_fov_kick = 15.0
|
||||
"vault":
|
||||
pass # pitch impulse is applied via add_pitch_impulse by the machine
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -53,6 +92,14 @@ func _process(delta: float) -> void:
|
||||
if not player:
|
||||
return
|
||||
|
||||
if not _machine:
|
||||
_connect_machine()
|
||||
|
||||
var state := ""
|
||||
if _machine:
|
||||
state = _machine.current_state
|
||||
var is_sliding := state == "slide"
|
||||
|
||||
# ── FOV kick ──────────────────────────────────────────────────────────
|
||||
_target_fov = SettingsManager.world_fov
|
||||
var hspeed := Vector2(player.velocity.x, player.velocity.z).length()
|
||||
@@ -61,15 +108,24 @@ func _process(delta: float) -> void:
|
||||
var speed_factor = clampf((hspeed - params.walk_speed) / (params.walk_speed * 0.5), 0.0, 1.0)
|
||||
_target_fov = lerpf(SettingsManager.world_fov, SettingsManager.world_fov + 20.0, speed_factor)
|
||||
|
||||
# Dash punch decays on top
|
||||
_dash_fov_kick = lerpf(_dash_fov_kick, 0.0, 1.0 - exp(-6.0 * delta))
|
||||
_target_fov += _dash_fov_kick
|
||||
|
||||
if _weapon_fov_override > 0.0:
|
||||
_target_fov = _weapon_fov_override
|
||||
|
||||
camera.fov = lerpf(camera.fov, _target_fov, 1.0 - exp(-params.fov_lerp_speed * delta))
|
||||
|
||||
# ── Head bob ──────────────────────────────────────────────────────────
|
||||
var sm = player.get_node_or_null("MovementStateMachine")
|
||||
var is_sliding = sm and sm.current_state == "slide"
|
||||
# ── Crouch / slide eye height (follows the smoothed capsule) ──────────
|
||||
var crouch_factor := 0.0
|
||||
if _machine:
|
||||
crouch_factor = _machine.get_crouch_factor()
|
||||
var eye_drop: float = _machine.original_capsule_height * 0.5 if _machine else 0.9
|
||||
var target_eye_y: float = _base_eye_y - crouch_factor * eye_drop
|
||||
position.y = lerpf(position.y, target_eye_y, 1.0 - exp(-12.0 * delta))
|
||||
|
||||
# ── Head bob ──────────────────────────────────────────────────────────
|
||||
if player.is_on_floor() and hspeed > 1.0 and not is_sliding:
|
||||
_bob_timer += delta * params.head_bob_frequency * (hspeed / params.walk_speed)
|
||||
var bob_y := sin(_bob_timer) * params.head_bob_amplitude
|
||||
@@ -81,12 +137,25 @@ func _process(delta: float) -> void:
|
||||
camera.position.y = lerp(camera.position.y, 0.0, 0.15)
|
||||
camera.position.x = lerp(camera.position.x, 0.0, 0.15)
|
||||
|
||||
# ── Landing dip (springs back up) ─────────────────────────────────────
|
||||
_land_dip = lerpf(_land_dip, 0.0, 1.0 - exp(-8.0 * delta))
|
||||
camera.position.y -= _land_dip
|
||||
|
||||
# ── Step Smoothing ─────────────────────────────────────────────────────
|
||||
_step_offset_y = lerpf(_step_offset_y, 0.0, 15.0 * delta)
|
||||
camera.position.y += _step_offset_y
|
||||
|
||||
# ── Wall-run tilt ─────────────────────────────────────────────────────
|
||||
_current_tilt = lerpf(_current_tilt, _target_tilt, 1.0 - exp(-params.wall_run_tilt_speed * delta))
|
||||
# ── Pitch impulse (vault kick etc.), spring-decayed ───────────────────
|
||||
_pitch_impulse = lerpf(_pitch_impulse, 0.0, 1.0 - exp(-7.0 * delta))
|
||||
camera.rotation.x = _pitch_impulse
|
||||
|
||||
# ── Tilt: wall-run lean + slide roll ──────────────────────────────────
|
||||
var tilt_target := _target_tilt
|
||||
if is_sliding:
|
||||
# Subtle roll in the slide's steering direction
|
||||
var slide_roll: float = params.slide_tilt_angle
|
||||
tilt_target += -_machine.input_dir.x * slide_roll if _machine else 0.0
|
||||
_current_tilt = lerpf(_current_tilt, tilt_target, 1.0 - exp(-params.wall_run_tilt_speed * delta))
|
||||
camera.rotation.z = deg_to_rad(_current_tilt)
|
||||
|
||||
## Called by the movement system to set wall-run tilt direction.
|
||||
@@ -105,8 +174,11 @@ func set_weapon_fov(fov: float) -> void:
|
||||
func clear_wall_tilt() -> void:
|
||||
_target_tilt = 0.0
|
||||
|
||||
## Kick the camera pitch (degrees); decays back smoothly. Used for vaults.
|
||||
func add_pitch_impulse(degrees: float) -> void:
|
||||
_pitch_impulse += deg_to_rad(degrees)
|
||||
|
||||
## Adds an offset to the camera so that when the player physics body snaps up a step,
|
||||
## the camera interpolates smoothly instead of snapping.
|
||||
func add_step_offset(offset_y: float) -> void:
|
||||
_step_offset_y += offset_y
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
extends Resource
|
||||
class_name PlayerSkin
|
||||
|
||||
## A player skin defines the visual appearance of the character.
|
||||
## Can be either a color tint (for the default procedural model)
|
||||
## or a full GLB model replacement.
|
||||
|
||||
@export var skin_name: String = "Default"
|
||||
@export var description: String = ""
|
||||
@export var model_path: String = "" # Path to .glb file, empty = use procedural
|
||||
@export var color_tint: Color = Color(0.2, 0.4, 0.8) # For procedural model
|
||||
@export var is_unlocked: bool = true
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpuejdwwevdn
|
||||
@@ -0,0 +1,100 @@
|
||||
extends Node
|
||||
|
||||
## Autoload: manages all available player skins.
|
||||
##
|
||||
## Skins come from two places:
|
||||
## 1. Built-in color tints for the procedural model (registered below).
|
||||
## 2. assets/characters/skins/skins.json — written automatically by
|
||||
## tools/pipeline.py for every imported character. No code changes needed
|
||||
## to add a new model: run the pipeline, restart the game, it's there.
|
||||
##
|
||||
## The selected skin persists to user://skin_selection.cfg and is synced to
|
||||
## other players via PlayerMovementController.synced_skin_id.
|
||||
|
||||
signal skin_changed(skin_id: String)
|
||||
|
||||
const SKINS_JSON := "res://assets/characters/skins/skins.json"
|
||||
const SELECTION_CFG := "user://skin_selection.cfg"
|
||||
|
||||
var skins: Dictionary = {}
|
||||
var active_skin_id: String = "default"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_register_builtin_skins()
|
||||
_load_skins_json()
|
||||
_load_selection()
|
||||
print("SkinManager: %d skins available" % skins.size())
|
||||
|
||||
|
||||
func _register_builtin_skins() -> void:
|
||||
_add_color_skin("default", "Default", "Standard issue", Color(0.2, 0.4, 0.8))
|
||||
_add_color_skin("red_team", "Red Team", "Red team colors", Color(0.8, 0.2, 0.2))
|
||||
_add_color_skin("forest", "Forest", "Forest camouflage", Color(0.2, 0.6, 0.2))
|
||||
|
||||
|
||||
func _add_color_skin(id: String, display_name: String, description: String, tint: Color) -> void:
|
||||
var skin := PlayerSkin.new()
|
||||
skin.skin_name = display_name
|
||||
skin.description = description
|
||||
skin.color_tint = tint
|
||||
skin.is_unlocked = true
|
||||
skins[id] = skin
|
||||
|
||||
|
||||
func _load_skins_json() -> void:
|
||||
if not FileAccess.file_exists(SKINS_JSON):
|
||||
return
|
||||
var file := FileAccess.open(SKINS_JSON, FileAccess.READ)
|
||||
if not file:
|
||||
return
|
||||
var data = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not data is Dictionary or not data.has("skins"):
|
||||
push_warning("SkinManager: skins.json is malformed")
|
||||
return
|
||||
for entry in data["skins"]:
|
||||
if not entry is Dictionary or not entry.has("id"):
|
||||
continue
|
||||
var skin := PlayerSkin.new()
|
||||
skin.skin_name = entry.get("name", entry["id"])
|
||||
skin.description = entry.get("description", "")
|
||||
skin.model_path = entry.get("model", "")
|
||||
skin.is_unlocked = entry.get("unlocked", true)
|
||||
skins[entry["id"]] = skin
|
||||
|
||||
|
||||
# ── Selection ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func get_skin(skin_id: String) -> PlayerSkin:
|
||||
return skins.get(skin_id, skins["default"])
|
||||
|
||||
|
||||
func get_active_skin() -> PlayerSkin:
|
||||
return get_skin(active_skin_id)
|
||||
|
||||
|
||||
func set_active_skin(skin_id: String) -> void:
|
||||
if not skins.has(skin_id) or not skins[skin_id].is_unlocked:
|
||||
return
|
||||
active_skin_id = skin_id
|
||||
_save_selection()
|
||||
skin_changed.emit(skin_id)
|
||||
|
||||
|
||||
func get_skin_ids() -> Array:
|
||||
return skins.keys()
|
||||
|
||||
|
||||
func _save_selection() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("skin", "active", active_skin_id)
|
||||
cfg.save(SELECTION_CFG)
|
||||
|
||||
|
||||
func _load_selection() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SELECTION_CFG) == OK:
|
||||
var saved: String = cfg.get_value("skin", "active", "default")
|
||||
if skins.has(saved):
|
||||
active_skin_id = saved
|
||||
@@ -0,0 +1 @@
|
||||
uid://corbsludoaige
|
||||
@@ -0,0 +1,518 @@
|
||||
extends Node3D
|
||||
class_name SkinnedPlayerModel
|
||||
|
||||
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
|
||||
## with a Mixamo-compatible skeleton and the canonical animation set.
|
||||
##
|
||||
## Drop-in replacement for the procedural HumanoidModel:
|
||||
## - update_state(state, speed, is_crouching) — drives animation selection
|
||||
## - set_weapon(script_path) — third-person weapon in hand
|
||||
## - shadows_only — legacy local-player mode
|
||||
##
|
||||
## View modes (for the LOCAL player only):
|
||||
## - first_person_mode = true → model renders shadows-only for the owner, so
|
||||
## the camera (which sits inside the head) never shows the inside of the
|
||||
## mesh. Still fully animated; still visible to other players and in shadows.
|
||||
## - Press the third-person toggle → set_owner_visible(true) makes the full
|
||||
## animated model visible to the owner too (over-the-shoulder camera).
|
||||
## - first_person_mode = false → full third-person model for other players.
|
||||
|
||||
@export var model_path: String = ""
|
||||
@export var first_person_mode: bool = false
|
||||
@export var shadows_only: bool = false
|
||||
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
|
||||
|
||||
## Horizontal speed (m/s) at which the Run clip plays at authored speed.
|
||||
@export var run_anim_reference_speed: float = 8.0
|
||||
@export var walk_anim_reference_speed: float = 3.0
|
||||
|
||||
## Canonical clip names -> fallback chain. First clip that exists wins, so a
|
||||
## model with only Idle/Walk/Run still animates in every movement state.
|
||||
const CLIP_FALLBACKS := {
|
||||
"Idle": ["Idle"],
|
||||
"Walk": ["Walk", "Run", "Idle"],
|
||||
"Run": ["Run", "Walk", "Idle"],
|
||||
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
|
||||
"Jump": ["Jump", "Fall", "Idle"],
|
||||
"Fall": ["Fall", "Jump", "Idle"],
|
||||
"Land": ["Land", "Idle"],
|
||||
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
|
||||
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
|
||||
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
|
||||
"WallRun": ["WallRun", "Run", "Walk"],
|
||||
"WallCling": ["WallCling", "CrouchIdle", "Idle"],
|
||||
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
|
||||
"Dash": ["Dash", "Sprint", "Run", "Idle"],
|
||||
"Death": ["Death", "Fall"],
|
||||
"Hit": ["Hit", "Idle"],
|
||||
"Dance": ["Dance", "Idle"],
|
||||
}
|
||||
|
||||
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
|
||||
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple", "Dance"]
|
||||
|
||||
const BLEND_TIME := 0.15
|
||||
|
||||
var skeleton: Skeleton3D
|
||||
var animation_player: AnimationPlayer
|
||||
var loaded: bool = false
|
||||
|
||||
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
|
||||
var _current_clip: String = ""
|
||||
var _weapon_attachment: BoneAttachment3D
|
||||
var is_holding_weapon: bool = false
|
||||
|
||||
# Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
|
||||
# the base clip by a SkeletonModifier3D so it composes with the animation.
|
||||
var _pose_mod: ShooterPoseModifier
|
||||
var _target_strafe: float = 0.0
|
||||
var _target_fwd: float = 0.0
|
||||
var _target_ads: float = 0.0
|
||||
var _target_wall: float = 0.0
|
||||
var _cur_strafe: float = 0.0
|
||||
var _cur_fwd: float = 0.0
|
||||
var _cur_ads: float = 0.0
|
||||
var _cur_slide: float = 0.0
|
||||
var _cur_wall: float = 0.0
|
||||
const POSE_SMOOTH := 10.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if model_path != "":
|
||||
load_model(model_path)
|
||||
|
||||
|
||||
func load_model(path: String) -> void:
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
skeleton = null
|
||||
animation_player = null
|
||||
loaded = false
|
||||
_resolved_clips.clear()
|
||||
_current_clip = ""
|
||||
_weapon_attachment = null
|
||||
|
||||
var scene := GLBLoader.load(path)
|
||||
if not scene:
|
||||
push_warning("SkinnedPlayerModel: failed to load '%s'" % path)
|
||||
return
|
||||
add_child(scene)
|
||||
if facing_flip:
|
||||
scene.rotation_degrees.y = 180.0
|
||||
|
||||
skeleton = _find_node_of_type(scene, "Skeleton3D") as Skeleton3D
|
||||
animation_player = _find_node_of_type(scene, "AnimationPlayer") as AnimationPlayer
|
||||
|
||||
if not skeleton:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
|
||||
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
||||
LevelMaterials.apply_toon_recursive(scene)
|
||||
if animation_player:
|
||||
_index_animations()
|
||||
else:
|
||||
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
|
||||
|
||||
# The local owner renders shadows-only (the camera is inside the head, so
|
||||
# showing the mesh would show the inside of it). Other players see it fully.
|
||||
# The third-person toggle calls set_owner_visible(true) to reveal it.
|
||||
if shadows_only or first_person_mode:
|
||||
_set_shadows_recursive(self)
|
||||
|
||||
loaded = true
|
||||
_play_clip("Idle")
|
||||
|
||||
|
||||
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
||||
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||
## pose (a permanent T-pose) while the skeleton animates invisibly. This repairs
|
||||
## that at load time so a bad export degrades gracefully instead of T-posing.
|
||||
func _ensure_meshes_bound(scene: Node) -> void:
|
||||
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.skin == null:
|
||||
continue # not a skinned mesh
|
||||
if mi.skeleton.is_empty() or mi.get_node_or_null(mi.skeleton) != skeleton:
|
||||
mi.skeleton = mi.get_path_to(skeleton)
|
||||
|
||||
|
||||
## Map canonical clip names to whatever actually shipped in the GLB and set
|
||||
## loop modes (glTF has no loop flag, so we set it here).
|
||||
func _index_animations() -> void:
|
||||
var available := animation_player.get_animation_list()
|
||||
for canonical in CLIP_FALLBACKS:
|
||||
for candidate in CLIP_FALLBACKS[canonical]:
|
||||
var match_name := _find_clip(available, candidate)
|
||||
if match_name != "":
|
||||
_resolved_clips[canonical] = match_name
|
||||
break
|
||||
for canonical in LOOPING_CLIPS:
|
||||
if _resolved_clips.has(canonical):
|
||||
var anim := animation_player.get_animation(_resolved_clips[canonical])
|
||||
if anim:
|
||||
anim.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _find_clip(available: PackedStringArray, wanted: String) -> String:
|
||||
for name in available:
|
||||
if name == wanted:
|
||||
return name
|
||||
# Tolerate library prefixes ("mixamo/Run") and case differences.
|
||||
var wanted_lower := wanted.to_lower()
|
||||
for name in available:
|
||||
var base := name.get_slice("/", name.get_slice_count("/") - 1).to_lower()
|
||||
if base == wanted_lower:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
# ── View modes ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Show or hide the model to its OWNER. In first person we render shadows-only
|
||||
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
|
||||
## toggle calls this with on=true to reveal the full animated model. Either way
|
||||
## the model keeps casting shadows and stays visible to other players.
|
||||
func set_owner_visible(on: bool) -> void:
|
||||
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
|
||||
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
_set_shadow_mode_recursive(self, mode)
|
||||
|
||||
|
||||
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
||||
if node is GeometryInstance3D:
|
||||
node.cast_shadow = mode
|
||||
for child in node.get_children():
|
||||
_set_shadow_mode_recursive(child, mode)
|
||||
|
||||
|
||||
# ── Animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
var _prev_state: String = ""
|
||||
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
||||
var _dancing: bool = false
|
||||
|
||||
|
||||
## Play a one-shot clip (Hit reaction, Land, ...) over locomotion for
|
||||
## `lock_time` seconds; locomotion resumes afterwards.
|
||||
func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
|
||||
if not loaded or not _resolved_clips.has(canonical):
|
||||
return
|
||||
_oneshot_lock = lock_time
|
||||
_play_clip(canonical, true)
|
||||
|
||||
|
||||
## Emote toggle (Dance). Shown while grounded and near-idle; any real
|
||||
## movement breaks it (the controller clears the flag too).
|
||||
func set_dancing(on: bool) -> void:
|
||||
_dancing = on
|
||||
|
||||
|
||||
## Same contract as HumanoidModel.update_state(). Called by the movement
|
||||
## controller each frame with either local or network-synced state.
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
if not loaded or not animation_player:
|
||||
return
|
||||
|
||||
# One-shots (Land, Hit) own playback briefly.
|
||||
if _oneshot_lock > 0.0:
|
||||
_oneshot_lock -= get_process_delta_time()
|
||||
if _oneshot_lock > 0.0:
|
||||
_prev_state = state
|
||||
return
|
||||
# A heavy landing plays the Land one-shot before locomotion resumes.
|
||||
if state in ["ground", "idle"] and _prev_state == "air" \
|
||||
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
|
||||
_oneshot_lock = 0.25
|
||||
_play_clip("Land")
|
||||
_prev_state = state
|
||||
return
|
||||
_prev_state = state
|
||||
|
||||
var clip := "Idle"
|
||||
match state:
|
||||
"ground", "idle":
|
||||
if _dancing and speed < 0.5 and not is_crouching:
|
||||
clip = "Dance"
|
||||
elif is_crouching:
|
||||
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
|
||||
elif speed > run_anim_reference_speed * 1.35:
|
||||
clip = "Sprint"
|
||||
elif speed > walk_anim_reference_speed * 1.2:
|
||||
clip = "Run"
|
||||
elif speed > 0.5:
|
||||
clip = "Walk"
|
||||
"air":
|
||||
# Rising = jump, falling = the fall loop.
|
||||
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
|
||||
"slide":
|
||||
clip = "Slide"
|
||||
"wall_run":
|
||||
clip = "WallRun"
|
||||
"wall_cling", "wall_climb":
|
||||
clip = "WallCling"
|
||||
"grapple":
|
||||
clip = "Grapple"
|
||||
"dash":
|
||||
clip = "Dash"
|
||||
"death":
|
||||
clip = "Death"
|
||||
|
||||
_play_clip(clip)
|
||||
|
||||
if _pose_mod:
|
||||
_pose_mod.state = state
|
||||
_pose_mod.weapon_held = is_holding_weapon
|
||||
|
||||
# Scale locomotion playback so feet keep up with actual movement speed.
|
||||
match clip:
|
||||
"Walk":
|
||||
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
|
||||
"Run", "Sprint", "WallRun":
|
||||
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
|
||||
_:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
## Drives the procedural pose layer. Called by the controller each frame.
|
||||
## strafe: -1 (moving left) .. +1 (moving right), relative to facing
|
||||
## fwd: -1 (moving back) .. +1 (moving forward), relative to facing
|
||||
## ads: 0 (hip) .. 1 (aiming down sights)
|
||||
func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
|
||||
_target_strafe = clampf(strafe, -1.0, 1.0)
|
||||
_target_fwd = clampf(fwd, -1.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.
|
||||
## Drives a whole-body lean into the wall.
|
||||
func set_wall_side(side: float) -> void:
|
||||
_target_wall = clampf(side, -1.0, 1.0)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _pose_mod:
|
||||
return
|
||||
var t := 1.0 - exp(-POSE_SMOOTH * delta)
|
||||
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
|
||||
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
|
||||
_cur_ads = lerpf(_cur_ads, _target_ads, t)
|
||||
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
|
||||
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||
_cur_wall = lerpf(_cur_wall, wall_target, t)
|
||||
_pose_mod.strafe = _cur_strafe
|
||||
_pose_mod.fwd = _cur_fwd
|
||||
_pose_mod.ads = _cur_ads
|
||||
_pose_mod.slide = _cur_slide
|
||||
_pose_mod.wall = _cur_wall
|
||||
|
||||
|
||||
func _play_clip(canonical: String, restart: bool = false) -> void:
|
||||
if not animation_player or not _resolved_clips.has(canonical):
|
||||
return
|
||||
var clip_name: String = _resolved_clips[canonical]
|
||||
if not restart and _current_clip == clip_name and animation_player.is_playing():
|
||||
return
|
||||
if restart:
|
||||
animation_player.stop()
|
||||
animation_player.play(clip_name, BLEND_TIME)
|
||||
_current_clip = clip_name
|
||||
|
||||
|
||||
## Vertical velocity of the body this model is attached to (0 if detached).
|
||||
func _vertical_speed() -> float:
|
||||
var p := get_parent()
|
||||
if p is CharacterBody3D:
|
||||
return p.velocity.y
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── Third-person weapon ───────────────────────────────────────────────────────
|
||||
|
||||
## Attach a weapon (by weapon script path) to the right hand bone so other
|
||||
## players see what this player is holding. Mirrors HumanoidModel.set_weapon().
|
||||
func set_weapon(script_path: String) -> void:
|
||||
if _weapon_attachment:
|
||||
_weapon_attachment.queue_free()
|
||||
_weapon_attachment = null
|
||||
is_holding_weapon = script_path != ""
|
||||
if script_path == "" or not skeleton:
|
||||
return
|
||||
|
||||
var script = load(script_path)
|
||||
if not script:
|
||||
return
|
||||
var w = script.new()
|
||||
w.name = "ThirdPersonWeapon"
|
||||
w.set_meta("is_third_person_weapon", true)
|
||||
w.ready.connect(func():
|
||||
w.set_process(false)
|
||||
w.set_process_input(false)
|
||||
if shadows_only:
|
||||
_set_shadows_recursive(w)
|
||||
# Undo the first-person viewmodel placement from the weapon's _ready.
|
||||
w.position = Vector3(0.0, 0.08, 0.03)
|
||||
w.rotation_degrees = Vector3(0, 90, 0)
|
||||
w.scale = Vector3(0.8, 0.8, 0.8)
|
||||
)
|
||||
|
||||
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
|
||||
if hand_idx >= 0:
|
||||
_weapon_attachment = BoneAttachment3D.new()
|
||||
_weapon_attachment.name = "WeaponAttachment"
|
||||
skeleton.add_child(_weapon_attachment)
|
||||
_weapon_attachment.bone_idx = hand_idx
|
||||
_weapon_attachment.add_child(w)
|
||||
else:
|
||||
# No hand bone — hold it at chest height like the procedural model did.
|
||||
w.ready.connect(func():
|
||||
w.position = Vector3(-0.15, 1.0, 0.4)
|
||||
w.rotation_degrees = Vector3(0, 180, 0)
|
||||
)
|
||||
add_child(w)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _find_bone(name_parts: Array) -> int:
|
||||
if not skeleton:
|
||||
return -1
|
||||
for i in range(skeleton.get_bone_count()):
|
||||
var bone_name := skeleton.get_bone_name(i)
|
||||
for part in name_parts:
|
||||
if bone_name.findn(part) != -1:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _find_node_of_type(node: Node, type_name: String) -> Node:
|
||||
if node.is_class(type_name):
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_node_of_type(child, type_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _set_shadows_recursive(node: Node) -> void:
|
||||
if node is GeometryInstance3D:
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
for child in node.get_children():
|
||||
_set_shadows_recursive(child)
|
||||
|
||||
|
||||
# ── Procedural shooter pose layer ─────────────────────────────────────────────
|
||||
#
|
||||
# Runs after the AnimationPlayer each frame and layers shooter-feel poses on top
|
||||
# of the base clip: lean into the movement direction, a slide that leans back and
|
||||
# looks forward, and an always-held weapon that raises to ADS. All rotations are
|
||||
# authored in the skeleton's own space (forward = +Z, up = +Y, character-right =
|
||||
# -X) and converted into each bone's local pose, so they read intuitively.
|
||||
class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
# Inputs, written by the owning SkinnedPlayerModel each frame.
|
||||
var strafe: float = 0.0 # -1 left .. +1 right
|
||||
var fwd: float = 0.0 # -1 back .. +1 forward
|
||||
var ads: float = 0.0 # 0 hip .. 1 aiming
|
||||
var slide: float = 0.0 # 0 .. 1 slide blend
|
||||
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
|
||||
var state: String = "idle"
|
||||
var weapon_held: bool = false
|
||||
|
||||
# Tuning (radians). Positive pitch leans forward; positive roll leans right.
|
||||
const LEAN_ROLL := 0.30
|
||||
const LEAN_PITCH := 0.18
|
||||
const SLIDE_BACK := 0.75 # torso lean-back during slide
|
||||
const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward
|
||||
const ADS_LIFT := 1.05 # upper-arm raise toward aim at full ADS
|
||||
const ADS_SWING := 0.6 # swing arms in toward centre-front on ADS
|
||||
const ADS_FOREARM := 0.55 # forearm bend to bring the weapon up on ADS
|
||||
|
||||
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
|
||||
func _resolve() -> void:
|
||||
var skel := get_skeleton()
|
||||
var names := SPINE + ["DEF-neck", "DEF-head",
|
||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L"]
|
||||
for n in names:
|
||||
_idx[n] = skel.find_bone(n)
|
||||
_resolved = true
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if not skel:
|
||||
return
|
||||
if not _resolved:
|
||||
_resolve()
|
||||
|
||||
_apply_lean(skel)
|
||||
if slide > 0.01:
|
||||
_apply_slide(skel)
|
||||
if absf(wall) > 0.01:
|
||||
_apply_wall_lean(skel)
|
||||
if weapon_held:
|
||||
_apply_weapon(skel)
|
||||
|
||||
# Distribute a skeleton-space lean across the spine bones.
|
||||
func _apply_lean(skel: Skeleton3D) -> void:
|
||||
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
|
||||
var roll := strafe * LEAN_ROLL * (1.0 - slide)
|
||||
if absf(pitch) < 0.001 and absf(roll) < 0.001:
|
||||
return
|
||||
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
|
||||
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
|
||||
# Wall run: roll the torso into the wall (+wall = wall on the right).
|
||||
func _apply_wall_lean(skel: Skeleton3D) -> void:
|
||||
var roll := Quaternion(Vector3(0, 0, 1), wall * 0.35)
|
||||
var per := Quaternion.IDENTITY.slerp(roll, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
|
||||
# Slide: lean the whole torso back, then pitch the head up to look forward.
|
||||
func _apply_slide(skel: Skeleton3D) -> void:
|
||||
var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide)
|
||||
var per := Quaternion.IDENTITY.slerp(back, 1.0 / SPINE.size())
|
||||
for n in SPINE:
|
||||
_add_space(skel, _idx.get(n, -1), per)
|
||||
var up := Quaternion(Vector3(1, 0, 0), SLIDE_HEAD_UP * slide)
|
||||
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(up, 0.5))
|
||||
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(up, 0.5))
|
||||
|
||||
# Weapon hold. At the hip the base clip already keeps the arms down with the
|
||||
# weapon (attached to the hand) at the side, so we leave it alone. On ADS we
|
||||
# additively lift both arms forward-up toward an aiming pose. Rotations are
|
||||
# about the skeleton's X axis (the shoulder line), so the down arms swing
|
||||
# forward to eye level.
|
||||
func _apply_weapon(skel: Skeleton3D) -> void:
|
||||
if ads < 0.01:
|
||||
return
|
||||
# Raise about X (down arm -> forward) and swing about Y so each arm comes
|
||||
# IN toward centre-front instead of splaying out to the side. Right arm
|
||||
# is on -X so it swings +Y; the left mirrors it.
|
||||
var lift := Quaternion(Vector3(1, 0, 0), -ADS_LIFT * ads)
|
||||
var swing := ADS_SWING * ads
|
||||
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), Quaternion(Vector3(0, 1, 0), swing) * lift)
|
||||
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), Quaternion(Vector3(0, 1, 0), -swing) * lift)
|
||||
var bend := Quaternion(Vector3(1, 0, 0), -ADS_FOREARM * ads)
|
||||
_add_space(skel, _idx.get("DEF-forearm.R", -1), bend)
|
||||
_add_space(skel, _idx.get("DEF-forearm.L", -1), bend)
|
||||
|
||||
# Compose a skeleton-space rotation onto a bone's animated local pose.
|
||||
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
|
||||
if idx < 0:
|
||||
return
|
||||
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
|
||||
var local := b.inverse() * q_space * b
|
||||
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3sy8q0weklf
|
||||
@@ -7,7 +7,7 @@ func _build_geometry() -> void:
|
||||
var env = get_node_or_null("WorldEnvironment")
|
||||
if env:
|
||||
var environment = env.environment
|
||||
environment.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||
# (keep the shared linear tonemap — ACES crushes the cel bands)
|
||||
if environment.sky and environment.sky.sky_material:
|
||||
var sky_mat = environment.sky.sky_material
|
||||
sky_mat.sky_top_color = Color(0.3, 0.5, 0.8)
|
||||
@@ -38,14 +38,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
|
||||
func _build_dust2_layout() -> void:
|
||||
# Materials
|
||||
var wall_mat = StandardMaterial3D.new()
|
||||
wall_mat.albedo_color = Color(0.85, 0.75, 0.6) # Sandstone
|
||||
|
||||
var floor_mat = StandardMaterial3D.new()
|
||||
floor_mat.albedo_color = Color(0.7, 0.65, 0.55) # Dusty ground
|
||||
|
||||
var box_mat = StandardMaterial3D.new()
|
||||
box_mat.albedo_color = Color(0.4, 0.3, 0.2) # Wood crates
|
||||
var wall_mat = LevelMaterials.tinted(Color(0.85, 0.75, 0.6)) # Sandstone
|
||||
var floor_mat = LevelMaterials.tinted(Color(0.7, 0.65, 0.55)) # Dusty ground
|
||||
var box_mat = LevelMaterials.tinted(Color(0.4, 0.3, 0.2), true) # Wood crates
|
||||
|
||||
# Root CSG
|
||||
var root_csg = CSGCombiner3D.new()
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://dn5ndscghwovm
|
||||
uid://5ayptqm4yjih
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless smoke test for the player spawn + skin + animation pipeline.
|
||||
## Boots the test level as a singleplayer match, spawns the local player,
|
||||
## ticks frames, and verifies the visual model came up for both the
|
||||
## procedural skin and a GLB skin.
|
||||
##
|
||||
## Run: godot --headless --path . -s res://debug/spawn_smoke_test.gd
|
||||
|
||||
var _failures: Array = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
||||
_check(skin_mgr != null, "SkinManager autoload exists")
|
||||
var audio_mgr = root.get_node_or_null("/root/AudioManager")
|
||||
_check(audio_mgr != null, "AudioManager autoload exists")
|
||||
if audio_mgr:
|
||||
_check(audio_mgr.has_sound("ak47_fire"), "AudioManager auto-registered ak47_fire")
|
||||
|
||||
# Pick the GLB skin if its model file is present, else default.
|
||||
var glb_skin_id := ""
|
||||
if skin_mgr:
|
||||
for id in skin_mgr.get_skin_ids():
|
||||
var s = skin_mgr.get_skin(id)
|
||||
if s.model_path != "" and ResourceLoader.exists(s.model_path):
|
||||
glb_skin_id = id
|
||||
break
|
||||
|
||||
await _test_spawn_with_skin("default", false)
|
||||
if glb_skin_id != "":
|
||||
await _test_spawn_with_skin(glb_skin_id, true)
|
||||
else:
|
||||
print("NOTE: no GLB skin available, skipped skinned model test")
|
||||
|
||||
print("\n=== SPAWN SMOKE SUMMARY ===")
|
||||
print("Failures: %d" % _failures.size())
|
||||
for f in _failures:
|
||||
print("FAIL: ", f)
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
||||
print("\n--- Spawn test with skin '%s' ---" % skin_id)
|
||||
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
||||
if skin_mgr:
|
||||
skin_mgr.set_active_skin(skin_id)
|
||||
|
||||
var nm = root.get_node_or_null("/root/NetworkManager")
|
||||
if not _check(nm != null, "NetworkManager autoload exists"):
|
||||
return
|
||||
nm.start_singleplayer_match("Deathmatch")
|
||||
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||
|
||||
# Let the level build and the player spawn + settle.
|
||||
for i in 30:
|
||||
await process_frame
|
||||
|
||||
var level = current_scene
|
||||
if not _check(level != null, "level scene loaded"):
|
||||
return
|
||||
var player = _find_player(level)
|
||||
if not _check(player != null, "player '1' spawned"):
|
||||
return
|
||||
|
||||
_check(player.get_node_or_null("MovementStateMachine") != null, "state machine present")
|
||||
var visual = player.get_visual_model() if player.has_method("get_visual_model") else null
|
||||
_check(visual != null, "visual model present")
|
||||
|
||||
if expect_skinned:
|
||||
var skinned = player.get_node_or_null("SkinnedModel")
|
||||
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
||||
_check(skinned.loaded, "GLB model loaded")
|
||||
_check(skinned.skeleton != null, "skeleton found in GLB")
|
||||
# The mesh must be bound to the skeleton, else it renders its bind
|
||||
# pose (T-pose) while the skeleton animates invisibly.
|
||||
var bound := false
|
||||
if skinned.skeleton:
|
||||
for mi in skinned.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.skin != null and mi.get_node_or_null(mi.skeleton) == skinned.skeleton:
|
||||
bound = true
|
||||
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
|
||||
if skinned.animation_player:
|
||||
_check(skinned.animation_player.is_playing(), "animation playing")
|
||||
print(" clips resolved: ", skinned._resolved_clips)
|
||||
# Drive some movement states through the same API the game uses.
|
||||
for state in ["ground", "air", "slide", "wall_run", "dash"]:
|
||||
skinned.update_state(state, 9.0, false)
|
||||
await process_frame
|
||||
_check(true, "state cycling did not crash")
|
||||
|
||||
# Third-person toggle: camera swap + owner model visibility.
|
||||
if player.has_method("set_third_person"):
|
||||
var tp_cam = player.get_node_or_null("HeadPivot/ThirdPersonBoom/ThirdPersonCamera")
|
||||
_check(tp_cam != null, "third-person camera boom created")
|
||||
player.set_third_person(true)
|
||||
await process_frame
|
||||
_check(player.third_person, "toggled into third person")
|
||||
if tp_cam:
|
||||
_check(tp_cam.current, "third-person camera is current when toggled on")
|
||||
player.set_third_person(false)
|
||||
await process_frame
|
||||
_check(not player.third_person, "toggled back to first person")
|
||||
else:
|
||||
var humanoid = player.get_node_or_null("HumanoidModel")
|
||||
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
|
||||
|
||||
# Simulate a few physics frames of idle play.
|
||||
for i in 30:
|
||||
await physics_frame
|
||||
_check(is_instance_valid(player), "player survived 30 physics frames")
|
||||
|
||||
|
||||
func _find_player(node: Node) -> Node:
|
||||
if node is CharacterBody3D and node.name == "1" and node.has_method("get_visual_model"):
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _check(ok: bool, msg: String) -> bool:
|
||||
if ok:
|
||||
print(" OK: ", msg)
|
||||
else:
|
||||
print(" FAIL: ", msg)
|
||||
_failures.append(msg)
|
||||
return ok
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqc7midjff148
|
||||
@@ -82,10 +82,7 @@ func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String =
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
return body
|
||||
|
||||
@@ -99,29 +96,9 @@ func _ramp_static(pos: Vector3, size: Vector3, rot_deg: Vector3, color: Color, n
|
||||
# ── Environment ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _build_environment() -> void:
|
||||
var env := WorldEnvironment.new()
|
||||
env.name = "WorldEnvironment"
|
||||
var environment := Environment.new()
|
||||
environment.background_mode = Environment.BG_SKY
|
||||
var sky := Sky.new()
|
||||
var sky_mat := ProceduralSkyMaterial.new()
|
||||
sky_mat.sky_top_color = Color(0.15, 0.2, 0.35)
|
||||
sky_mat.sky_horizon_color = Color(0.45, 0.5, 0.65)
|
||||
sky_mat.ground_bottom_color = Color(0.1, 0.08, 0.06)
|
||||
sky_mat.ground_horizon_color = Color(0.35, 0.3, 0.25)
|
||||
sky.sky_material = sky_mat
|
||||
environment.sky = sky
|
||||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
environment.ambient_light_energy = 0.4
|
||||
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
environment.glow_enabled = true
|
||||
environment.glow_intensity = 0.3
|
||||
environment.glow_bloom = 0.1
|
||||
environment.fog_enabled = true
|
||||
environment.fog_light_color = Color(0.5, 0.55, 0.65)
|
||||
environment.fog_density = 0.002
|
||||
env.environment = environment
|
||||
add_child(env)
|
||||
# Shared stylized environment (anime sky, bloom, cel color grade).
|
||||
# Also creates the Sun/FillLight pair since none exists yet.
|
||||
LevelEnvironment.add_to(self)
|
||||
|
||||
|
||||
# ── Floor ─────────────────────────────────────────────────────────────────────
|
||||
@@ -264,24 +241,9 @@ func _build_target_dummy() -> void:
|
||||
# ── Lighting ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _build_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.name = "Sun"
|
||||
sun.rotation_degrees = Vector3(-50, 30, 0)
|
||||
sun.light_color = Color(1.0, 0.95, 0.85)
|
||||
sun.light_energy = 1.2
|
||||
sun.shadow_enabled = true
|
||||
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
|
||||
sun.directional_shadow_max_distance = 100.0
|
||||
add_child(sun)
|
||||
|
||||
# Fill light (opposite side)
|
||||
var fill := DirectionalLight3D.new()
|
||||
fill.name = "FillLight"
|
||||
fill.rotation_degrees = Vector3(-30, -150, 0)
|
||||
fill.light_color = Color(0.6, 0.7, 0.9)
|
||||
fill.light_energy = 0.3
|
||||
fill.shadow_enabled = false
|
||||
add_child(fill)
|
||||
# Sun and fill are created by LevelEnvironment.add_to in _build_environment;
|
||||
# kept as a hook for subclasses that re-style them (see dust2).
|
||||
pass
|
||||
|
||||
|
||||
# ── Player ────────────────────────────────────────────────────────────────────
|
||||
@@ -299,15 +261,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
@@ -316,8 +272,19 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://c7ltcn37gfd71
|
||||
uid://btkp2l168jj45
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Character Pipeline: Sketchfab → Rigged → Animated → In-Game
|
||||
|
||||
One command turns a Sketchfab model into a playable character skin:
|
||||
|
||||
```bash
|
||||
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
|
||||
```
|
||||
|
||||
That downloads the model, auto-rigs it in Blender, merges the shared
|
||||
animation library onto it, writes `assets/characters/skins/space_marine.glb`,
|
||||
and registers it in `skins.json`. Restart the game — the skin is in the main
|
||||
menu dropdown, fully animated in first AND third person, synced in multiplayer.
|
||||
|
||||
## Prerequisites (one-time setup)
|
||||
|
||||
1. **Sketchfab API token** — from <https://sketchfab.com/settings/password>.
|
||||
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
|
||||
`.sketchfab_token` in the project root (gitignored).
|
||||
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
|
||||
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
|
||||
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
|
||||
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
|
||||
this library's skeleton (fitting the arm bones to the model's actual pose),
|
||||
and `merge_animations.py` retargets each clip onto that fitted rest. To use a
|
||||
different/larger set instead, see "Swapping the animation library" below.
|
||||
|
||||
## The pipeline, step by step
|
||||
|
||||
Each step is also runnable on its own:
|
||||
|
||||
| Step | Tool | What it does |
|
||||
|---|---|---|
|
||||
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights |
|
||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names |
|
||||
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
||||
|
||||
`tools/pipeline.py` chains all of it. Useful flags:
|
||||
|
||||
- `--input file.glb` instead of `--uid` for local files (GLB/FBX/OBJ).
|
||||
- `--rigged` — skip auto-rig for models that already have a skeleton
|
||||
(Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention
|
||||
automatically when recognizable.
|
||||
- `--height 1.6` — target character height in meters.
|
||||
|
||||
## Auto-rigging: what to use when
|
||||
|
||||
Mixamo has **no public API** (and Adobe has said one isn't coming), so full
|
||||
automation needs an alternative. In order of preference:
|
||||
|
||||
1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit +
|
||||
Blender automatic weights, fully automated, zero cost. Works well for
|
||||
normal-proportioned upright humanoids. Weakest on flowing
|
||||
dresses/capes/extreme proportions.
|
||||
2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at
|
||||
<https://www.mixamo.com>, place 7 markers, download rigged FBX "without
|
||||
animations", then `pipeline.py --input rigged.fbx --rigged --name x`.
|
||||
Best weight quality for the effort.
|
||||
3. **Reallusion AccuRig** (free desktop app) — better auto-weights than
|
||||
Mixamo for tricky meshes; export FBX and feed with `--rigged`.
|
||||
4. **UniRig / Tripo AI / Meshy / Anything World** — ML auto-riggers. UniRig is
|
||||
open source (VAST/Tripo); Tripo, Meshy, and Anything World offer paid HTTP
|
||||
APIs if you ever want a fully hosted rigging step with no Blender at all.
|
||||
|
||||
All roads lead to the same place: a GLB with Mixamo bone names, which is what
|
||||
`merge_animations.py` and the game expect.
|
||||
|
||||
## Swapping the animation library
|
||||
|
||||
`merge_animations.py` supports two library layouts in
|
||||
`assets/characters/animations/`:
|
||||
|
||||
**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
|
||||
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
|
||||
`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal
|
||||
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land,
|
||||
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py`
|
||||
rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb`
|
||||
you must re-rig characters against the new one (the pipeline does this
|
||||
automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro
|
||||
`_library.glb` and add rows to `LIBRARY_CLIP_MAP`.
|
||||
|
||||
**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add
|
||||
FBX/GLB files named by clip (`idle.fbx` → `Idle`, `crouch_walk.fbx` →
|
||||
`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
|
||||
("In Place" variants). In this mode characters must be on the Mixamo skeleton
|
||||
(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
|
||||
`build_mixamo_armature` fit one).
|
||||
|
||||
Missing clips are fine either way: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in
|
||||
`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still
|
||||
animates in every movement state.
|
||||
|
||||
## How it works in-game
|
||||
|
||||
- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at
|
||||
boot. Selected skin persists per-user and is synced to other players via
|
||||
`synced_skin_id`.
|
||||
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
|
||||
names, sets loop modes, blends between clips (0.15 s), and scales
|
||||
locomotion playback speed to actual movement speed.
|
||||
- **First person (owner):** the model renders shadows-only for the owner (the
|
||||
camera sits inside the head, so drawing the mesh would show its inside). It's
|
||||
still fully animated and casts a real shadow; other players see the full body.
|
||||
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
|
||||
over-the-shoulder `SpringArm3D` camera and reveal your own animated model —
|
||||
the easiest way to eyeball that a new skin's animations look right. Firing
|
||||
still uses the first-person camera, so aim is unchanged. Press V again to
|
||||
return to first person.
|
||||
- **Other players** always see the full third-person model, driven by the
|
||||
synced movement state, with the current weapon attached to the hand bone.
|
||||
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
|
||||
CC-BY models require crediting the author — keep these files and surface
|
||||
them in your credits screen before shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- *Model T-poses in game* — the GLB has no animations; re-run
|
||||
`merge_animations.py` and check it printed `Merged N clips`.
|
||||
- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via
|
||||
Mixamo web or AccuRig and re-run with `--rigged`.
|
||||
- *Character slides while walking* — clips exported with root motion; re-run
|
||||
without `--keep-root-motion` (stripping is the default).
|
||||
- *Skin missing in exported build* — raw `.glb` files must be included in the
|
||||
export: Project → Export → Resources → include filter `*.glb, *.json`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Asset Sources — Replacing Procedural Content
|
||||
|
||||
Goal: move away from procedurally generated animations, maps, and sounds.
|
||||
Everything below is free for commercial use unless marked otherwise.
|
||||
**CC0 = no attribution needed. CC-BY = must credit the author.**
|
||||
|
||||
## Animations
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Mixamo](https://www.mixamo.com) | Free (Adobe account) | 2500+ humanoid clips, the de-facto standard skeleton this pipeline targets. No API — download clips manually once. |
|
||||
| [Quaternius Universal Animation Library](https://quaternius.com) | CC0 | 500+ clips on a Mixamo-compatible rig. Drop straight into `assets/characters/animations/`. |
|
||||
| [Kenney Character Assets](https://kenney.nl/assets) | CC0 | Simple rigged characters + basic clips. |
|
||||
| Sketchfab (filter: animated + downloadable) | per-model | Many characters ship with their own baked animations; `pipeline.py --rigged` keeps them. |
|
||||
|
||||
## Character models
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Sketchfab downloads](https://sketchfab.com/search?features=downloadable&type=models) | per-model (shown by our search tool) | Primary source — use `tools/sketchfab_import.py search`. |
|
||||
| [Quaternius](https://quaternius.com) | CC0 | Stylized low-poly packs, many pre-rigged. |
|
||||
| [KayKit](https://kaylousberg.itch.io) | CC0 | Character + dungeon packs, rigged, game-ready. |
|
||||
| [PolyPizza](https://poly.pizza) | mostly CC0/CC-BY | Searchable low-poly aggregator. |
|
||||
|
||||
## Maps / environments (replace the procedural arena)
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Kenney kits](https://kenney.nl/assets) (City, Prototype, Platformer) | CC0 | Modular kits — greybox with Prototype textures, dress with City kits. |
|
||||
| [KayKit Dungeon/City packs](https://kaylousberg.itch.io) | CC0 | Modular, snaps to grid, ideal for arena shooters. |
|
||||
| [Sketchfab scenes](https://sketchfab.com) | per-model | Whole environments exist; check poly counts before importing. |
|
||||
| [ambientCG](https://ambientcg.com) | CC0 | PBR materials/textures for level surfaces. |
|
||||
|
||||
Workflow: block out in Godot with CSG/GridMap for movement flow first, then
|
||||
replace geometry with kit pieces. Movement (wall-run surfaces, slide ramps)
|
||||
should drive layout, not the other way around.
|
||||
|
||||
## Sounds
|
||||
|
||||
See [SOUND_DESIGN.md](SOUND_DESIGN.md) for the full plan. Short list:
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Sonniss GDC Bundles](https://sonniss.com/gameaudiogdc) | Royalty-free | 100+ GB of pro game audio, free, commercial OK. THE first stop. |
|
||||
| [Kenney Audio packs](https://kenney.nl/assets?q=audio) | CC0 | UI, impacts, footsteps, sci-fi weapons. |
|
||||
| [Freesound](https://freesound.org) (filter CC0) | CC0/CC-BY | Searchable; check license per file. |
|
||||
| [BOOM Library free packs](https://www.boomlibrary.com/free-sound-effects/) | Royalty-free | Cinematic-quality freebies. |
|
||||
| [99Sounds](https://99sounds.org) | Royalty-free | Weapon/impact packs. |
|
||||
|
||||
## Attribution bookkeeping
|
||||
|
||||
- Sketchfab: `tools/sketchfab_import.py` writes `<model>.license.json` next to
|
||||
every download; the pipeline copies it beside the final skin GLB.
|
||||
- Anything CC-BY (models or sounds) must appear in a credits screen.
|
||||
Grep for license files before shipping: `ls assets/**/*.license.json`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sound Design Plan
|
||||
|
||||
The current sounds are procedurally generated sine/noise blends
|
||||
(`generate_sounds.py`) — functional, but robotic. This doc is the path to
|
||||
real sound design with zero budget.
|
||||
|
||||
## What's in place now
|
||||
|
||||
`AudioManager` (autoload, `globals/audio_manager.gd`) provides the plumbing
|
||||
good sound design needs:
|
||||
|
||||
- **Bus layout**: `Master ← SFX ← {Weapons, Footsteps, UI}`, `Master ← Music`
|
||||
— created automatically at boot, so volume sliders and effects (EQ,
|
||||
compression, reverb zones) can target categories.
|
||||
- **Variation playback**: drop `footstep_01.wav`, `footstep_02.wav`,
|
||||
`footstep_03.wav` into `assets/sounds/` and `AudioManager.play_3d("footstep", pos)`
|
||||
picks one at random.
|
||||
- **Pitch randomization**: every playback is pitched ±6% by default — the
|
||||
single cheapest trick to stop repeated sounds sounding fake.
|
||||
- **Pooled 3D players**: `play_3d()` is safe to call every frame; no node
|
||||
churn, no cut-off management.
|
||||
|
||||
```gdscript
|
||||
# anywhere in gameplay code:
|
||||
AudioManager.play_3d("ak47_fire", muzzle.global_position)
|
||||
AudioManager.play_ui("hit_confirm")
|
||||
```
|
||||
|
||||
## Replacing the procedural sounds (priority order)
|
||||
|
||||
Sound is ~50% of "game feel" in an FPS. Replace in this order:
|
||||
|
||||
1. **Weapon fire** (heard constantly): layered = body (low thump) + crack
|
||||
(mid transient) + tail (room reflection). Sonniss GDC packs have complete
|
||||
layered gunshots. One file per weapon is fine to start; add `_01.._03`
|
||||
variations later.
|
||||
2. **Hit confirm + kill confirm**: short, bright, satisfying (this is the
|
||||
"touchdown" pillar in the design doc).
|
||||
3. **Footsteps**: 4+ variations minimum, quieter than you think, on the
|
||||
`Footsteps` bus so they can be ducked while firing.
|
||||
4. **Movement verbs**: slide (cloth+concrete scrape), dash (air whoosh),
|
||||
wall-run loop, jump/land (land intensity scaled by fall speed).
|
||||
5. **Ambience**: a quiet room tone per map kills the "dead air" feeling.
|
||||
6. **UI**: menu hover/click/equip from a Kenney UI pack.
|
||||
|
||||
Where to get files: see [ASSET_SOURCES.md](ASSET_SOURCES.md#sounds) —
|
||||
Sonniss GDC bundles first, Kenney CC0 packs second, Freesound (CC0 filter)
|
||||
for gaps.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Files: `assets/sounds/<event>[_NN].wav` — 44.1 kHz, 16-bit, mono for 3D
|
||||
positional sounds, stereo only for UI/music.
|
||||
- Ids are auto-registered from filenames at boot (`ak47_fire.wav` →
|
||||
`"ak47_fire"`). Names containing `fire`/`reload` route to the Weapons bus,
|
||||
`footstep` to Footsteps, everything else to SFX
|
||||
(see `BUS_HINTS` in audio_manager.gd).
|
||||
- Keep source loudness consistent: normalize weapon shots to about -6 dBFS
|
||||
peak, footsteps/foley around -18 dBFS.
|
||||
|
||||
## Later polish (cheap wins, in order)
|
||||
|
||||
1. Distance low-pass on the Weapons bus (far gunfire sounds muffled).
|
||||
2. A short duck (sidechain) on Footsteps/ambience when local weapon fires.
|
||||
3. Reverb `AudioEffectReverb` per-map on SFX for indoor spaces.
|
||||
4. Surface-dependent footsteps: raycast down, map material → sound id
|
||||
(`footstep_metal`, `footstep_concrete`).
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 155 KiB |
@@ -33,7 +33,7 @@ func _ready() -> void:
|
||||
dps_label.modulate = Color(1, 0.8, 0)
|
||||
add_child(dps_label)
|
||||
|
||||
func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
func take_damage(amount: float, hit_position: Vector3, source: Node = null, _impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
var now = Time.get_ticks_msec() / 1000.0
|
||||
damage_history.append({"time": now, "amount": amount})
|
||||
|
||||
@@ -73,5 +73,3 @@ func _wiggle() -> void:
|
||||
tween.tween_property(visual_node, "position", orig_pos + wiggle_dir, 0.05)
|
||||
tween.tween_property(visual_node, "position", orig_pos - wiggle_dir, 0.05)
|
||||
tween.tween_property(visual_node, "position", orig_pos, 0.05)
|
||||
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ func _physics_process(delta: float) -> void:
|
||||
var current_h_speed = Vector2(velocity.x, velocity.z).length()
|
||||
visual_node.update_state("ground", current_h_speed)
|
||||
|
||||
func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
func take_damage(amount: float, hit_position: Vector3, source: Node = null, _impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_dead:
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
extends Node
|
||||
|
||||
## Autoload: central sound playback with variation + pitch randomization.
|
||||
##
|
||||
## Why: identical samples played back-to-back sound robotic. Real games ship
|
||||
## 3-5 variations per event and randomize pitch a few percent per shot. This
|
||||
## manager makes that the default for every sound in the game.
|
||||
##
|
||||
## Usage:
|
||||
## AudioManager.play_3d("ak47_fire", muzzle_global_pos)
|
||||
## AudioManager.play_ui("hit_confirm")
|
||||
## AudioManager.register_sound("footstep_metal", [
|
||||
## "res://assets/sounds/footstep_metal_01.wav",
|
||||
## "res://assets/sounds/footstep_metal_02.wav",
|
||||
## ], "Footsteps")
|
||||
##
|
||||
## Sounds are auto-registered from assets/sounds/*.wav on startup (the file
|
||||
## stem is the sound id). Drop variations next to the original with _01/_02/…
|
||||
## suffixes and they are picked up as one id with random selection:
|
||||
## footstep.wav, footstep_01.wav, footstep_02.wav -> id "footstep" (3 vars)
|
||||
##
|
||||
## Buses: Master <- SFX <- {Weapons, Footsteps, UI}, Master <- Music.
|
||||
## See docs/SOUND_DESIGN.md for where to get good source audio.
|
||||
|
||||
const SOUNDS_DIR := "res://assets/sounds"
|
||||
const POOL_SIZE_3D := 32
|
||||
const POOL_SIZE_2D := 8
|
||||
|
||||
# id -> { "streams": Array[AudioStream], "bus": String, "pitch_var": float, "volume_db": float }
|
||||
var _library: Dictionary = {}
|
||||
var _pool_3d: Array[AudioStreamPlayer3D] = []
|
||||
var _pool_2d: Array[AudioStreamPlayer] = []
|
||||
var _next_3d: int = 0
|
||||
var _next_2d: int = 0
|
||||
|
||||
# Default routing/tuning per id prefix (applied during auto-registration).
|
||||
const BUS_HINTS := {
|
||||
"footstep": "Footsteps",
|
||||
"fire": "Weapons",
|
||||
"reload": "Weapons",
|
||||
}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_build_pools()
|
||||
_auto_register_sounds()
|
||||
print("AudioManager: %d sounds registered" % _library.size())
|
||||
|
||||
|
||||
# ── Bus layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _setup_buses() -> void:
|
||||
_ensure_bus("SFX", "Master")
|
||||
_ensure_bus("Weapons", "SFX")
|
||||
_ensure_bus("Footsteps", "SFX")
|
||||
_ensure_bus("UI", "SFX")
|
||||
_ensure_bus("Music", "Master")
|
||||
|
||||
|
||||
func _ensure_bus(bus_name: String, send_to: String) -> void:
|
||||
if AudioServer.get_bus_index(bus_name) != -1:
|
||||
return
|
||||
var idx := AudioServer.bus_count
|
||||
AudioServer.add_bus(idx)
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
AudioServer.set_bus_send(idx, send_to)
|
||||
|
||||
|
||||
func set_bus_volume_linear(bus_name: String, linear: float) -> void:
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx != -1:
|
||||
AudioServer.set_bus_volume_db(idx, linear_to_db(clampf(linear, 0.0001, 1.0)))
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
func register_sound(id: String, paths: Array, bus: String = "SFX",
|
||||
pitch_var: float = 0.06, volume_db: float = 0.0) -> void:
|
||||
var streams: Array = []
|
||||
for p in paths:
|
||||
if ResourceLoader.exists(p):
|
||||
var s = load(p)
|
||||
if s:
|
||||
streams.append(s)
|
||||
if streams.is_empty():
|
||||
return
|
||||
_library[id] = {
|
||||
"streams": streams,
|
||||
"bus": bus,
|
||||
"pitch_var": pitch_var,
|
||||
"volume_db": volume_db,
|
||||
}
|
||||
|
||||
|
||||
func _auto_register_sounds() -> void:
|
||||
var dir := DirAccess.open(SOUNDS_DIR)
|
||||
if not dir:
|
||||
return
|
||||
# Group files: "footstep_01.wav" and "footstep.wav" both map to "footstep".
|
||||
var groups: Dictionary = {}
|
||||
for fname in dir.get_files():
|
||||
if not (fname.ends_with(".wav") or fname.ends_with(".ogg") or fname.ends_with(".mp3")):
|
||||
continue
|
||||
var stem := fname.get_basename()
|
||||
var base := stem
|
||||
var parts := stem.rsplit("_", true, 1)
|
||||
if parts.size() == 2 and parts[1].is_valid_int():
|
||||
base = parts[0]
|
||||
if not groups.has(base):
|
||||
groups[base] = []
|
||||
groups[base].append(SOUNDS_DIR + "/" + fname)
|
||||
|
||||
for id in groups:
|
||||
var bus := "SFX"
|
||||
for hint in BUS_HINTS:
|
||||
if id.findn(hint) != -1:
|
||||
bus = BUS_HINTS[hint]
|
||||
break
|
||||
register_sound(id, groups[id], bus)
|
||||
|
||||
|
||||
func has_sound(id: String) -> bool:
|
||||
return _library.has(id)
|
||||
|
||||
|
||||
# ── Playback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _build_pools() -> void:
|
||||
for i in POOL_SIZE_3D:
|
||||
var p := AudioStreamPlayer3D.new()
|
||||
p.name = "Pool3D_%d" % i
|
||||
p.max_distance = 80.0
|
||||
p.unit_size = 8.0
|
||||
add_child(p)
|
||||
_pool_3d.append(p)
|
||||
for i in POOL_SIZE_2D:
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.name = "Pool2D_%d" % i
|
||||
add_child(p)
|
||||
_pool_2d.append(p)
|
||||
|
||||
|
||||
## Play a positional sound. Safe to call every frame — players are pooled.
|
||||
func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
|
||||
pitch_scale: float = 1.0) -> AudioStreamPlayer3D:
|
||||
var entry = _library.get(id)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_3d[_next_3d]
|
||||
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.bus = entry["bus"]
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.global_position = global_pos
|
||||
player.play()
|
||||
return player
|
||||
|
||||
|
||||
## Play a non-positional sound (UI clicks, local confirms).
|
||||
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
|
||||
var entry = _library.get(id)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_2d[_next_2d]
|
||||
_next_2d = (_next_2d + 1) % POOL_SIZE_2D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.bus = "UI"
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.play()
|
||||
return player
|
||||
@@ -0,0 +1 @@
|
||||
uid://c87jctwfqhyui
|
||||
@@ -3,10 +3,10 @@ class_name MovementParams
|
||||
|
||||
# ── Ground ────────────────────────────────────────────────────────────────────
|
||||
@export var walk_speed: float = 11.0
|
||||
@export var crouch_speed: float = 4.0
|
||||
@export var crouch_speed: float = 4.5
|
||||
@export var ground_friction: float = 10.0
|
||||
@export var ground_acceleration: float = 50.0
|
||||
@export var ground_deceleration: float = 40.0
|
||||
@export var ground_acceleration: float = 60.0
|
||||
@export var ground_deceleration: float = 45.0
|
||||
|
||||
# ── Jump / Air ────────────────────────────────────────────────────────────────
|
||||
@export var jump_velocity: float = 8.5
|
||||
@@ -14,70 +14,88 @@ class_name MovementParams
|
||||
@export var jump_buffer: float = 0.12
|
||||
@export var air_control: float = 0.35
|
||||
@export var air_acceleration: float = 30.0
|
||||
@export var max_air_speed: float = 16.0
|
||||
@export var max_air_speed: float = 18.0
|
||||
@export var gravity: float = 22.0
|
||||
@export var fall_multiplier: float = 2.2
|
||||
@export var fall_multiplier: float = 2.0
|
||||
@export var low_jump_multiplier: float = 1.6
|
||||
|
||||
# ── Air Strafe (Quake-style) ──────────────────────────────────────────────────
|
||||
@export var air_strafe_accel: float = 80.0
|
||||
@export var air_strafe_max_gain: float = 1.0
|
||||
# Classic accelerate: only the velocity component along wish_dir is capped, so
|
||||
# turning while strafing genuinely gains speed (up to max_air_speed overall).
|
||||
@export var air_strafe_accel: float = 90.0
|
||||
@export var air_wish_speed_cap: float = 2.5 # per-tick projection cap (m/s), quake-style
|
||||
# Direct steering: bends existing horizontal velocity toward the input without
|
||||
# changing its magnitude, so air direction changes feel light and responsive.
|
||||
@export var air_steer_rate: float = 5.0
|
||||
|
||||
# ── Bunny Hop ─────────────────────────────────────────────────────────────────
|
||||
@export var bunny_hop_impulse: float = 1.15
|
||||
@export var bunny_hop_speed_gain: float = 0.5
|
||||
@export var bunny_hop_speed_cap: float = 16.0
|
||||
@export var bunny_hop_impulse: float = 1.1
|
||||
@export var bunny_hop_speed_gain: float = 0.7
|
||||
@export var bunny_hop_speed_cap: float = 20.0
|
||||
|
||||
# ── Slide ─────────────────────────────────────────────────────────────────────
|
||||
@export var slide_speed: float = 14.0
|
||||
@export var slide_friction: float = 0.96
|
||||
@export var slide_min_speed: float = 5.0
|
||||
@export var slide_duration: float = 0.8
|
||||
@export var slide_cooldown: float = 0.4 # Prevents rapid slide re-entry
|
||||
@export var slide_jump_speed_boost: float = 1.5 # Extra m/s added when jumping out of slide
|
||||
@export var slide_boost: float = 3.0 # flat entry boost when grounded and fast
|
||||
@export var slide_friction_flat: float = 2.2 # m/s^2 decel on flat ground (low = long slides)
|
||||
@export var slide_slope_accel: float = 16.0 # downhill acceleration from gravity projection
|
||||
@export var slide_min_speed: float = 4.0
|
||||
@export var slide_cooldown: float = 0.35 # prevents rapid slide re-entry
|
||||
@export var slide_steer_rate: float = 2.5 # how fast slide direction bends toward input
|
||||
@export var slide_jump_speed_boost: float = 1.5 # extra m/s added when jumping out of slide
|
||||
|
||||
# ── Wall Run ──────────────────────────────────────────────────────────────────
|
||||
@export var wall_run_speed: float = 12.0
|
||||
@export var wall_run_vertical_speed: float = 4.0
|
||||
@export var wall_run_duration: float = 1.2
|
||||
@export var wall_run_gravity: float = 6.0
|
||||
@export var wall_run_speed: float = 13.0
|
||||
@export var wall_run_accel: float = 18.0 # accelerate toward run speed (no hard set)
|
||||
@export var wall_run_duration: float = 1.8
|
||||
@export var wall_run_gravity: float = 5.0
|
||||
@export var wall_run_entry_max_up: float = 3.5 # keep this much upward momentum on attach
|
||||
@export var wall_run_auto_jump_speed: float = 10.0
|
||||
@export var wall_run_jump_horizontal: float = 8.0
|
||||
@export var wall_run_jump_off_normal: float = 5.0
|
||||
@export var wall_run_jump_off_normal: float = 6.0
|
||||
@export var wall_angle_threshold: float = 70.0
|
||||
@export var wall_detect_distance: float = 0.7
|
||||
@export var wall_detect_distance: float = 0.8
|
||||
@export var wall_ray_up_height: float = 0.6
|
||||
@export var wall_ray_down_height: float = 0.0
|
||||
|
||||
# ── Wall Climb ────────────────────────────────────────────────────────────────
|
||||
@export var wall_climb_speed: float = 6.0
|
||||
@export var wall_climb_duration: float = 3.0
|
||||
@export var wall_climb_speed: float = 6.5
|
||||
@export var wall_climb_duration: float = 2.2
|
||||
@export var wall_climb_max_angle: float = 75.0
|
||||
@export var wall_climb_vault_forward: float = 16.0
|
||||
@export var wall_climb_vault_up: float = 14.0
|
||||
@export var wall_climb_vault_forward: float = 14.0
|
||||
@export var wall_climb_vault_up: float = 12.0
|
||||
@export var wall_climb_vault_height_check: float = 1.0
|
||||
|
||||
# ── Jumping ───────────────────────────────────────────────────────────────────
|
||||
@export var jump_cooldown: float = 0.27
|
||||
@export var jump_cooldown: float = 0.12
|
||||
|
||||
# ── Wall Cling ────────────────────────────────────────────────────────────────
|
||||
@export var wall_cling_slide_speed: float = -1.5
|
||||
@export var wall_cling_stamina_drain: float = 2.0
|
||||
@export var wall_cling_max_stamina: float = 2.0
|
||||
@export var wall_cling_stamina_drain: float = 1.0
|
||||
@export var wall_cling_max_stamina: float = 2.5
|
||||
|
||||
# ── Grapple ───────────────────────────────────────────────────────────────────
|
||||
@export var grapple_range: float = 30.0
|
||||
@export var grapple_pull_force: float = 25.0
|
||||
@export var grapple_range: float = 35.0
|
||||
@export var grapple_pull_force: float = 18.0 # constant reel toward the hook
|
||||
@export var grapple_reel_force: float = 22.0 # extra reel while holding forward
|
||||
@export var grapple_jump_boost: float = 12.0
|
||||
@export var grapple_spring_strength: float = 10.0
|
||||
@export var grapple_air_control: float = 20.0
|
||||
@export var grapple_spring_strength: float = 14.0
|
||||
@export var grapple_air_control: float = 24.0
|
||||
@export var grapple_shoot_speed: float = 70.0 # hook travel speed (m/s)
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────────────
|
||||
@export var dash_speed: float = 10.0
|
||||
@export var dash_duration: float = 0.25
|
||||
@export var dash_cooldown: float = 10.0
|
||||
@export var dash_speed: float = 13.0
|
||||
@export var dash_duration: float = 0.18
|
||||
@export var dash_charges: int = 2 # dashes available before recharging
|
||||
@export var dash_cooldown: float = 2.0 # seconds to regain ONE charge
|
||||
@export var dash_invulnerability_time: float = 0.1
|
||||
|
||||
# ── Landing feel ──────────────────────────────────────────────────────────────
|
||||
@export var land_soft_speed: float = 8.0 # fall speed where landing feedback starts
|
||||
@export var land_heavy_speed: float = 18.0 # fall speed considered a heavy landing
|
||||
|
||||
# ── Crouch ────────────────────────────────────────────────────────────────────
|
||||
@export var crouch_transition_speed: float = 12.0 # capsule/camera height lerp rate
|
||||
|
||||
# ── Rocket Jump ───────────────────────────────────────────────────────────────
|
||||
@export var rocket_jump_self_damage: float = 15.0
|
||||
@export var rocket_jump_up_impulse: float = 20.0
|
||||
@@ -106,3 +124,6 @@ class_name MovementParams
|
||||
@export var head_bob_amplitude: float = 0.04
|
||||
@export var wall_run_tilt_angle: float = 12.0
|
||||
@export var wall_run_tilt_speed: float = 8.0
|
||||
@export var slide_tilt_angle: float = 4.0
|
||||
@export var land_dip_scale: float = 0.012 # camera dip per m/s of landing speed
|
||||
@export var land_dip_max: float = 0.25 # max camera dip on heavy landings
|
||||
|
||||
@@ -3,6 +3,15 @@ class_name MovementStateMachine
|
||||
|
||||
## Generic state machine for player movement.
|
||||
## Each state is a Node child; the machine switches between them.
|
||||
##
|
||||
## The machine owns cross-state concerns so individual states stay small:
|
||||
## - input snapshot (written by the controller each tick)
|
||||
## - jump buffering / coyote time / jump cooldown
|
||||
## - crouch capsule resizing (smoothly lerped, single owner)
|
||||
## - dash cooldown (per-instance, not shared between players)
|
||||
## - grapple hook raycast + travel
|
||||
## - wall detection helpers
|
||||
## - chain-bonus bookkeeping
|
||||
|
||||
signal state_changed(from_state: String, to_state: String)
|
||||
signal movement_event(event_name: String, data: Dictionary)
|
||||
@@ -19,6 +28,7 @@ var wish_dir_world: Vector3 = Vector3.ZERO
|
||||
var input_jump_pressed: bool = false
|
||||
var input_jump_just_pressed: bool = false
|
||||
var input_crouch: bool = false
|
||||
var input_sprint: bool = false
|
||||
var input_dash: bool = false
|
||||
var input_grapple: bool = false
|
||||
var input_grapple_just_pressed: bool = false
|
||||
@@ -38,6 +48,8 @@ var last_ground_time: float = 0.0
|
||||
var jump_buffer_time: float = 0.0
|
||||
var coyote_timer: float = 0.0
|
||||
var jump_cooldown_timer: float = 0.0
|
||||
var dash_charges: int = -1 # -1 = initialize from params on first tick
|
||||
var dash_recharge_timer: float = 0.0
|
||||
var current_jump_count: int = 0
|
||||
var chain_timer: float = 0.0
|
||||
var chain_count: int = 0
|
||||
@@ -48,6 +60,9 @@ var wall_cooldown_timer: float = 0.0 # Prevents instant re-attachment after wa
|
||||
var is_crouched: bool = false
|
||||
var can_wall_climb: bool = true
|
||||
|
||||
# Smooth capsule crouch: the machine is the single owner of capsule height.
|
||||
var _capsule_current_height: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Ensure grapple state is injected
|
||||
@@ -71,7 +86,9 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if player and not player.multiplayer.is_server(): return
|
||||
# Client-authoritative movement: only the peer that OWNS this player
|
||||
# simulates it. Everyone else interpolates the synced transform.
|
||||
if player and not player.is_multiplayer_authority(): return
|
||||
|
||||
# Update coyote time
|
||||
if on_ground:
|
||||
@@ -85,9 +102,19 @@ func _physics_process(delta: float) -> void:
|
||||
else:
|
||||
jump_buffer_time = maxf(jump_buffer_time - delta, 0.0)
|
||||
|
||||
# Update jump cooldown
|
||||
if jump_cooldown_timer > 0.0:
|
||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||
# Update timers
|
||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Dash charges: regain one per dash_cooldown while below max
|
||||
if dash_charges < 0:
|
||||
dash_charges = params.dash_charges
|
||||
if dash_charges < params.dash_charges:
|
||||
dash_recharge_timer -= delta
|
||||
if dash_recharge_timer <= 0.0:
|
||||
dash_charges += 1
|
||||
dash_recharge_timer = params.dash_cooldown if dash_charges < params.dash_charges else 0.0
|
||||
|
||||
# Update chain timer
|
||||
if chain_timer > 0.0 and on_ground:
|
||||
@@ -110,21 +137,9 @@ func _physics_process(delta: float) -> void:
|
||||
movement_event.emit("grapple_latch", {})
|
||||
switch_to("grapple")
|
||||
|
||||
# Manage global crouch state
|
||||
var want_crouch = input_crouch
|
||||
if current_state == "slide":
|
||||
want_crouch = true
|
||||
if want_crouch != is_crouched:
|
||||
is_crouched = want_crouch
|
||||
_apply_crouch(is_crouched)
|
||||
|
||||
# Update slide cooldown
|
||||
if slide_cooldown_timer > 0.0:
|
||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||
|
||||
# Update wall cooldown
|
||||
if wall_cooldown_timer > 0.0:
|
||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||
# Manage global crouch state (slide keeps the capsule low)
|
||||
is_crouched = input_crouch or current_state == "slide"
|
||||
_update_crouch_capsule(delta)
|
||||
|
||||
if current_state.is_empty():
|
||||
return
|
||||
@@ -155,6 +170,33 @@ func switch_to(new_state_name: String, data: Dictionary = {}) -> void:
|
||||
state_changed.emit(prev, new_state_name)
|
||||
|
||||
|
||||
## Called by states when the player touches down. Emits the landing event so
|
||||
## camera/audio/animation can react proportionally to impact speed.
|
||||
func notify_landed(fall_speed: float) -> void:
|
||||
on_ground = true
|
||||
current_jump_count = 0
|
||||
can_wall_climb = true
|
||||
if fall_speed > params.land_soft_speed:
|
||||
movement_event.emit("land", {
|
||||
"fall_speed": fall_speed,
|
||||
"heavy": fall_speed >= params.land_heavy_speed,
|
||||
})
|
||||
|
||||
|
||||
## Shared jump executed from ground-like states. Keeps horizontal momentum.
|
||||
func do_jump(extra_boost: float = 0.0) -> void:
|
||||
player.velocity.y = params.jump_velocity + extra_boost
|
||||
current_jump_count = 1
|
||||
on_ground = false
|
||||
coyote_timer = 0.0
|
||||
jump_buffer_time = 0.0
|
||||
jump_cooldown_timer = params.jump_cooldown
|
||||
register_chain_mechanic("jump")
|
||||
movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
|
||||
|
||||
func _try_start_grapple() -> void:
|
||||
if not player or not player.camera:
|
||||
return
|
||||
@@ -168,9 +210,7 @@ func _try_start_grapple() -> void:
|
||||
if not hit.is_empty():
|
||||
grapple_point = hit.position
|
||||
grapple_length = origin.distance_to(grapple_point)
|
||||
|
||||
# Travel at 45 m/s
|
||||
grapple_travel_time = grapple_length / 45
|
||||
grapple_travel_time = grapple_length / params.grapple_shoot_speed
|
||||
grapple_shoot_time = 0.0
|
||||
is_grapple_shooting = true
|
||||
movement_event.emit("grapple_shoot", {})
|
||||
@@ -226,7 +266,7 @@ func detect_wall_horizontal() -> Vector3:
|
||||
continue
|
||||
var n: Vector3 = hit.get("normal", Vector3.ZERO)
|
||||
# Wall must be roughly vertical (normal mostly horizontal)
|
||||
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
|
||||
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
|
||||
wall_normal = n.normalized()
|
||||
wall_side = side_data["side"]
|
||||
return wall_normal
|
||||
@@ -246,7 +286,7 @@ func detect_wall_horizontal() -> Vector3:
|
||||
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
|
||||
continue
|
||||
var n: Vector3 = hit.get("normal", Vector3.ZERO)
|
||||
if absf(n.y) < 0.3 and n.length_squared() > 0.0:
|
||||
if abs(n.y) < 0.3 and n.length_squared() > 0.0:
|
||||
wall_normal = n.normalized()
|
||||
wall_side = side_data["side"]
|
||||
return wall_normal
|
||||
@@ -294,7 +334,7 @@ func detect_wall_forward() -> Dictionary:
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
|
||||
|
||||
var normal: Vector3 = best_hit.get("normal", Vector3.ZERO)
|
||||
if absf(normal.y) >= 0.3:
|
||||
if abs(normal.y) >= 0.3:
|
||||
return {"hit": false, "normal": Vector3.ZERO, "is_short": false} # Not a vertical wall
|
||||
|
||||
# Check if wall is short (ledge check) using a top ray
|
||||
@@ -306,8 +346,30 @@ func detect_wall_forward() -> Dictionary:
|
||||
return {"hit": true, "normal": normal.normalized(), "is_short": hit_high.is_empty()}
|
||||
|
||||
|
||||
func _apply_crouch(crouched: bool) -> void:
|
||||
var shape_node = null
|
||||
## Perform an instant vault over a short wall: forward+up impulse, camera kick
|
||||
## via the rig, cooldown so we don't immediately re-detect the same wall.
|
||||
func do_vault() -> void:
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
movement_event.emit("vault", {})
|
||||
var rig = player.get_node_or_null("HeadPivot")
|
||||
if rig and rig.has_method("add_pitch_impulse"):
|
||||
rig.add_pitch_impulse(6.0)
|
||||
wall_cooldown_timer = 0.3
|
||||
register_chain_mechanic("vault")
|
||||
|
||||
|
||||
## Smoothly lerp the collision capsule toward the crouch/stand height and keep
|
||||
## the capsule's bottom anchored so shrinking doesn't lift the player off the
|
||||
## floor. Standing back up is blocked while there's no headroom.
|
||||
func _update_crouch_capsule(delta: float) -> void:
|
||||
var shape_node: CollisionShape3D = null
|
||||
for child in player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
shape_node = child
|
||||
@@ -315,19 +377,59 @@ func _apply_crouch(crouched: bool) -> void:
|
||||
if not shape_node:
|
||||
return
|
||||
var shape = shape_node.shape as CapsuleShape3D
|
||||
var head = player.get_node_or_null("HeadPivot")
|
||||
if _capsule_current_height <= 0.0:
|
||||
_capsule_current_height = shape.height
|
||||
|
||||
if crouched:
|
||||
shape.height = original_capsule_height * 0.5
|
||||
shape_node.position.y = -original_capsule_height * 0.25
|
||||
if head:
|
||||
head.position.y = 0.7 - (original_capsule_height * 0.5)
|
||||
else:
|
||||
shape.height = original_capsule_height
|
||||
shape_node.position.y = 0.0
|
||||
if head:
|
||||
head.position.y = 0.7
|
||||
var target_height := original_capsule_height * (0.5 if is_crouched else 1.0)
|
||||
|
||||
# Don't stand up into a ceiling
|
||||
if not is_crouched and target_height > _capsule_current_height + 0.01:
|
||||
var space_state := player.get_world_3d().direct_space_state
|
||||
var from := player.global_position + Vector3.UP * (_capsule_current_height * 0.5)
|
||||
var to := player.global_position + Vector3.UP * (original_capsule_height * 0.55)
|
||||
var ray := PhysicsRayQueryParameters3D.create(from, to)
|
||||
ray.exclude = [player.get_rid()]
|
||||
if not space_state.intersect_ray(ray).is_empty():
|
||||
target_height = _capsule_current_height # hold until clear
|
||||
|
||||
var t := 1.0 - exp(-params.crouch_transition_speed * delta)
|
||||
_capsule_current_height = lerpf(_capsule_current_height, target_height, t)
|
||||
if absf(_capsule_current_height - target_height) < 0.005:
|
||||
_capsule_current_height = target_height
|
||||
|
||||
shape.height = _capsule_current_height
|
||||
# Keep feet planted: offset the shape down by half the height loss.
|
||||
shape_node.position.y = -(original_capsule_height - _capsule_current_height) * 0.5
|
||||
|
||||
|
||||
## 0 (standing) → 1 (fully crouched); used by the camera rig for eye height.
|
||||
func get_crouch_factor() -> float:
|
||||
if original_capsule_height <= 0.0 or _capsule_current_height <= 0.0:
|
||||
return 0.0
|
||||
return clampf((original_capsule_height - _capsule_current_height)
|
||||
/ (original_capsule_height * 0.5), 0.0, 1.0)
|
||||
|
||||
|
||||
func can_dash() -> bool:
|
||||
return dash_charges != 0 # -1 (uninitialized) counts as ready
|
||||
|
||||
|
||||
## Spend one dash charge and start the recharge clock if it isn't running.
|
||||
func consume_dash_charge() -> void:
|
||||
if dash_charges < 0:
|
||||
dash_charges = params.dash_charges
|
||||
dash_charges = maxi(dash_charges - 1, 0)
|
||||
if dash_recharge_timer <= 0.0:
|
||||
dash_recharge_timer = params.dash_cooldown
|
||||
|
||||
|
||||
## Seconds until the NEXT dash is available (0 when a charge is banked).
|
||||
## HUDs also read get_dash_charges() to draw pips.
|
||||
func get_dash_cooldown_remaining() -> float:
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
return maxf(0.0, params.dash_cooldown - (now - StateDash._last_dash_time))
|
||||
if dash_charges != 0:
|
||||
return 0.0
|
||||
return maxf(dash_recharge_timer, 0.0)
|
||||
|
||||
|
||||
func get_dash_charges() -> int:
|
||||
return dash_charges if dash_charges >= 0 else params.dash_charges
|
||||
|
||||
@@ -11,6 +11,12 @@ var camera: Camera3D = null
|
||||
var flashlight: SpotLight3D = null
|
||||
var _damage_layer: CanvasLayer = null
|
||||
|
||||
# Third-person view toggle (local player only). The first-person camera keeps
|
||||
# handling aim/firing; the toggle only changes which camera renders and whether
|
||||
# the owner sees their own model.
|
||||
var third_person: bool = false
|
||||
var _tp_camera: Camera3D = null
|
||||
|
||||
# Health and Shield
|
||||
var max_health: float = 100.0
|
||||
var health: float = 100.0
|
||||
@@ -59,10 +65,31 @@ var grapple_rope: MeshInstance3D
|
||||
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
|
||||
# These are written by the authority each frame and replicated to remote peers
|
||||
# via the MultiplayerSynchronizer properties.
|
||||
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
|
||||
# broadcasts position/velocity; remote peers interpolate toward it (see
|
||||
# _process). The server stays authoritative for health, shield, and kills.
|
||||
var synced_movement_state: String = "idle"
|
||||
var synced_movement_speed: float = 0.0
|
||||
var synced_is_crouching: bool = false
|
||||
var synced_position: Vector3 = Vector3.ZERO
|
||||
var synced_velocity: Vector3 = Vector3.ZERO
|
||||
var synced_is_ads: bool = false
|
||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
||||
var synced_is_dancing: bool = false # dance emote (B), shown on the model
|
||||
|
||||
# Anime speed-lines overlay (local player only)
|
||||
var _speedlines: ColorRect = null
|
||||
var _speedline_burst: float = 0.0
|
||||
@export var synced_skin_id: String = ""
|
||||
@export var synced_weapon_path: String = ""
|
||||
|
||||
# Remote interpolation tuning
|
||||
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
|
||||
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
|
||||
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
|
||||
|
||||
var skinned_model: Node3D = null
|
||||
var _applied_skin_id: String = "__none__"
|
||||
@export var synced_grapple_point: Vector3 = Vector3.ZERO
|
||||
@export var synced_is_grapple_shooting: bool = false
|
||||
|
||||
@@ -108,12 +135,107 @@ func _ready() -> void:
|
||||
add_child(_damage_layer)
|
||||
_setup_hit_marker()
|
||||
_setup_hud()
|
||||
_setup_speedlines()
|
||||
else:
|
||||
call_deferred("_hide_remote_weapons")
|
||||
|
||||
set_process(true)
|
||||
if is_multiplayer_authority():
|
||||
set_physics_process(true)
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
if skin_mgr:
|
||||
synced_skin_id = skin_mgr.active_skin_id
|
||||
synced_position = position
|
||||
_setup_third_person_camera()
|
||||
# Build the visual model for whatever skin is selected (remote peers get
|
||||
# the id via the synchronizer and rebuild in _process when it arrives).
|
||||
_apply_skin_model(synced_skin_id)
|
||||
|
||||
|
||||
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
|
||||
## clip through walls. The first-person camera still exists and handles firing;
|
||||
## this one only renders when third_person is on.
|
||||
func _setup_third_person_camera() -> void:
|
||||
if not head_pivot or not is_instance_valid(camera):
|
||||
return
|
||||
var boom := SpringArm3D.new()
|
||||
boom.name = "ThirdPersonBoom"
|
||||
boom.spring_length = 3.2
|
||||
boom.margin = 0.3
|
||||
boom.collision_mask = 1 # environment only
|
||||
boom.add_excluded_object(get_rid())
|
||||
# Aim the arm up-and-back from the head so the camera sits behind/above.
|
||||
boom.rotation_degrees = Vector3(20, 0, 0)
|
||||
head_pivot.add_child(boom)
|
||||
|
||||
_tp_camera = Camera3D.new()
|
||||
_tp_camera.name = "ThirdPersonCamera"
|
||||
_tp_camera.fov = camera.fov
|
||||
_tp_camera.current = false
|
||||
boom.add_child(_tp_camera)
|
||||
|
||||
|
||||
## Toggle between first- and third-person. Firing keeps using the first-person
|
||||
## camera, so aim is unchanged; only rendering and self-visibility change.
|
||||
func set_third_person(on: bool) -> void:
|
||||
if not is_multiplayer_authority():
|
||||
return
|
||||
third_person = on
|
||||
if is_instance_valid(_tp_camera):
|
||||
_tp_camera.current = on
|
||||
if is_instance_valid(camera):
|
||||
camera.current = not on
|
||||
# Reveal / hide the owner's own model.
|
||||
var visual = get_visual_model()
|
||||
if visual and visual.has_method("set_owner_visible"):
|
||||
visual.set_owner_visible(on)
|
||||
# Hide the first-person weapon viewmodel in third person (it would float in
|
||||
# the middle of the screen); the third-person weapon on the model shows.
|
||||
if is_instance_valid(camera):
|
||||
var wman = camera.get_node_or_null("WeaponManager")
|
||||
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
|
||||
wman.canvas_layer.visible = not on
|
||||
|
||||
## Returns the node that visually represents this player (skinned GLB model
|
||||
## if the active skin has one, otherwise the procedural HumanoidModel).
|
||||
func get_visual_model() -> Node3D:
|
||||
if is_instance_valid(skinned_model):
|
||||
return skinned_model
|
||||
return get_node_or_null("HumanoidModel")
|
||||
|
||||
## Swap the visual model to match a skin id. GLB skins replace the procedural
|
||||
## model entirely; color skins tint the procedural model.
|
||||
func _apply_skin_model(skin_id: String) -> void:
|
||||
_applied_skin_id = skin_id
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
var old = get_node_or_null("SkinnedModel")
|
||||
if old:
|
||||
remove_child(old)
|
||||
old.queue_free()
|
||||
skinned_model = null
|
||||
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
var skin = skin_mgr.get_skin(skin_id) if skin_mgr else null
|
||||
var has_model: bool = skin != null and skin.model_path != "" \
|
||||
and (ResourceLoader.exists(skin.model_path) or FileAccess.file_exists(skin.model_path))
|
||||
|
||||
if has_model:
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.name = "SkinnedModel"
|
||||
model.model_path = skin.model_path
|
||||
# Owner gets the first-person body view (head hidden, fully animated);
|
||||
# everyone else sees the full third-person model.
|
||||
model.first_person_mode = is_multiplayer_authority()
|
||||
model.position = Vector3(0, -0.9, 0) # capsule center -> feet
|
||||
add_child(model)
|
||||
skinned_model = model
|
||||
if humanoid:
|
||||
humanoid.visible = false
|
||||
else:
|
||||
if humanoid:
|
||||
humanoid.visible = not is_dead
|
||||
if skin and humanoid.has_method("_apply_color"):
|
||||
humanoid._apply_color(skin.color_tint)
|
||||
|
||||
func _hide_remote_weapons() -> void:
|
||||
if camera:
|
||||
@@ -199,6 +321,18 @@ func _setup_audio() -> void:
|
||||
add_child(grapple_swing_player)
|
||||
grapple_swing_player.play()
|
||||
|
||||
func _setup_speedlines() -> void:
|
||||
_speedlines = ColorRect.new()
|
||||
_speedlines.name = "SpeedLines"
|
||||
_speedlines.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_speedlines.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = load("res://assets/shaders/speed_lines.gdshader")
|
||||
mat.set_shader_parameter("intensity", 0.0)
|
||||
_speedlines.material = mat
|
||||
_damage_layer.add_child(_speedlines)
|
||||
|
||||
|
||||
func _setup_hit_marker() -> void:
|
||||
hit_marker = Control.new()
|
||||
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
||||
@@ -304,7 +438,19 @@ func server_apply_impulse(force: Vector3) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if sender != 1 and sender != str(name).to_int(): return
|
||||
apply_impulse(force)
|
||||
# Movement is client-authoritative, so the impulse must land on the peer
|
||||
# that simulates this body.
|
||||
if is_multiplayer_authority():
|
||||
apply_impulse(force)
|
||||
else:
|
||||
authority_apply_impulse.rpc_id(get_multiplayer_authority(), force)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func authority_apply_impulse(force: Vector3) -> void:
|
||||
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||
if multiplayer.get_remote_sender_id() != 1: return
|
||||
if is_multiplayer_authority():
|
||||
apply_impulse(force)
|
||||
|
||||
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
var attacker_id = 0
|
||||
@@ -509,10 +655,9 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
|
||||
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if impulse.length_squared() > 0.01:
|
||||
apply_impulse(impulse)
|
||||
|
||||
# Broadcast damage event to all peers so the victim dies on all screens
|
||||
# Broadcast damage event to all peers so the victim dies on all screens.
|
||||
# Knockback is applied inside rpc_take_damage on the victim's own peer,
|
||||
# since that peer simulates this body.
|
||||
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
@@ -538,6 +683,16 @@ func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_n
|
||||
if attacker_id != 0:
|
||||
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
|
||||
|
||||
# Flinch: the victim's model plays a hit reaction on every screen.
|
||||
if amount > 0.0:
|
||||
var vm = get_visual_model()
|
||||
if vm and vm.has_method("play_oneshot"):
|
||||
vm.play_oneshot("Hit", 0.3)
|
||||
|
||||
# Knockback lands on the simulating peer (movement is client-authoritative)
|
||||
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
|
||||
apply_impulse(impulse)
|
||||
|
||||
time_since_last_damage = 0.0
|
||||
|
||||
if shield > 0.0:
|
||||
@@ -647,32 +802,9 @@ func _ensure_machine() -> MovementStateMachine:
|
||||
_machine = null
|
||||
return null
|
||||
|
||||
var _client_input_dir: Vector2 = Vector2.ZERO
|
||||
var _client_wish_dir_world: Vector3 = Vector3.ZERO
|
||||
var _client_jump: bool = false
|
||||
var _client_jump_just_pressed: bool = false
|
||||
var _client_crouch: bool = false
|
||||
var _client_dash: bool = false
|
||||
var _client_grapple: bool = false
|
||||
var _client_grapple_just_pressed: bool = false
|
||||
|
||||
@rpc("any_peer", "call_local", "unreliable")
|
||||
func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, jump_just: bool, crouch: bool, dash: bool, grapple: bool, grapple_just: bool) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if sender != str(name).to_int() and sender != 1: return # Accept from owner or host self
|
||||
|
||||
_client_input_dir = input_dir
|
||||
_client_wish_dir_world = wish_dir
|
||||
_client_jump = jump
|
||||
_client_jump_just_pressed = jump_just
|
||||
_client_crouch = crouch
|
||||
_client_dash = dash
|
||||
_client_grapple = grapple
|
||||
_client_grapple_just_pressed = grapple_just
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Local client captures input and sends it
|
||||
# The owning peer reads input and feeds its own state machine directly.
|
||||
# No server round-trip: movement responds on the same frame it's pressed.
|
||||
if is_multiplayer_authority():
|
||||
var raw_input := Vector2.ZERO
|
||||
var input_jump := false
|
||||
@@ -706,7 +838,35 @@ func _physics_process(_delta: float) -> void:
|
||||
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
|
||||
flashlight.visible = !flashlight.visible
|
||||
|
||||
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, input_grapple_just)
|
||||
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||
set_third_person(not third_person)
|
||||
|
||||
# Dance emote (B): toggles while grounded and idle-ish; any
|
||||
# movement/jump/crouch input breaks it.
|
||||
if Input.is_action_just_pressed("emote"):
|
||||
var m := _ensure_machine()
|
||||
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
|
||||
if not synced_is_dancing and m and m.current_state == "ground" and slow:
|
||||
synced_is_dancing = true
|
||||
else:
|
||||
synced_is_dancing = false
|
||||
if synced_is_dancing:
|
||||
var m2 := _ensure_machine()
|
||||
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
|
||||
var airborne: bool = m2 and m2.current_state != "ground"
|
||||
if moving or airborne:
|
||||
synced_is_dancing = false
|
||||
|
||||
var machine := _ensure_machine()
|
||||
if machine:
|
||||
machine.input_dir = raw_input
|
||||
machine.wish_dir_world = world_dir
|
||||
machine.input_jump_pressed = input_jump
|
||||
machine.input_jump_just_pressed = input_jump_just
|
||||
machine.input_crouch = input_crouch
|
||||
machine.input_dash = input_dash
|
||||
machine.input_grapple = input_grapple
|
||||
machine.input_grapple_just_pressed = input_grapple_just
|
||||
|
||||
var speed = velocity.length()
|
||||
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
|
||||
@@ -714,27 +874,22 @@ func _physics_process(_delta: float) -> void:
|
||||
if not wind_player.playing and wind_factor > 0.0:
|
||||
wind_player.play()
|
||||
|
||||
# Anime speed lines: fade in past ~1.2x walk speed, spike on dash.
|
||||
if is_instance_valid(_speedlines):
|
||||
_speedline_burst = maxf(_speedline_burst - _delta * 2.5, 0.0)
|
||||
var hs := Vector2(velocity.x, velocity.z).length()
|
||||
var speed_intensity := clampf((hs - params.walk_speed * 1.2) / 12.0, 0.0, 0.85)
|
||||
var target := maxf(speed_intensity, _speedline_burst)
|
||||
var mat := _speedlines.material as ShaderMaterial
|
||||
var cur: float = mat.get_shader_parameter("intensity")
|
||||
mat.set_shader_parameter("intensity", lerpf(cur, target, 1.0 - exp(-10.0 * _delta)))
|
||||
|
||||
var sm := _ensure_machine()
|
||||
if not sm:
|
||||
return
|
||||
|
||||
# Only the Server sets input values to the StateMachine for evaluation
|
||||
if multiplayer.is_server():
|
||||
sm.input_dir = _client_input_dir
|
||||
sm.wish_dir_world = _client_wish_dir_world
|
||||
sm.input_jump_pressed = _client_jump
|
||||
sm.input_jump_just_pressed = _client_jump_just_pressed
|
||||
sm.input_crouch = _client_crouch
|
||||
sm.input_dash = _client_dash
|
||||
sm.input_grapple = _client_grapple
|
||||
sm.input_grapple_just_pressed = _client_grapple_just_pressed
|
||||
|
||||
# Reset one-frame actions
|
||||
_client_jump_just_pressed = false
|
||||
_client_dash = false
|
||||
_client_grapple_just_pressed = false
|
||||
|
||||
# Update synced properties for the grapple
|
||||
# The owning peer publishes its grapple state for everyone's rope visuals
|
||||
if is_multiplayer_authority():
|
||||
synced_grapple_point = sm.grapple_point
|
||||
synced_is_grapple_shooting = sm.is_grapple_shooting
|
||||
|
||||
@@ -761,18 +916,51 @@ func _physics_process(_delta: float) -> void:
|
||||
else:
|
||||
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
|
||||
|
||||
# Update humanoid model animation state (runs everywhere)
|
||||
# Local player's aim-down-sights state (drives the model's weapon raise).
|
||||
synced_is_ads = _read_ads()
|
||||
|
||||
# Update humanoid model animation state
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
if humanoid:
|
||||
# Drive the visual model (skinned GLB or procedural) from local state
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
var h_speed = Vector2(velocity.x, velocity.z).length()
|
||||
humanoid.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_locomotion"):
|
||||
var d := _local_move_dir()
|
||||
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(sm.wall_side)
|
||||
if visual.has_method("set_dancing"):
|
||||
visual.set_dancing(synced_is_dancing)
|
||||
|
||||
# Write synced state for remote peers
|
||||
# Publish state for remote peers
|
||||
synced_movement_state = sm.current_state
|
||||
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
|
||||
synced_is_crouching = sm.input_crouch
|
||||
synced_wall_side = sm.wall_side
|
||||
synced_position = position
|
||||
synced_velocity = velocity
|
||||
|
||||
|
||||
## Movement direction relative to facing: x = strafe (+right), y = forward
|
||||
## (+forward). Derived from velocity so it works for local and remote players.
|
||||
func _local_move_dir() -> Vector2:
|
||||
var hspeed := Vector2(velocity.x, velocity.z).length()
|
||||
if hspeed < 0.5:
|
||||
return Vector2.ZERO
|
||||
var local_vel := global_transform.basis.inverse() * velocity
|
||||
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
|
||||
|
||||
|
||||
## Whether the local player's active weapon is aiming down sights.
|
||||
func _read_ads() -> bool:
|
||||
if not is_instance_valid(camera):
|
||||
return false
|
||||
var wman = camera.get_node_or_null("WeaponManager")
|
||||
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
|
||||
var w = wman.weapons[wman.active_slot]
|
||||
if w and "is_ads" in w:
|
||||
return w.is_ads
|
||||
return false
|
||||
|
||||
|
||||
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
@@ -782,9 +970,20 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
grapple_shoot_player.play()
|
||||
elif ev == "grapple_latch":
|
||||
grapple_latch_player.play()
|
||||
elif ev == "dash":
|
||||
_speedline_burst = 1.0
|
||||
elif ev == "land":
|
||||
# Landing thud: reuse the footstep sample, pitched down and louder
|
||||
# with impact. Ground state resets pitch/volume before each step.
|
||||
if footstep_player:
|
||||
var heavy: bool = data.get("heavy", false)
|
||||
footstep_player.pitch_scale = 0.55 if heavy else 0.7
|
||||
footstep_player.volume_db = 2.0 if heavy else -2.0
|
||||
footstep_player.play()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Remote players: apply synced movement state to their HumanoidModel
|
||||
# Remote players: interpolate toward the owner's synced transform and
|
||||
# apply synced movement state to their visual model
|
||||
if not is_multiplayer_authority():
|
||||
if synced_loadout_ready and not has_meta("remote_weapons_built"):
|
||||
set_meta("remote_weapons_built", true)
|
||||
@@ -792,13 +991,35 @@ func _process(delta: float) -> void:
|
||||
if wman and wman.has_method("_build_remote_loadout"):
|
||||
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
|
||||
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
if humanoid:
|
||||
humanoid.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
||||
# Skin can arrive/change after spawn — rebuild the model when it does
|
||||
if synced_skin_id != _applied_skin_id:
|
||||
_apply_skin_model(synced_skin_id)
|
||||
|
||||
# Snapshot interpolation: chase the owner's last known position with a
|
||||
# little velocity extrapolation so fast targets stay accurate between
|
||||
# packets. Snap on big desyncs (respawn/teleport).
|
||||
if not is_dead and synced_position != Vector3.ZERO:
|
||||
var target := synced_position + synced_velocity * NET_EXTRAPOLATION
|
||||
if position.distance_to(target) > NET_SNAP_DISTANCE:
|
||||
position = target
|
||||
else:
|
||||
position = position.lerp(target, 1.0 - exp(-NET_LERP_RATE * delta))
|
||||
velocity = synced_velocity
|
||||
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
||||
if visual.has_method("set_locomotion"):
|
||||
var d := _local_move_dir()
|
||||
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(synced_wall_side)
|
||||
if visual.has_method("set_dancing"):
|
||||
visual.set_dancing(synced_is_dancing)
|
||||
# Check for weapon changes
|
||||
if synced_weapon_path != "" and synced_weapon_path != humanoid.get_meta("current_weapon_path", ""):
|
||||
humanoid.set_weapon(synced_weapon_path)
|
||||
humanoid.set_meta("current_weapon_path", synced_weapon_path)
|
||||
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
||||
visual.set_weapon(synced_weapon_path)
|
||||
visual.set_meta("current_weapon_path", synced_weapon_path)
|
||||
return
|
||||
|
||||
# Update UI
|
||||
@@ -963,6 +1184,10 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_dead: return
|
||||
is_dead = true
|
||||
|
||||
# Return to first person so the toggle state doesn't fight the death cam.
|
||||
if is_multiplayer_authority() and third_person:
|
||||
set_third_person(false)
|
||||
|
||||
# Disable movement state machine inputs
|
||||
if _machine:
|
||||
_machine.process_mode = Node.PROCESS_MODE_DISABLED
|
||||
@@ -971,6 +1196,9 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
if humanoid:
|
||||
humanoid.visible = false
|
||||
var skinned = get_node_or_null("SkinnedModel")
|
||||
if skinned:
|
||||
skinned.visible = false
|
||||
|
||||
# Disable collision so player doesn't block bullets
|
||||
var col = get_node_or_null("CollisionShape3D")
|
||||
@@ -984,7 +1212,7 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
ragdoll_instance = ragdoll
|
||||
get_tree().current_scene.add_child(ragdoll)
|
||||
ragdoll.global_transform = global_transform
|
||||
ragdoll.build_ragdoll(Color(0.2, 0.4, 0.8)) # Blueish player color
|
||||
ragdoll.build_ragdoll(Color(0.0, 0.75, 0.75)) # Miku teal — matches player 1 skin
|
||||
|
||||
# Wait a frame for physics to initialize then apply velocity
|
||||
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse))
|
||||
@@ -1112,7 +1340,7 @@ func _throw_grenade() -> void:
|
||||
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
|
||||
var spawn_pos = camera.global_position + camera.global_transform.basis.z * -0.5
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
server_spawn_grenade.rpc_id(1, spawn_pos, throw_vel)
|
||||
else:
|
||||
_spawn_grenade_local(spawn_pos, throw_vel, 1)
|
||||
@@ -1131,7 +1359,8 @@ func _spawn_grenade_local(pos: Vector3, vel: Vector3, owner_id: int) -> void:
|
||||
var g_scene = load("res://weapons/grenade_projectile.gd")
|
||||
if not g_scene: return
|
||||
var g = g_scene.new()
|
||||
g.global_position = pos
|
||||
# Set position before adding to tree to avoid global_transform warning
|
||||
g.position = pos
|
||||
g.velocity = vel
|
||||
|
||||
var p = null
|
||||
@@ -1213,14 +1442,21 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
|
||||
if col:
|
||||
col.set_deferred("disabled", false)
|
||||
|
||||
var visual_model = get_node_or_null("HumanoidModel")
|
||||
var visual_model = get_visual_model()
|
||||
if visual_model:
|
||||
visual_model.visible = true
|
||||
var humanoid_model = get_node_or_null("HumanoidModel")
|
||||
if humanoid_model and humanoid_model != visual_model:
|
||||
humanoid_model.visible = not is_instance_valid(skinned_model)
|
||||
|
||||
# Server sets the actual position to sync to everyone
|
||||
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||
position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
# Everyone applies the broadcast spawn position; the owning peer also
|
||||
# resets its synced transform so remotes snap instead of lerping across
|
||||
# the map.
|
||||
position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
if is_multiplayer_authority():
|
||||
synced_position = spawn_pos
|
||||
synced_velocity = Vector3.ZERO
|
||||
|
||||
# Local client resets UI and rebuilds weapons for their view
|
||||
if is_multiplayer_authority():
|
||||
|
||||
@@ -28,30 +28,38 @@ func update(delta: float) -> void:
|
||||
grav *= params.low_jump_multiplier
|
||||
vel.y -= grav * delta
|
||||
|
||||
# ── Air control ───────────────────────────────────────────────────────
|
||||
# ── Air control: Quake-style accelerate ───────────────────────────────
|
||||
# Only the velocity component ALONG wish_dir is capped, so turning while
|
||||
# strafing genuinely gains speed. Total input-driven speed is bounded by
|
||||
# max_air_speed; externally-gained speed (dash, rockets) is never clamped.
|
||||
var wish_dir: Vector3 = machine.wish_dir_world
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
var current_speed = hvel.length()
|
||||
var wish_speed: float = minf(machine.get_effective_speed(params.walk_speed), params.max_air_speed)
|
||||
var current_along := hvel.dot(wish_dir)
|
||||
var add_speed := wish_speed - current_along
|
||||
if add_speed > 0.0:
|
||||
var accel_speed: float = minf(params.air_strafe_accel * delta, add_speed)
|
||||
# Quake's trick: cap per-tick projection so sharp turns give the gain
|
||||
accel_speed = minf(accel_speed, params.air_wish_speed_cap)
|
||||
hvel += wish_dir * accel_speed
|
||||
|
||||
# Add a strong force in the wish direction
|
||||
var air_accel = params.air_strafe_accel * delta
|
||||
var new_hvel = hvel + wish_dir * air_accel
|
||||
|
||||
# Limit the speed so we don't gain speed purely from air control.
|
||||
# We only allow air control to change our direction (strafing)
|
||||
# or to accelerate us up to our walk speed if we started slow.
|
||||
var max_allowed_speed = maxf(current_speed, params.walk_speed)
|
||||
|
||||
if new_hvel.length() > max_allowed_speed:
|
||||
new_hvel = new_hvel.normalized() * max_allowed_speed
|
||||
|
||||
hvel = new_hvel
|
||||
# Direct steering: bend existing velocity toward the input WITHOUT
|
||||
# changing its magnitude, so airborne direction changes feel light.
|
||||
# Skipped when input opposes travel (braking is the accel path's job,
|
||||
# and lerping through zero would flip the heading unstably).
|
||||
var speed_now := hvel.length()
|
||||
if speed_now > 1.0:
|
||||
var travel_dir := hvel / speed_now
|
||||
if travel_dir.dot(wish_dir) > -0.7:
|
||||
var steered := hvel.lerp(wish_dir * speed_now, params.air_steer_rate * delta)
|
||||
if steered.length_squared() > 0.01:
|
||||
hvel = steered.normalized() * speed_now
|
||||
else:
|
||||
# No input: slight air drag (very subtle)
|
||||
hvel *= (1.0 - 0.5 * delta)
|
||||
# No input: barely any drag — held momentum should carry
|
||||
hvel *= (1.0 - 0.15 * delta)
|
||||
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
@@ -61,8 +69,8 @@ func update(delta: float) -> void:
|
||||
|
||||
# ── Landing ───────────────────────────────────────────────────────────
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
var fall_speed: float = maxf(-vel.y, 0.0)
|
||||
machine.notify_landed(fall_speed)
|
||||
|
||||
# Bunny hop: if jump was buffered or pressed on landing frame
|
||||
if (machine.input_jump_pressed or machine.jump_buffer_time > 0.0) and machine.jump_cooldown_timer <= 0.0:
|
||||
@@ -105,26 +113,7 @@ func update(delta: float) -> void:
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if fwd_wall.hit:
|
||||
if fwd_wall.is_short:
|
||||
# Instant Vault
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.do_vault()
|
||||
return
|
||||
else:
|
||||
# Check if moving directly into wall and looking generally towards it
|
||||
@@ -145,11 +134,11 @@ func update(delta: float) -> void:
|
||||
|
||||
# Require looking mostly along the wall (angle > 41 degrees from normal)
|
||||
# and inputting mostly along the wall (angle > 31 degrees from normal, allows W+A/D diagonally into wall)
|
||||
if absf(h_look.dot(wall_n)) < 0.75 and absf(wish_dir.dot(wall_n)) < 0.85:
|
||||
if abs(h_look.dot(wall_n)) < 0.75 and abs(wish_dir.dot(wall_n)) < 0.85:
|
||||
machine.switch_to("wall_run")
|
||||
return
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────
|
||||
if machine.input_dash:
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
extends Node
|
||||
class_name StateDash
|
||||
|
||||
## Short burst dash. Cooldown lives on the machine (per-player instance) and is
|
||||
## checked by the states that enter dash, so entering this state always dashes.
|
||||
## Momentum is fully preserved on exit — the dash ADDS speed to your run.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var direction: Vector3 = Vector3.ZERO
|
||||
var _exit_speed: float = 0.0
|
||||
var _last_vel: Vector3 = Vector3.ZERO
|
||||
|
||||
# Cooldown tracked across dash instances
|
||||
static var _last_dash_time: float = -999.0
|
||||
var _dash_speed: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
# Check cooldown
|
||||
var now := Time.get_ticks_msec() / 1000.0
|
||||
if now - _last_dash_time < params.dash_cooldown:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
elapsed = 0.0
|
||||
_last_dash_time = now
|
||||
machine.consume_dash_charge()
|
||||
|
||||
var player := machine.player
|
||||
if player.dash_player:
|
||||
@@ -38,6 +32,7 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
direction = direction.normalized()
|
||||
|
||||
machine.register_chain_mechanic("dash")
|
||||
machine.movement_event.emit("dash", {})
|
||||
|
||||
var current_hvel := Vector3(machine.player.velocity.x, 0.0, machine.player.velocity.z)
|
||||
var current_speed := current_hvel.length()
|
||||
@@ -47,46 +42,39 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
|
||||
if proj >= 0.0:
|
||||
# Dashing forward or diagonally forward: add base dash speed to the projected speed
|
||||
_exit_speed = proj + base_dash_speed
|
||||
_dash_speed = proj + base_dash_speed
|
||||
else:
|
||||
# Dashing backward or diagonally backward:
|
||||
# Reflect current momentum into the new direction, smoothly scaling from base_dash_speed (at sideways) to current_speed (at exactly backward).
|
||||
# Reflect current momentum into the new direction, smoothly scaling from
|
||||
# base_dash_speed (at sideways) to current_speed (at exactly backward).
|
||||
var backward_factor = -proj / current_speed if current_speed > 0.001 else 0.0
|
||||
_exit_speed = lerpf(base_dash_speed, maxf(current_speed, base_dash_speed), backward_factor)
|
||||
_dash_speed = lerpf(base_dash_speed, maxf(current_speed, base_dash_speed), backward_factor)
|
||||
|
||||
machine.player.velocity = direction * _exit_speed
|
||||
machine.player.velocity = direction * _dash_speed
|
||||
machine.on_ground = false
|
||||
_last_vel = machine.player.velocity
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
# Preserve dash velocity on exit (don't cut speed abruptly)
|
||||
pass
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
var player := machine.player
|
||||
|
||||
if elapsed > params.dash_duration:
|
||||
machine.player.velocity = direction * _exit_speed * 0.85
|
||||
if machine.player.is_on_floor():
|
||||
# Keep the full dash velocity — the dash is a speed investment
|
||||
player.velocity = direction * _dash_speed
|
||||
if player.is_on_floor():
|
||||
machine.on_ground = true
|
||||
machine.switch_to("ground")
|
||||
else:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Maintain dash velocity
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
|
||||
if vel.distance_squared_to(_last_vel) > 100.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
vel = direction * _exit_speed
|
||||
# Maintain dash velocity, flat trajectory
|
||||
var vel := direction * _dash_speed
|
||||
if player.is_on_floor():
|
||||
vel.y = -0.5 # Snap to floor to handle ramps
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
_last_vel = player.velocity
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
extends Node
|
||||
class_name StateGrapple
|
||||
|
||||
## Swing grapple with active rope control:
|
||||
## - the rope acts as a hard pendulum constraint at its current length
|
||||
## - holding forward reels in (rope shortens, converts to speed)
|
||||
## - holding back pays rope out (up to the original latch length)
|
||||
## - strafe input steers tangentially around the swing sphere
|
||||
## - jump detaches with a boost along your current motion
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var _rope_length: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
machine.on_ground = false
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.register_chain_mechanic("grapple")
|
||||
_rope_length = machine.grapple_length
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
@@ -24,16 +37,26 @@ func update(delta: float) -> void:
|
||||
return
|
||||
|
||||
var grapple_pos: Vector3 = machine.grapple_point
|
||||
var current_dist: float = player.global_position.distance_to(grapple_pos)
|
||||
var dir_to_point: Vector3 = (grapple_pos - player.global_position).normalized()
|
||||
var to_point: Vector3 = grapple_pos - player.global_position
|
||||
var current_dist: float = to_point.length()
|
||||
if current_dist < 0.01:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
var dir_to_point: Vector3 = to_point / current_dist
|
||||
|
||||
# Jump to detach and get a boost
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
vel.y = params.jump_velocity
|
||||
# Add a directional boost if jumping while swinging
|
||||
vel.y = maxf(vel.y, params.jump_velocity)
|
||||
# Boost along current motion so the release keeps the swing's energy
|
||||
var h_vel := Vector3(vel.x, 0.0, vel.z)
|
||||
if h_vel.length_squared() > 0.1:
|
||||
var boost := h_vel.normalized() * params.grapple_jump_boost * 0.5
|
||||
vel.x += boost.x
|
||||
vel.z += boost.z
|
||||
# Plus a steer boost toward held direction
|
||||
var h_look = machine.wish_dir_world
|
||||
if h_look.length_squared() > 0.1:
|
||||
var h_boost = h_look.normalized() * params.grapple_jump_boost
|
||||
var h_boost = h_look.normalized() * params.grapple_jump_boost * 0.5
|
||||
vel.x += h_boost.x
|
||||
vel.z += h_boost.z
|
||||
|
||||
@@ -48,39 +71,40 @@ func update(delta: float) -> void:
|
||||
# Apply gravity
|
||||
vel.y -= params.gravity * delta
|
||||
|
||||
# Apply slight inward pull (reeling in)
|
||||
# Base reel: constant gentle pull toward the hook
|
||||
vel += dir_to_point * params.grapple_pull_force * delta
|
||||
|
||||
# Pendulum / Rope physics
|
||||
# If the player tries to go further than the original rope length, pull them back aggressively
|
||||
var max_len: float = machine.grapple_length
|
||||
if current_dist > max_len:
|
||||
# Project velocity onto the tangent of the sphere (prevent moving further away)
|
||||
# ── Active rope control ───────────────────────────────────────────────
|
||||
var fwd_input := -machine.input_dir.y # +1 holding forward, -1 back
|
||||
if fwd_input > 0.1:
|
||||
# Reel in: shorten rope and pull hard — converts to swing speed
|
||||
vel += dir_to_point * params.grapple_reel_force * fwd_input * delta
|
||||
_rope_length = maxf(_rope_length - 8.0 * fwd_input * delta, 2.0)
|
||||
elif fwd_input < -0.1:
|
||||
# Pay out rope back toward the original latch length
|
||||
_rope_length = minf(_rope_length + 8.0 * -fwd_input * delta, machine.grapple_length)
|
||||
|
||||
# Keep the working length taut to where we actually are (swinging inside
|
||||
# the sphere shortens the constraint, giving crisp Tarzan arcs)
|
||||
_rope_length = minf(_rope_length, maxf(current_dist, 2.0))
|
||||
|
||||
# ── Pendulum constraint at the current rope length ────────────────────
|
||||
if current_dist > _rope_length:
|
||||
# Kill outward radial velocity (the rope is taut and inextensible)
|
||||
var radial_vel = vel.project(dir_to_point)
|
||||
# If radial_vel is pointing AWAY from the grapple point (dot < 0), kill it
|
||||
if radial_vel.dot(dir_to_point) < 0:
|
||||
vel -= radial_vel
|
||||
# Spring correction toward the sphere surface
|
||||
var diff = current_dist - _rope_length
|
||||
vel += dir_to_point * diff * params.grapple_spring_strength * delta
|
||||
|
||||
# Add a spring force to pull them back to the sphere
|
||||
var diff = current_dist - max_len
|
||||
var spring_force = dir_to_point * diff * params.grapple_spring_strength
|
||||
vel += spring_force * delta
|
||||
|
||||
# Air control while swinging
|
||||
var wish_dir: Vector3 = machine.wish_dir_world
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
# Add force tangentially to the rope
|
||||
# We want to steer, but not pull away from or directly into the rope.
|
||||
var tangent_wish = wish_dir - wish_dir.project(dir_to_point)
|
||||
if tangent_wish.length_squared() > 0.01:
|
||||
vel += tangent_wish.normalized() * params.grapple_air_control * delta
|
||||
# ── Tangential steering (strafe around the swing sphere) ──────────────
|
||||
var strafe_input := machine.input_dir.x
|
||||
if absf(strafe_input) > 0.1:
|
||||
var right := player.global_transform.basis.x
|
||||
var tangent_steer := right - right.project(dir_to_point)
|
||||
if tangent_steer.length_squared() > 0.01:
|
||||
vel += tangent_steer.normalized() * params.grapple_air_control * strafe_input * delta
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
|
||||
# Remove ground detach so players can grapple along the floor
|
||||
# if player.is_on_floor():
|
||||
# machine.on_ground = true
|
||||
# machine.switch_to("ground")
|
||||
# return
|
||||
|
||||
@@ -7,6 +7,7 @@ var params: MovementParams:
|
||||
|
||||
var _footstep_timer: float = 0.0
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
machine.on_ground = true
|
||||
machine.current_jump_count = 0
|
||||
@@ -16,9 +17,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if rig:
|
||||
rig.clear_wall_tilt()
|
||||
|
||||
# Ensure capsule is correct when entering ground state
|
||||
_update_capsule_height()
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
pass
|
||||
@@ -28,36 +26,19 @@ func update(delta: float) -> void:
|
||||
var player := machine.player
|
||||
var vel: Vector3 = player.velocity
|
||||
|
||||
# ── Jump (coyote time + jump buffer) ──────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.coyote_timer > 0.0 and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.on_ground = false
|
||||
machine.coyote_timer = 0.0
|
||||
machine.register_chain_mechanic("jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Jump buffer: player pressed jump just before landing
|
||||
if machine.on_ground and machine.jump_buffer_time > 0.0 and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.on_ground = false
|
||||
machine.jump_buffer_time = 0.0
|
||||
machine.register_chain_mechanic("jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
# ── Jump (direct press w/ coyote, or buffered from before landing) ────
|
||||
if machine.jump_cooldown_timer <= 0.0:
|
||||
var pressed := machine.input_jump_just_pressed and machine.coyote_timer > 0.0
|
||||
var buffered := machine.on_ground and machine.jump_buffer_time > 0.0
|
||||
if pressed or buffered:
|
||||
machine.do_jump()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Slide (crouch while moving fast enough) ───────────────────────────
|
||||
if machine.input_crouch and machine.slide_cooldown_timer <= 0.0:
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if hspeed > params.slide_min_speed:
|
||||
if hspeed > params.slide_min_speed + 1.0:
|
||||
machine.switch_to("slide")
|
||||
return
|
||||
|
||||
@@ -80,7 +61,7 @@ func update(delta: float) -> void:
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
# ── Gravity (correct direction: downward) ─────────────────────────────
|
||||
# ── Gravity / floor snap ──────────────────────────────────────────────
|
||||
if not player.is_on_floor():
|
||||
vel.y -= params.gravity * delta
|
||||
else:
|
||||
@@ -94,56 +75,17 @@ func update(delta: float) -> void:
|
||||
|
||||
# ── Stair Stepping ────────────────────────────────────────────────────
|
||||
if wish_dir.length_squared() > 0.01 and not machine.input_crouch:
|
||||
for i in range(player.get_slide_collision_count()):
|
||||
var col = player.get_slide_collision(i)
|
||||
if absf(col.get_normal().y) < 0.3: # Hit a wall
|
||||
var max_step_height = 0.95
|
||||
var space_state = player.get_world_3d().direct_space_state
|
||||
var feet_y = player.global_position.y - (machine.original_capsule_height / 2.0)
|
||||
|
||||
# Raycast down from above the step
|
||||
var fwd = wish_dir.normalized()
|
||||
var ray_start = player.global_position + fwd * 0.65
|
||||
ray_start.y = feet_y + max_step_height + 0.1
|
||||
var ray_end = ray_start - Vector3.UP * (max_step_height + 0.2)
|
||||
|
||||
var ray = PhysicsRayQueryParameters3D.create(ray_start, ray_end)
|
||||
ray.exclude = [player.get_rid()]
|
||||
var hit = space_state.intersect_ray(ray)
|
||||
|
||||
if not hit.is_empty() and hit.normal.y > 0.7:
|
||||
var step_height = hit.position.y - feet_y
|
||||
if step_height > 0.01 and step_height <= max_step_height:
|
||||
# Check if we have headroom to move up
|
||||
var head_ray_start = player.global_position
|
||||
head_ray_start.y += (machine.original_capsule_height / 2.0)
|
||||
var head_ray_end = head_ray_start + Vector3.UP * (step_height + 0.1)
|
||||
var head_ray = PhysicsRayQueryParameters3D.create(head_ray_start, head_ray_end)
|
||||
head_ray.exclude = [player.get_rid()]
|
||||
|
||||
if space_state.intersect_ray(head_ray).is_empty():
|
||||
# Check if there is enough space to move forward after stepping up
|
||||
var test_transform = player.global_transform
|
||||
test_transform.origin.y += step_height + 0.05
|
||||
if not player.test_move(test_transform, fwd * 0.15):
|
||||
# We can safely step up
|
||||
player.global_position.y += step_height + 0.01
|
||||
if player.head_pivot and player.head_pivot.has_method("add_step_offset"):
|
||||
player.head_pivot.add_step_offset(-(step_height + 0.01))
|
||||
# Push slightly forward to get onto the step
|
||||
player.global_position += fwd * 0.1
|
||||
# Restore horizontal velocity that was lost from hitting the wall
|
||||
player.velocity.x = hvel.x
|
||||
player.velocity.z = hvel.z
|
||||
break
|
||||
|
||||
_update_capsule_height()
|
||||
_try_step_up(player, wish_dir, hvel)
|
||||
|
||||
var current_hspeed = Vector2(player.velocity.x, player.velocity.z).length()
|
||||
if current_hspeed > 1.0:
|
||||
if current_hspeed > 1.0 and player.is_on_floor():
|
||||
_footstep_timer -= delta
|
||||
if _footstep_timer <= 0.0:
|
||||
if player.footstep_player:
|
||||
# Reset pitch/volume (the landing thud reuses this player) and
|
||||
# add slight variation so steps don't machine-gun.
|
||||
player.footstep_player.pitch_scale = randf_range(0.92, 1.08)
|
||||
player.footstep_player.volume_db = -6.0
|
||||
player.footstep_player.play()
|
||||
_footstep_timer = max(0.2, 3.0 / current_hspeed)
|
||||
else:
|
||||
@@ -162,82 +104,79 @@ func update(delta: float) -> void:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Wall interaction (Run, Climb, Vault) ──────────────────────────────
|
||||
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
|
||||
# ── Wall interaction (Vault, Climb) ───────────────────────────────────
|
||||
if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 \
|
||||
and machine.wall_cooldown_timer <= 0.0 and not machine.input_crouch:
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if fwd_wall.hit:
|
||||
if fwd_wall.is_short:
|
||||
# Instant Vault
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if fwd_wall.is_short and hspeed > 3.0:
|
||||
machine.do_vault()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
else:
|
||||
elif not fwd_wall.is_short:
|
||||
# Check if moving directly into wall and looking generally towards it
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var looking_towards := look_dir.dot(-fwd_wall.normal) > 0.0
|
||||
var moving_towards := wish_dir.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
|
||||
var moving_towards := machine.wish_dir_world.dot(-fwd_wall.normal) > cos(deg_to_rad(params.wall_climb_max_angle))
|
||||
|
||||
if looking_towards and moving_towards and machine.can_wall_climb:
|
||||
machine.wall_normal = fwd_wall.normal
|
||||
machine.switch_to("wall_climb")
|
||||
return
|
||||
|
||||
# Fallback to wall run
|
||||
var wall_n := machine.detect_wall_horizontal()
|
||||
if wall_n != Vector3.ZERO:
|
||||
if not player.is_on_floor():
|
||||
var w_look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(w_look_dir.x, 0.0, w_look_dir.z).normalized()
|
||||
|
||||
# Require looking mostly along the wall (angle > 41 degrees from normal)
|
||||
# and inputting mostly along the wall (angle > 31 degrees from normal, allows W+A/D diagonally into wall)
|
||||
if absf(h_look.dot(wall_n)) < 0.75 and absf(wish_dir.dot(wall_n)) < 0.85:
|
||||
machine.switch_to("wall_run")
|
||||
return
|
||||
|
||||
# ── Dash ──────────────────────────────────────────────────────────────
|
||||
if machine.input_dash:
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
if hspeed > 0.1 or machine.input_dir.length() > 0.1:
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
|
||||
func _try_step_up(player: CharacterBody3D, wish_dir: Vector3, hvel: Vector3) -> void:
|
||||
for i in range(player.get_slide_collision_count()):
|
||||
var col = player.get_slide_collision(i)
|
||||
if abs(col.get_normal().y) < 0.3: # Hit a wall
|
||||
var max_step_height = 0.95
|
||||
var space_state = player.get_world_3d().direct_space_state
|
||||
var feet_y = player.global_position.y - (machine.original_capsule_height / 2.0)
|
||||
|
||||
# Raycast down from above the step
|
||||
var fwd = wish_dir.normalized()
|
||||
var ray_start = player.global_position + fwd * 0.65
|
||||
ray_start.y = feet_y + max_step_height + 0.1
|
||||
var ray_end = ray_start - Vector3.UP * (max_step_height + 0.2)
|
||||
|
||||
var ray = PhysicsRayQueryParameters3D.create(ray_start, ray_end)
|
||||
ray.exclude = [player.get_rid()]
|
||||
var hit = space_state.intersect_ray(ray)
|
||||
|
||||
if not hit.is_empty() and hit.normal.y > 0.7:
|
||||
var step_height = hit.position.y - feet_y
|
||||
if step_height > 0.01 and step_height <= max_step_height:
|
||||
# Check if we have headroom to move up
|
||||
var head_ray_start = player.global_position
|
||||
head_ray_start.y += (machine.original_capsule_height / 2.0)
|
||||
var head_ray_end = head_ray_start + Vector3.UP * (step_height + 0.1)
|
||||
var head_ray = PhysicsRayQueryParameters3D.create(head_ray_start, head_ray_end)
|
||||
head_ray.exclude = [player.get_rid()]
|
||||
|
||||
if space_state.intersect_ray(head_ray).is_empty():
|
||||
# Check if there is enough space to move forward after stepping up
|
||||
var test_transform = player.global_transform
|
||||
test_transform.origin.y += step_height + 0.05
|
||||
if not player.test_move(test_transform, fwd * 0.15):
|
||||
# We can safely step up
|
||||
player.global_position.y += step_height + 0.01
|
||||
if player.head_pivot and player.head_pivot.has_method("add_step_offset"):
|
||||
player.head_pivot.add_step_offset(-(step_height + 0.01))
|
||||
# Push slightly forward to get onto the step
|
||||
player.global_position += fwd * 0.1
|
||||
# Restore horizontal velocity that was lost from hitting the wall
|
||||
player.velocity.x = hvel.x
|
||||
player.velocity.z = hvel.z
|
||||
break
|
||||
|
||||
|
||||
func _get_camera_rig():
|
||||
if machine.player and machine.player.has_node("HeadPivot"):
|
||||
return machine.player.get_node("HeadPivot")
|
||||
return null
|
||||
|
||||
|
||||
func _update_capsule_height() -> void:
|
||||
var shape = _get_capsule()
|
||||
if shape:
|
||||
if machine.input_crouch:
|
||||
shape.height = machine.original_capsule_height * 0.5
|
||||
else:
|
||||
shape.height = machine.original_capsule_height
|
||||
|
||||
|
||||
func _get_capsule() -> CapsuleShape3D:
|
||||
for child in machine.player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
return child.shape as CapsuleShape3D
|
||||
return null
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
extends Node
|
||||
class_name StateSlide
|
||||
|
||||
## Momentum slide. Friction is a flat deceleration on level ground but gravity
|
||||
## projected along the floor accelerates you downhill, so slopes are the fast
|
||||
## route. The capsule stays low via the machine's crouch handling (slide counts
|
||||
## as crouched); no capsule fiddling here.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var _saved_capsule_height: float = 0.0
|
||||
var _slide_direction: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
@@ -17,12 +21,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if machine.player.slide_player and not machine.player.slide_player.playing:
|
||||
machine.player.slide_player.play()
|
||||
|
||||
# Save original capsule height and halve it
|
||||
var shape: CapsuleShape3D = _get_capsule()
|
||||
if shape:
|
||||
_saved_capsule_height = shape.height
|
||||
shape.height = _saved_capsule_height * 0.5
|
||||
|
||||
# Slide in the direction of current velocity (momentum-based)
|
||||
var hvel := Vector3(machine.player.velocity.x, 0.0, machine.player.velocity.z)
|
||||
if hvel.length_squared() > 0.01:
|
||||
@@ -33,23 +31,21 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
_slide_direction.y = 0.0
|
||||
_slide_direction = _slide_direction.normalized()
|
||||
|
||||
# Set initial slide velocity
|
||||
var slide_speed := maxf(hvel.length(), params.slide_speed)
|
||||
machine.player.velocity.x = _slide_direction.x * machine.get_effective_speed(slide_speed)
|
||||
machine.player.velocity.z = _slide_direction.z * machine.get_effective_speed(slide_speed)
|
||||
# Entry speed: keep momentum, add a flat boost when actually moving fast on
|
||||
# the ground (rewards slide-cancelling sprints without making crouch a brake)
|
||||
var entry_speed := hvel.length()
|
||||
if machine.player.is_on_floor() and entry_speed > params.walk_speed * 0.8:
|
||||
entry_speed += params.slide_boost
|
||||
entry_speed = maxf(entry_speed, params.slide_speed * 0.75)
|
||||
machine.player.velocity.x = _slide_direction.x * machine.get_effective_speed(entry_speed)
|
||||
machine.player.velocity.z = _slide_direction.z * machine.get_effective_speed(entry_speed)
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
machine.slide_cooldown_timer = params.slide_cooldown
|
||||
|
||||
if machine.player.slide_player:
|
||||
machine.player.slide_player.stop()
|
||||
|
||||
# Restore original capsule height
|
||||
var shape: CapsuleShape3D = _get_capsule()
|
||||
if shape and _saved_capsule_height > 0.0:
|
||||
shape.height = _saved_capsule_height
|
||||
|
||||
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
@@ -59,28 +55,46 @@ func update(delta: float) -> void:
|
||||
player.slide_player.play()
|
||||
|
||||
var vel: Vector3 = player.velocity
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var hspeed := hvel.length()
|
||||
|
||||
# ── Apply friction to horizontal velocity ─────────────────────────────
|
||||
vel.x *= pow(params.slide_friction, delta * 10.0)
|
||||
vel.z *= pow(params.slide_friction, delta * 10.0)
|
||||
# ── Slope physics: gravity projected along the floor plane ────────────
|
||||
var on_slope := false
|
||||
if player.is_on_floor():
|
||||
var floor_n := player.get_floor_normal()
|
||||
if floor_n.y < 0.999:
|
||||
# Downhill direction on this slope
|
||||
var downhill := (Vector3.DOWN - floor_n * Vector3.DOWN.dot(floor_n))
|
||||
if downhill.length_squared() > 0.0001:
|
||||
downhill = downhill.normalized()
|
||||
var steepness := 1.0 - floor_n.y # 0 flat .. ~0.3 steep
|
||||
var slope_pull := downhill * params.slide_slope_accel * steepness * 8.0
|
||||
hvel += Vector3(slope_pull.x, 0.0, slope_pull.z) * delta
|
||||
on_slope = downhill.dot(_slide_direction) > 0.1
|
||||
|
||||
# ── Allow slight steering ─────────────────────────────────────────────
|
||||
# ── Friction: flat decel on level ground, nearly free downhill ────────
|
||||
if hspeed > 0.01:
|
||||
var friction := params.slide_friction_flat
|
||||
if on_slope:
|
||||
friction *= 0.15
|
||||
var new_speed := maxf(hvel.length() - friction * delta, 0.0)
|
||||
if hvel.length() > 0.001:
|
||||
hvel = hvel.normalized() * new_speed
|
||||
|
||||
# ── Steering: bend velocity toward input without changing speed ───────
|
||||
var wish_dir := machine.wish_dir_world
|
||||
if wish_dir.length_squared() > 0.01:
|
||||
if wish_dir.length_squared() > 0.01 and hvel.length_squared() > 0.01:
|
||||
wish_dir = wish_dir.normalized()
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var current_speed := hvel.length()
|
||||
var speed_now := hvel.length()
|
||||
var steered := hvel.lerp(wish_dir * speed_now, params.slide_steer_rate * delta)
|
||||
if steered.length_squared() > 0.01:
|
||||
hvel = steered.normalized() * speed_now
|
||||
_slide_direction = hvel.normalized()
|
||||
|
||||
# Slowly bend velocity towards wish_dir
|
||||
var steer_speed = 3.0 * delta
|
||||
var new_hvel = hvel.lerp(wish_dir * current_speed, steer_speed)
|
||||
vel.x = new_hvel.x
|
||||
vel.z = new_hvel.z
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
if new_hvel.length_squared() > 0.01:
|
||||
_slide_direction = new_hvel.normalized()
|
||||
|
||||
# ── Gravity (for slopes) ──────────────────────────────────────────────
|
||||
# ── Gravity (for slopes / edges) ──────────────────────────────────────
|
||||
if not player.is_on_floor():
|
||||
vel.y -= params.gravity * delta
|
||||
else:
|
||||
@@ -97,10 +111,41 @@ func update(delta: float) -> void:
|
||||
machine.on_ground = false
|
||||
|
||||
# ── End conditions ────────────────────────────────────────────────────
|
||||
var hspeed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
var out_speed := Vector3(player.velocity.x, 0.0, player.velocity.z).length()
|
||||
|
||||
# Jump out of slide: momentum-preserving hop
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
var hv := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
if hv.length_squared() > 0.01:
|
||||
var dir := hv.normalized()
|
||||
var jump_speed := maxf(out_speed, minf(out_speed + params.slide_jump_speed_boost, params.bunny_hop_speed_cap))
|
||||
player.velocity.x = dir.x * jump_speed
|
||||
player.velocity.z = dir.z * jump_speed
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.current_jump_count = 1
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.on_ground = false
|
||||
machine.register_chain_mechanic("slide_jump")
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Slide straight onto a wall → wall run (keeps the flow going)
|
||||
if not player.is_on_floor() and machine.wall_cooldown_timer <= 0.0 and out_speed > params.slide_min_speed:
|
||||
var wall_n := machine.detect_wall_horizontal()
|
||||
if wall_n != Vector3.ZERO and abs(machine.wish_dir_world.dot(wall_n)) < 0.85:
|
||||
machine.switch_to("wall_run")
|
||||
return
|
||||
|
||||
# Dash out of slide
|
||||
if machine.input_dash and machine.can_dash():
|
||||
machine.switch_to("dash")
|
||||
return
|
||||
|
||||
# Slide ended: too slow
|
||||
if hspeed < params.slide_min_speed:
|
||||
if out_speed < params.slide_min_speed:
|
||||
machine.switch_to("ground")
|
||||
return
|
||||
|
||||
@@ -113,27 +158,3 @@ func update(delta: float) -> void:
|
||||
if not machine.on_ground and machine.coyote_timer <= 0.0:
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# Jump out of slide
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity.y = params.jump_velocity
|
||||
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
if hvel.length_squared() > 0.01:
|
||||
var dir := hvel.normalized()
|
||||
var current_speed := hvel.length()
|
||||
var jump_speed := maxf(current_speed, minf(current_speed + params.slide_jump_speed_boost, params.bunny_hop_speed_cap))
|
||||
player.velocity.x = dir.x * jump_speed
|
||||
player.velocity.z = dir.z * jump_speed
|
||||
machine.current_jump_count = 1
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.on_ground = false
|
||||
machine.register_chain_mechanic("slide_jump")
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
|
||||
func _get_capsule() -> CapsuleShape3D:
|
||||
for child in machine.player.get_children():
|
||||
if child is CollisionShape3D and child.shape is CapsuleShape3D:
|
||||
return child.shape as CapsuleShape3D
|
||||
return null
|
||||
|
||||
@@ -15,9 +15,9 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if machine.player.wallrun_player and not machine.player.wallrun_player.playing:
|
||||
machine.player.wallrun_player.play()
|
||||
|
||||
# Give an initial upward boost
|
||||
# Carry existing upward momentum into the climb so jump→climb chains flow
|
||||
var player := machine.player
|
||||
player.velocity.y = params.wall_climb_speed
|
||||
player.velocity.y = maxf(player.velocity.y, params.wall_climb_speed)
|
||||
|
||||
var hvel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
player.velocity.x = hvel.x * 0.5
|
||||
@@ -39,7 +39,7 @@ func update(delta: float) -> void:
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Look/Move away to transition to wall run ──────────────────────────────
|
||||
# ── Look/Move away to transition to wall run ──────────────────────────
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var wish_dir := machine.wish_dir_world
|
||||
|
||||
@@ -59,6 +59,18 @@ func update(delta: float) -> void:
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Jump off the wall (backward kick) ─────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
player.velocity = machine.wall_normal * params.wall_run_jump_off_normal
|
||||
player.velocity.y = params.jump_velocity
|
||||
machine.register_chain_mechanic("wall_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
_detach()
|
||||
return
|
||||
|
||||
# ── Ledge Vault Check ─────────────────────────────────────────────────
|
||||
var fwd_wall = machine.detect_wall_forward()
|
||||
if not fwd_wall.hit:
|
||||
@@ -68,12 +80,14 @@ func update(delta: float) -> void:
|
||||
|
||||
if fwd_wall.is_short:
|
||||
# The upper ray missed, meaning we reached the top of the wall!
|
||||
_vault()
|
||||
machine.do_vault()
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Movement ──────────────────────────────────────────────────────────
|
||||
# Slow down over time
|
||||
var speed_factor = maxf(0.0, 1.0 - (elapsed / params.wall_climb_duration))
|
||||
# ── Movement: ease out over the climb ─────────────────────────────────
|
||||
var speed_factor: float = pow(maxf(0.0, 1.0 - (elapsed / params.wall_climb_duration)), 0.7)
|
||||
player.velocity.y = params.wall_climb_speed * speed_factor
|
||||
|
||||
# Push slightly into the wall to maintain contact
|
||||
@@ -95,31 +109,3 @@ func _detach() -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.can_wall_climb = false
|
||||
machine.switch_to("air")
|
||||
|
||||
func _vault() -> void:
|
||||
var player := machine.player
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
|
||||
if h_look.length_squared() > 0.01:
|
||||
h_look = h_look.normalized()
|
||||
|
||||
# Impulse
|
||||
player.velocity = h_look * params.wall_climb_vault_forward
|
||||
player.velocity.y = params.wall_climb_vault_up
|
||||
|
||||
if player.vault_player:
|
||||
player.vault_player.play()
|
||||
|
||||
# Quick camera animation (tilt up and forward bob)
|
||||
var camera = player.camera
|
||||
if camera:
|
||||
var tween = player.create_tween()
|
||||
tween.tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x + 10.0, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", -0.2, 0.15).set_trans(Tween.TRANS_SINE)
|
||||
tween.chain().tween_property(camera, "rotation_degrees:x", camera.rotation_degrees.x, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
tween.parallel().tween_property(camera, "v_offset", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
|
||||
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
|
||||
@@ -13,8 +13,8 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
elapsed = 0.0
|
||||
stamina = params.wall_cling_max_stamina
|
||||
machine.register_chain_mechanic("wall_cling")
|
||||
# Kill most velocity but keep slight downward
|
||||
machine.player.velocity = Vector3.ZERO
|
||||
# Keep a touch of momentum so the stop reads as a grab, not a freeze
|
||||
machine.player.velocity *= 0.15
|
||||
|
||||
|
||||
func exit() -> void:
|
||||
@@ -56,6 +56,9 @@ func update(delta: float) -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.register_chain_mechanic("wall_cling_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if machine.player.jump_player:
|
||||
machine.player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
extends Node
|
||||
class_name StateWallRun
|
||||
|
||||
## Wall run that preserves momentum: on attach we keep speed along the wall
|
||||
## (plus some upward carry) and accelerate toward wall_run_speed instead of
|
||||
## hard-setting velocity, so entering fast stays fast and entering slow ramps
|
||||
## up smoothly. Gravity fades in over the run so the start feels planted.
|
||||
|
||||
var machine: MovementStateMachine
|
||||
var params: MovementParams:
|
||||
get: return machine.params
|
||||
|
||||
var elapsed: float = 0.0
|
||||
var _run_speed: float = 0.0
|
||||
var _last_vel: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
var _current_tangent: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
func enter(_data: Dictionary = {}) -> void:
|
||||
elapsed = 0.0
|
||||
machine.register_chain_mechanic("wall_run")
|
||||
# Cap the upward momentum so they don't fly up the wall,
|
||||
# and prevent negative y momentum so the wall "catches" them.
|
||||
machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, 1.5)
|
||||
machine.on_ground = false
|
||||
_last_vel = machine.player.velocity
|
||||
|
||||
# Keep some upward carry so jumping into a wall run flows; the wall
|
||||
# still "catches" downward momentum.
|
||||
machine.player.velocity.y = clampf(machine.player.velocity.y, 0.0, params.wall_run_entry_max_up)
|
||||
|
||||
if machine.player.wallrun_player and not machine.player.wallrun_player.playing:
|
||||
machine.player.wallrun_player.play()
|
||||
@@ -29,11 +31,6 @@ func enter(_data: Dictionary = {}) -> void:
|
||||
if hvel.dot(_current_tangent) < 0.0:
|
||||
_current_tangent = -_current_tangent
|
||||
|
||||
# Preserve speed along the wall if it's faster than base wall_run_speed
|
||||
var base_speed = machine.get_effective_speed(params.wall_run_speed)
|
||||
var projected_speed = hvel.dot(_current_tangent)
|
||||
_run_speed = maxf(base_speed, projected_speed)
|
||||
|
||||
# Camera tilt
|
||||
var rig = _get_camera_rig()
|
||||
if rig:
|
||||
@@ -53,10 +50,7 @@ func exit() -> void:
|
||||
func update(delta: float) -> void:
|
||||
elapsed += delta
|
||||
if elapsed > params.wall_run_duration:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
_detach(0.4)
|
||||
return
|
||||
|
||||
var player := machine.player
|
||||
@@ -65,42 +59,35 @@ func update(delta: float) -> void:
|
||||
if player.wallrun_player and not player.wallrun_player.playing:
|
||||
player.wallrun_player.play()
|
||||
|
||||
if vel.distance_squared_to(_last_vel) > 100.0:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
# ── Gradual gravity pull (starts light, increases over time) ──────────
|
||||
var gravity_factor := 0.1 + 0.9 * (elapsed / params.wall_run_duration)
|
||||
# ── Gradual gravity pull (starts weightless, ramps up) ────────────────
|
||||
var gravity_factor := 0.05 + 0.95 * pow(elapsed / params.wall_run_duration, 1.5)
|
||||
vel.y -= params.wall_run_gravity * gravity_factor * delta
|
||||
|
||||
# ── Move along wall tangent ───────────────────────────────────────────
|
||||
# ── Accelerate along wall tangent (keeps momentum, no hard set) ───────
|
||||
var wall_tangent := machine.wall_normal.cross(Vector3.UP).normalized()
|
||||
# Ensure the new tangent aligns with our locked forward direction
|
||||
if _current_tangent.dot(wall_tangent) < 0.0:
|
||||
wall_tangent = -wall_tangent
|
||||
_current_tangent = wall_tangent
|
||||
|
||||
vel.x = wall_tangent.x * _run_speed
|
||||
vel.z = wall_tangent.z * _run_speed
|
||||
var hvel := Vector3(vel.x, 0.0, vel.z)
|
||||
var along := hvel.dot(wall_tangent)
|
||||
var target_speed: float = maxf(machine.get_effective_speed(params.wall_run_speed), along)
|
||||
along = move_toward(along, target_speed, params.wall_run_accel * delta)
|
||||
# Redirect all horizontal velocity along the wall (kills the into-wall part)
|
||||
hvel = wall_tangent * along
|
||||
vel.x = hvel.x
|
||||
vel.z = hvel.z
|
||||
|
||||
# ── Look away to break wall run ───────────────────────────────────────
|
||||
var look_dir := -player.global_transform.basis.z
|
||||
if look_dir.dot(machine.wall_normal) > 0.4:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.switch_to("air")
|
||||
_detach(0.3)
|
||||
return
|
||||
|
||||
# ── Push slightly toward wall to maintain contact ─────────────────────
|
||||
vel -= machine.wall_normal * 2.0
|
||||
|
||||
# ── Wall jump ─────────────────────────────────────────────────────────
|
||||
if machine.input_jump_just_pressed and machine.jump_cooldown_timer <= 0.0:
|
||||
# Base jump off velocity on current preserved momentum
|
||||
var jump_vel: Vector3 = _current_tangent * _run_speed
|
||||
var jump_vel: Vector3 = _current_tangent * along
|
||||
|
||||
# Spring off the wall
|
||||
jump_vel += machine.wall_normal * params.wall_run_jump_off_normal
|
||||
@@ -115,6 +102,9 @@ func update(delta: float) -> void:
|
||||
machine.wall_cooldown_timer = 0.3
|
||||
machine.register_chain_mechanic("wall_jump")
|
||||
machine.jump_cooldown_timer = params.jump_cooldown
|
||||
machine.movement_event.emit("jump", {})
|
||||
if player.jump_player:
|
||||
player.jump_player.play()
|
||||
machine.switch_to("air")
|
||||
return
|
||||
|
||||
@@ -123,16 +113,16 @@ func update(delta: float) -> void:
|
||||
machine.switch_to("wall_cling")
|
||||
return
|
||||
|
||||
# ── Push slightly toward wall to maintain contact ─────────────────────
|
||||
vel -= machine.wall_normal * 2.0
|
||||
|
||||
player.velocity = vel
|
||||
player.move_and_slide()
|
||||
_last_vel = player.velocity
|
||||
|
||||
# ── Check still on wall ───────────────────────────────────────────────
|
||||
var still_on_wall := machine.detect_wall_horizontal()
|
||||
if still_on_wall == Vector3.ZERO:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
machine.switch_to("air")
|
||||
_detach(0.0)
|
||||
return
|
||||
|
||||
# ── Hit floor during wall run ─────────────────────────────────────────
|
||||
@@ -144,6 +134,14 @@ func update(delta: float) -> void:
|
||||
machine.switch_to("ground")
|
||||
|
||||
|
||||
func _detach(cooldown: float) -> void:
|
||||
machine.wall_normal = Vector3.ZERO
|
||||
machine.wall_side = 0.0
|
||||
if cooldown > 0.0:
|
||||
machine.wall_cooldown_timer = cooldown
|
||||
machine.switch_to("air")
|
||||
|
||||
|
||||
func _get_camera_rig():
|
||||
if machine.player and machine.player.has_node("HeadPivot"):
|
||||
return machine.player.get_node("HeadPivot")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
extends CharacterBody3D
|
||||
class_name FakeMovementPlayer
|
||||
|
||||
## Minimal stand-in for PlayerMovementController in movement unit tests.
|
||||
## Carries just the properties movement states touch, all inert.
|
||||
|
||||
var jump_player = null
|
||||
var double_jump_player = null
|
||||
var footstep_player = null
|
||||
var dash_player = null
|
||||
var slide_player = null
|
||||
var wallrun_player = null
|
||||
var vault_player = null
|
||||
var wind_player = null
|
||||
var camera = null
|
||||
var head_pivot = null
|
||||
@@ -0,0 +1 @@
|
||||
uid://d07u4xreb41rf
|
||||
@@ -0,0 +1,14 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless entrypoint for the MovementStateMachine unit tests.
|
||||
## Run: godot --headless --path . -s res://movement/tests/run_fsm_tests.gd
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
var tester = load("res://movement/tests/test_fsm_runner.gd").new()
|
||||
root.add_child(tester)
|
||||
tester.run_all() # quits with the right exit code
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhu2es4slyh2i
|
||||
@@ -1,6 +1,12 @@
|
||||
extends Node
|
||||
class_name MovementStateMachineTest
|
||||
|
||||
## Unit tests for MovementStateMachine + states. Runs headless via
|
||||
## movement/tests/run_fsm_tests.gd. Each test gets a fresh machine with the
|
||||
## real state scripts attached, added to the tree so _ready registers states.
|
||||
## Tests exercise transitions and bookkeeping — not physics (no move_and_slide
|
||||
## against a real world here; the spawn smoke test covers integration).
|
||||
|
||||
var sm: MovementStateMachine
|
||||
var fake_player: CharacterBody3D
|
||||
var params: MovementParams
|
||||
@@ -9,32 +15,38 @@ var fail_log := []
|
||||
var tests_passed: int = 0
|
||||
var tests_failed: int = 0
|
||||
|
||||
const STATE_SCRIPTS := {
|
||||
"state_ground": "res://movement/states/state_ground.gd",
|
||||
"state_air": "res://movement/states/state_air.gd",
|
||||
"state_wall_run": "res://movement/states/state_wall_run.gd",
|
||||
"state_wall_cling": "res://movement/states/state_wall_cling.gd",
|
||||
"state_slide": "res://movement/states/state_slide.gd",
|
||||
"state_dash": "res://movement/states/state_dash.gd",
|
||||
}
|
||||
|
||||
|
||||
func run_all() -> void:
|
||||
print("=== MovementStateMachine tests ===")
|
||||
tests_passed = 0
|
||||
tests_failed = 0
|
||||
params = MovementParams.new()
|
||||
fake_player = CharacterBody3D.new()
|
||||
fake_player.set_floor_max_angle(0.01)
|
||||
sm = MovementStateMachine.new()
|
||||
sm.player = fake_player
|
||||
sm.params = params
|
||||
|
||||
var tests := [
|
||||
test_states_initialize_with_ground,
|
||||
test_ground_transitions_to_air_on_jump,
|
||||
test_air_transitions_to_ground_on_land,
|
||||
test_double_jump_allowed_once,
|
||||
test_wall_run_started_when_near_wall,
|
||||
test_chain_bonus_caps_at_50pct,
|
||||
test_sliding_reduces_speed,
|
||||
test_dash_speed_under_effective_cap,
|
||||
"test_states_register_and_initialize",
|
||||
"test_do_jump_sets_velocity_and_counters",
|
||||
"test_wall_run_preserves_fast_entry_speed",
|
||||
"test_wall_run_entry_keeps_some_upward_momentum",
|
||||
"test_dash_cooldown_is_per_machine",
|
||||
"test_dash_has_two_charges_then_recharges",
|
||||
"test_dash_adds_speed_to_forward_momentum",
|
||||
"test_chain_bonus_soft_caps",
|
||||
"test_notify_landed_emits_scaled_event",
|
||||
"test_slide_entry_boosts_and_keeps_direction",
|
||||
"test_grapple_state_registered_by_machine",
|
||||
]
|
||||
for t in tests:
|
||||
_fresh()
|
||||
call(t)
|
||||
var ok := fail_log.is_empty()
|
||||
var ok: bool = fail_log.is_empty()
|
||||
print(" %s: %s" % ["PASS" if ok else "FAIL", t])
|
||||
if ok:
|
||||
tests_passed += 1
|
||||
@@ -43,6 +55,7 @@ func run_all() -> void:
|
||||
for msg in fail_log:
|
||||
print(" ", msg)
|
||||
fail_log.clear()
|
||||
_teardown()
|
||||
|
||||
print("=== Results: %d passed, %d failed ===" % [tests_passed, tests_failed])
|
||||
if tests_failed > 0:
|
||||
@@ -56,24 +69,27 @@ func run_all() -> void:
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func _fresh() -> void:
|
||||
sm.current_state = ""
|
||||
sm.chain_count = 0
|
||||
sm.current_chain_bonus = 0.0
|
||||
sm.chain_timer = 0.0
|
||||
sm.on_ground = false
|
||||
sm.coyote_timer = 0.0
|
||||
sm.jump_buffer_time = 0.0
|
||||
sm.current_jump_count = 0
|
||||
sm.wall_normal = Vector3.ZERO
|
||||
sm.input_dir = Vector2.ZERO
|
||||
sm.input_jump_just_pressed = false
|
||||
sm.input_jump_pressed = false
|
||||
sm.input_sprint = false
|
||||
sm.input_crouch = false
|
||||
sm.input_dash = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
for c in sm.get_children():
|
||||
sm.remove_child(c.queue_free())
|
||||
params = MovementParams.new()
|
||||
fake_player = load("res://movement/tests/fake_player.gd").new()
|
||||
add_child(fake_player)
|
||||
sm = MovementStateMachine.new()
|
||||
sm.player = fake_player
|
||||
sm.params = params
|
||||
for state_name in STATE_SCRIPTS:
|
||||
var st := Node.new()
|
||||
st.name = state_name
|
||||
st.set_script(load(STATE_SCRIPTS[state_name]))
|
||||
sm.add_child(st)
|
||||
fake_player.add_child(sm) # triggers _ready: registers states + injected ones
|
||||
sm.set_physics_process(false) # tests drive the machine manually
|
||||
|
||||
|
||||
func _teardown() -> void:
|
||||
if is_instance_valid(fake_player):
|
||||
remove_child(fake_player)
|
||||
fake_player.free()
|
||||
sm = null
|
||||
fake_player = null
|
||||
|
||||
|
||||
func _expect(cond: bool, msg: String) -> void:
|
||||
@@ -88,108 +104,141 @@ func _eq(a: Variant, b: Variant, msg: String = "") -> void:
|
||||
|
||||
# ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func test_states_initialize_with_ground() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
func test_states_register_and_initialize() -> void:
|
||||
# All six explicit states plus the two the machine injects itself.
|
||||
for expected in ["ground", "air", "wall_run", "wall_cling", "slide", "dash", "grapple", "wall_climb"]:
|
||||
_expect(sm.states.has(expected), "state '%s' should be registered" % expected)
|
||||
_expect(not sm.current_state.is_empty(), "machine should start in a state")
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
|
||||
|
||||
func test_ground_transitions_to_air_on_jump() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.on_ground = true
|
||||
sm.switch_to("air")
|
||||
_eq(sm.current_state, "air")
|
||||
|
||||
|
||||
func test_air_transitions_to_ground_on_land() -> void:
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
func test_do_jump_sets_velocity_and_counters() -> void:
|
||||
sm.switch_to("ground")
|
||||
sm.on_ground = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
sm.switch_to("air")
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
sm.on_ground = true
|
||||
fake_player.velocity = Vector3(5, 0, 0)
|
||||
sm.do_jump()
|
||||
_expect(fake_player.velocity.y == params.jump_velocity, "jump sets vertical velocity")
|
||||
_expect(fake_player.velocity.x == 5.0, "jump keeps horizontal momentum")
|
||||
_eq(sm.current_jump_count, 1, "jump count set")
|
||||
_expect(sm.jump_cooldown_timer > 0.0, "jump cooldown armed")
|
||||
_expect(not sm.on_ground, "no longer grounded")
|
||||
|
||||
|
||||
func test_double_jump_allowed_once() -> void:
|
||||
params.double_jump_max_count = 1
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.on_ground = false
|
||||
sm.current_jump_count = 1
|
||||
_eq(sm.current_jump_count, 1)
|
||||
_expect(
|
||||
sm.current_jump_count < params.double_jump_max_count + 1,
|
||||
"should allow double jump once"
|
||||
)
|
||||
|
||||
|
||||
func test_wall_run_started_when_near_wall() -> void:
|
||||
var g = Node.new()
|
||||
g.name = "state_ground"
|
||||
g.machine = sm
|
||||
sm.add_child(g)
|
||||
var a = Node.new()
|
||||
a.name = "state_air"
|
||||
a.machine = sm
|
||||
sm.add_child(a)
|
||||
sm.wall_normal = Vector3.RIGHT.normalized()
|
||||
func test_wall_run_preserves_fast_entry_speed() -> void:
|
||||
# Enter a wall run at 20 m/s along the wall; speed must not drop to base.
|
||||
sm.wall_normal = Vector3.RIGHT
|
||||
sm.wall_side = -1.0
|
||||
fake_player.velocity = Vector3(0, 0, -20) # along wall tangent
|
||||
sm.switch_to("wall_run")
|
||||
_eq(sm.current_state, "wall_run")
|
||||
var hspeed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(hspeed >= 19.9, "wall run must keep entry speed (got %.1f)" % hspeed)
|
||||
|
||||
|
||||
func test_chain_bonus_caps_at_50pct() -> void:
|
||||
func test_wall_run_entry_keeps_some_upward_momentum() -> void:
|
||||
sm.wall_normal = Vector3.RIGHT
|
||||
fake_player.velocity = Vector3(0, 20.0, -10)
|
||||
sm.switch_to("wall_run")
|
||||
_expect(fake_player.velocity.y > 0.0, "some upward carry should remain")
|
||||
_expect(fake_player.velocity.y <= params.wall_run_entry_max_up + 0.001,
|
||||
"upward carry capped at wall_run_entry_max_up")
|
||||
|
||||
|
||||
func test_dash_cooldown_is_per_machine() -> void:
|
||||
fake_player.velocity = Vector3(10, 0, 0)
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
sm.switch_to("dash")
|
||||
_expect(sm.get_dash_charges() == params.dash_charges - 1, "dash spends one charge")
|
||||
_expect(sm.dash_recharge_timer > 0.0, "dash arms this machine's recharge clock")
|
||||
|
||||
# A second, independent machine must NOT share that state (the old
|
||||
# implementation used a static var shared across all players).
|
||||
var player2 = load("res://movement/tests/fake_player.gd").new()
|
||||
add_child(player2)
|
||||
var sm2 := MovementStateMachine.new()
|
||||
sm2.player = player2
|
||||
sm2.params = MovementParams.new()
|
||||
player2.add_child(sm2)
|
||||
_expect(sm2.get_dash_charges() == sm2.params.dash_charges, "second player keeps full charges")
|
||||
remove_child(player2)
|
||||
player2.free()
|
||||
|
||||
|
||||
func test_dash_has_two_charges_then_recharges() -> void:
|
||||
params.dash_charges = 2
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
fake_player.velocity = Vector3(10, 0, 0)
|
||||
|
||||
_expect(sm.can_dash(), "first dash available")
|
||||
sm.switch_to("dash")
|
||||
sm.switch_to("air")
|
||||
_expect(sm.can_dash(), "second charge still banked — no cooldown wait")
|
||||
_eq(sm.get_dash_cooldown_remaining(), 0.0, "cooldown reads 0 while a charge is banked")
|
||||
sm.switch_to("dash")
|
||||
sm.switch_to("air")
|
||||
_expect(not sm.can_dash(), "both charges spent → dash blocked")
|
||||
_expect(sm.get_dash_cooldown_remaining() > 0.0, "cooldown now counts to next charge")
|
||||
|
||||
# Simulate the recharge tick the machine runs each physics frame.
|
||||
# Blank the state so only machine bookkeeping runs (no state update /
|
||||
# move_and_slide outside a real physics frame).
|
||||
sm.current_state = ""
|
||||
sm.dash_recharge_timer = 0.0001
|
||||
sm._physics_process(0.016)
|
||||
_expect(sm.can_dash(), "one charge regained after recharge timer elapses")
|
||||
_expect(sm.dash_recharge_timer > 0.0, "recharge continues toward the second charge")
|
||||
|
||||
|
||||
func test_dash_adds_speed_to_forward_momentum() -> void:
|
||||
fake_player.velocity = Vector3(12, 0, 0)
|
||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||
sm.switch_to("dash")
|
||||
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(speed >= 12.0 + params.dash_speed - 0.01,
|
||||
"forward dash should add dash_speed on top of momentum (got %.1f)" % speed)
|
||||
|
||||
|
||||
func test_chain_bonus_soft_caps() -> void:
|
||||
params.chain_bonus_per_success = 0.05
|
||||
params.chain_bonus_cap = 0.50
|
||||
sm.register_chain_mechanic("jump")
|
||||
sm.register_chain_mechanic("slide")
|
||||
sm.register_chain_mechanic("wall_run")
|
||||
_expect(sm.chain_count == 3, "chain_count 3 after 3 mechanics")
|
||||
_expect(
|
||||
sm.current_chain_bonus <= params.chain_bonus_cap + 0.001,
|
||||
"bonus should be bounded by cap"
|
||||
)
|
||||
params.chain_bonus_cap = 0.25
|
||||
for i in range(12):
|
||||
sm.register_chain_mechanic("jump")
|
||||
_eq(sm.chain_count, 12, "chain count accumulates")
|
||||
_expect(sm.current_chain_bonus <= params.chain_bonus_cap + 0.35,
|
||||
"bonus stays near cap (soft cap)")
|
||||
_expect(sm.current_chain_bonus >= params.chain_bonus_cap,
|
||||
"soft cap still rewards long chains slightly")
|
||||
|
||||
|
||||
func test_sliding_reduces_speed() -> void:
|
||||
params.slide_speed = 14.0
|
||||
params.slide_min_speed = 12.0
|
||||
_expect(params.slide_min_speed < params.slide_speed, "slide_min_speed must be below slide_speed")
|
||||
func test_notify_landed_emits_scaled_event() -> void:
|
||||
var events := []
|
||||
sm.movement_event.connect(func(ev, data): events.append([ev, data]))
|
||||
sm.notify_landed(2.0) # soft: below land_soft_speed → no event
|
||||
_expect(events.is_empty(), "gentle landings emit no event")
|
||||
sm.notify_landed(params.land_heavy_speed + 1.0)
|
||||
_expect(events.size() == 1 and events[0][0] == "land", "heavy landing emits land event")
|
||||
if events.size() == 1:
|
||||
_expect(events[0][1].get("heavy", false), "heavy flag set above land_heavy_speed")
|
||||
_expect(sm.current_jump_count == 0, "landing resets jump count")
|
||||
|
||||
|
||||
func test_dash_speed_under_effective_cap() -> void:
|
||||
var d = Node.new()
|
||||
d.name = "state_dash"
|
||||
d.machine = sm
|
||||
sm.add_child(d)
|
||||
sm.on_ground = false
|
||||
var eff := sm.get_effective_speed(params.dash_speed)
|
||||
_expect(
|
||||
eff <= params.dash_speed * (1.0 + params.chain_bonus_cap),
|
||||
"dash effective speed should respect chain cap"
|
||||
)
|
||||
func test_slide_entry_boosts_and_keeps_direction() -> void:
|
||||
sm.switch_to("ground")
|
||||
fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed
|
||||
# Slide entry boost only applies when is_on_floor(); fake player isn't on a
|
||||
# real floor, so just verify direction is kept and speed never decreases.
|
||||
sm.switch_to("slide")
|
||||
_expect(fake_player.velocity.z < 0.0, "slide keeps travel direction")
|
||||
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
||||
_expect(speed >= 12.0 - 0.01, "slide entry must not lose speed (got %.1f)" % speed)
|
||||
_expect(sm.states.has("slide"), "slide state present")
|
||||
|
||||
|
||||
func test_rocket_jump_impulse_sets_upward_velocity() -> void:
|
||||
params.rocket_jump_up_impulse = 20.0
|
||||
_expect(params.rocket_jump_up_impulse > 0.0, "rocket jump must have positive upward impulse")
|
||||
func test_grapple_state_registered_by_machine() -> void:
|
||||
_expect(sm.states.has("grapple"), "machine injects grapple state")
|
||||
_expect(sm.states.has("wall_climb"), "machine injects wall_climb state")
|
||||
sm.grapple_length = 10.0
|
||||
sm.switch_to("grapple")
|
||||
_eq(sm.current_state, "grapple")
|
||||
|
||||
@@ -12,7 +12,7 @@ config_version=5
|
||||
|
||||
config/name="Papaya-Shooter"
|
||||
run/main_scene="res://ui/main_menu/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
@@ -21,6 +21,8 @@ SettingsManager="*res://globals/settings_manager.gd"
|
||||
LoadoutManager="*res://globals/loadout_manager.gd"
|
||||
PauseMenu="*res://ui/pause_menu.gd"
|
||||
NetworkManager="*res://globals/network_manager.gd"
|
||||
SkinManager="*res://characters/skin_manager.gd"
|
||||
AudioManager="*res://globals/audio_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -106,6 +108,16 @@ scoreboard={
|
||||
"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":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
toggle_camera_view={
|
||||
"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":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
emote={
|
||||
"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":66,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Python dependencies for tools/ (sketchfab_import.py, pipeline.py).
|
||||
#
|
||||
# Currently empty: these scripts only use the standard library
|
||||
# (urllib, json, zipfile, argparse, subprocess). No third-party
|
||||
# packages to install — this file exists so `pip install -r
|
||||
# requirements.txt` works out of the box in a fresh venv.
|
||||
#
|
||||
# NOT covered here: tools/autorig.py and tools/merge_animations.py run
|
||||
# inside Blender's own bundled Python via `blender --background
|
||||
# --python ...`, not this venv. Their `bpy` / `mathutils` imports come
|
||||
# from Blender itself and cannot be pip-installed.
|
||||
@@ -0,0 +1,84 @@
|
||||
extends Object
|
||||
class_name LevelEnvironment
|
||||
|
||||
## Shared stylized environment for every level: saturated anime-sky gradient,
|
||||
## bloom for emissives (tracers, plasma), and a color grade that pushes the
|
||||
## cel-shaded look (high saturation, slight contrast lift). Builders call
|
||||
## add_to(level) instead of hand-rolling WorldEnvironment + sun.
|
||||
|
||||
|
||||
## Creates and adds a WorldEnvironment + key sun + cool fill light.
|
||||
## Returns the WorldEnvironment node.
|
||||
static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment:
|
||||
var env := WorldEnvironment.new()
|
||||
env.name = "WorldEnvironment"
|
||||
env.environment = make_environment(sky_variant)
|
||||
level.add_child(env)
|
||||
|
||||
if not level.has_node("Sun"):
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.name = "Sun"
|
||||
sun.rotation_degrees = Vector3(-52, 38, 0)
|
||||
sun.light_color = Color(1.0, 0.97, 0.88)
|
||||
sun.light_energy = 1.4
|
||||
sun.shadow_enabled = true
|
||||
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
|
||||
sun.directional_shadow_max_distance = 120.0
|
||||
level.add_child(sun)
|
||||
|
||||
var fill := DirectionalLight3D.new()
|
||||
fill.name = "FillLight"
|
||||
fill.rotation_degrees = Vector3(-30, -142, 0)
|
||||
fill.light_color = Color(0.6, 0.7, 1.0)
|
||||
fill.light_energy = 0.25
|
||||
fill.shadow_enabled = false
|
||||
level.add_child(fill)
|
||||
return env
|
||||
|
||||
|
||||
static func make_environment(sky_variant: String = "day") -> Environment:
|
||||
var environment := Environment.new()
|
||||
environment.background_mode = Environment.BG_SKY
|
||||
var sky := Sky.new()
|
||||
var sky_mat := ProceduralSkyMaterial.new()
|
||||
match sky_variant:
|
||||
"sunset":
|
||||
sky_mat.sky_top_color = Color(0.25, 0.2, 0.5)
|
||||
sky_mat.sky_horizon_color = Color(0.95, 0.6, 0.45)
|
||||
sky_mat.ground_bottom_color = Color(0.18, 0.12, 0.2)
|
||||
sky_mat.ground_horizon_color = Color(0.85, 0.55, 0.45)
|
||||
sky_mat.sun_curve = 0.12
|
||||
_:
|
||||
# Bold anime day sky: deep saturated blue up top, bright cyan horizon.
|
||||
sky_mat.sky_top_color = Color(0.18, 0.4, 0.85)
|
||||
sky_mat.sky_horizon_color = Color(0.72, 0.88, 0.98)
|
||||
sky_mat.ground_bottom_color = Color(0.22, 0.2, 0.24)
|
||||
sky_mat.ground_horizon_color = Color(0.62, 0.7, 0.75)
|
||||
sky_mat.sun_curve = 0.1
|
||||
sky_mat.sun_angle_max = 20.0
|
||||
sky.sky_material = sky_mat
|
||||
environment.sky = sky
|
||||
|
||||
# Flat-ish ambient keeps toon shadow bands readable (sun does the shaping).
|
||||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
environment.ambient_light_energy = 0.55
|
||||
|
||||
# Filmic crushes the cel bands; linear-ish keeps them crisp.
|
||||
environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||
|
||||
# Bloom sells emissives (tracers, plasma, rim highlights).
|
||||
environment.glow_enabled = true
|
||||
environment.glow_intensity = 0.5
|
||||
environment.glow_bloom = 0.05
|
||||
environment.glow_hdr_threshold = 1.1
|
||||
|
||||
# Cel color grade: punchy saturation, hint of contrast.
|
||||
environment.adjustment_enabled = true
|
||||
environment.adjustment_saturation = 1.22
|
||||
environment.adjustment_contrast = 1.05
|
||||
|
||||
# A touch of depth haze for scale; far enough to not gray the arena.
|
||||
environment.fog_enabled = true
|
||||
environment.fog_light_color = Color(0.65, 0.75, 0.9)
|
||||
environment.fog_density = 0.0012
|
||||
return environment
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpl4d7ndkc4ft
|
||||
@@ -0,0 +1,93 @@
|
||||
extends Object
|
||||
class_name LevelMaterials
|
||||
|
||||
## Shared cel-shaded materials for code-built levels and characters.
|
||||
##
|
||||
## Level geometry gets the toon shader with world-space triplanar grid
|
||||
## (0.5 m cells) tinted per surface; characters get the same toon shading
|
||||
## over their own textures via convert_to_toon(), plus an inverted-hull
|
||||
## outline overlay for the inked silhouette.
|
||||
|
||||
const GRID_GRAY := "res://assets/textures/prototype/grid_gray.png"
|
||||
const GRID_DARK := "res://assets/textures/prototype/grid_dark.png"
|
||||
const TOON_SHADER := "res://assets/shaders/toon.gdshader"
|
||||
const OUTLINE_SHADER := "res://assets/shaders/toon_outline.gdshader"
|
||||
|
||||
## One texture tile = 2 m of world, so one grid cell = 0.5 m.
|
||||
const WORLD_UNITS_PER_TILE := 2.0
|
||||
|
||||
static var _cache: Dictionary = {}
|
||||
static var _outline_cache: Dictionary = {}
|
||||
|
||||
|
||||
## A tinted toon grid material for level geometry. Cached per (tint, dark) so
|
||||
## identical surfaces share one material.
|
||||
static func tinted(tint: Color, dark: bool = false) -> Material:
|
||||
var key := "%s|%s" % [tint.to_html(), dark]
|
||||
if _cache.has(key):
|
||||
return _cache[key]
|
||||
var shader: Shader = load(TOON_SHADER)
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = shader
|
||||
var tex_path := GRID_DARK if dark else GRID_GRAY
|
||||
var tex: Texture2D = load(tex_path) if ResourceLoader.exists(tex_path) else null
|
||||
if tex:
|
||||
mat.set_shader_parameter("albedo_texture", tex)
|
||||
mat.set_shader_parameter("has_texture", true)
|
||||
mat.set_shader_parameter("use_triplanar", true)
|
||||
mat.set_shader_parameter("triplanar_tile", WORLD_UNITS_PER_TILE)
|
||||
else:
|
||||
mat.set_shader_parameter("has_texture", false)
|
||||
# The texture is grayscale ~mid value; multiply by ~2x-brightened tint to
|
||||
# land near the original flat color while keeping the grid contrast.
|
||||
mat.set_shader_parameter("albedo_color", Color(
|
||||
minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0)))
|
||||
_cache[key] = mat
|
||||
return mat
|
||||
|
||||
|
||||
## Toon version of an arbitrary material (usually a character's imported
|
||||
## StandardMaterial3D): keeps its albedo texture/color, swaps the shading.
|
||||
static func toonify(src: Material) -> Material:
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = load(TOON_SHADER)
|
||||
var tex: Texture2D = null
|
||||
var col := Color.WHITE
|
||||
if src is BaseMaterial3D:
|
||||
tex = src.albedo_texture
|
||||
col = src.albedo_color
|
||||
mat.set_shader_parameter("albedo_texture", tex)
|
||||
mat.set_shader_parameter("has_texture", tex != null)
|
||||
mat.set_shader_parameter("use_triplanar", false)
|
||||
mat.set_shader_parameter("albedo_color", col)
|
||||
# Characters read best with a slightly wider lit band and stronger rim.
|
||||
mat.set_shader_parameter("rim_strength", 0.45)
|
||||
return mat
|
||||
|
||||
|
||||
## Swap every mesh surface under `node` to toon shading and add an
|
||||
## inverted-hull outline overlay. Safe on skinned meshes (material_overlay
|
||||
## re-renders the same deformed mesh).
|
||||
static func apply_toon_recursive(node: Node, outline_width: float = 0.015) -> void:
|
||||
if node is MeshInstance3D:
|
||||
var mi := node as MeshInstance3D
|
||||
var surface_count: int = mi.mesh.get_surface_count() if mi.mesh else 0
|
||||
for s in range(surface_count):
|
||||
var src := mi.get_active_material(s)
|
||||
if src and not (src is ShaderMaterial):
|
||||
mi.set_surface_override_material(s, toonify(src))
|
||||
if outline_width > 0.0:
|
||||
mi.material_overlay = outline(outline_width)
|
||||
for child in node.get_children():
|
||||
apply_toon_recursive(child, outline_width)
|
||||
|
||||
|
||||
static func outline(width: float = 0.015) -> ShaderMaterial:
|
||||
var key := "%.4f" % width
|
||||
if _outline_cache.has(key):
|
||||
return _outline_cache[key]
|
||||
var mat := ShaderMaterial.new()
|
||||
mat.shader = load(OUTLINE_SHADER)
|
||||
mat.set_shader_parameter("outline_width", width)
|
||||
_outline_cache[key] = mat
|
||||
return mat
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1v4lq35py3gd
|
||||
@@ -92,30 +92,37 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
# Dynamic Spawning
|
||||
player.position = _get_dynamic_spawn_position()
|
||||
|
||||
# Server Synchronizer (Host is the ground truth)
|
||||
# Server Synchronizer — host-owned gameplay state (health, kills, death)
|
||||
var server_sync = MultiplayerSynchronizer.new()
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
# Client Synchronizer (Client dictates their aim and loadout setup)
|
||||
# Client Synchronizer — owner-simulated movement, aim, animation state.
|
||||
# Movement is client-authoritative for instant response; remote peers
|
||||
# interpolate synced_position/velocity (see PlayerMovementController).
|
||||
var client_sync = MultiplayerSynchronizer.new()
|
||||
client_sync.name = "MultiplayerSynchronizer"
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -176,10 +176,7 @@ func _create_box(node_name: String, pos: Vector3, size: Vector3, color: Color) -
|
||||
mesh.name = "MeshInstance3D"
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
|
||||
add_child(body)
|
||||
@@ -206,10 +203,7 @@ func _create_ramp(node_name: String, pos: Vector3, size: Vector3, rot_deg: Vecto
|
||||
mesh.name = "MeshInstance3D"
|
||||
mesh.mesh = BoxMesh.new()
|
||||
mesh.mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = 0.8
|
||||
mesh.mesh.surface_set_material(0, mat)
|
||||
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
||||
body.add_child(mesh)
|
||||
|
||||
add_child(body)
|
||||
|
||||
@@ -19,6 +19,16 @@ func _ready() -> void:
|
||||
# Hide mouse
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
# Cel-shaded presentation: stylized sky/grade + toon materials over the
|
||||
# arena's baked geometry. The scene ships its own DirectionalLight3D;
|
||||
# rename it to Sun so the helper doesn't stack extra suns.
|
||||
var existing_light := get_node_or_null("DirectionalLight3D")
|
||||
if existing_light:
|
||||
existing_light.name = "Sun"
|
||||
if not has_node("WorldEnvironment"):
|
||||
LevelEnvironment.add_to(self)
|
||||
LevelMaterials.apply_toon_recursive(self, 0.0)
|
||||
|
||||
_build_hud()
|
||||
|
||||
# Multiplayer Spawning
|
||||
@@ -63,15 +73,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
@@ -80,8 +84,19 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_sync.name = "MultiplayerSynchronizer"
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[gd_scene format=3 uid="uid://pp1g1inidixa"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c7ltcn37gfd71" path="res://debug/test_level_builder.gd" id="1_2venv"]
|
||||
|
||||
[node name="TestLevel" type="Node3D" unique_id=130984349]
|
||||
script = ExtResource("1_2venv")
|
||||
@@ -0,0 +1,136 @@
|
||||
import bpy
|
||||
import math
|
||||
|
||||
# Clear scene
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Import the existing Miku GLB
|
||||
input_path = "/home/ai-agent/Papay-Shooter/assets/characters/skins/miku_rigged_animated.glb"
|
||||
output_path = "/home/ai-agent/Papay-Shooter/assets/characters/skins/miku_proper_anim.glb"
|
||||
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
print("Imported GLB")
|
||||
|
||||
# Find the armature
|
||||
armatures = [obj for obj in bpy.data.objects if obj.type == 'ARMATURE']
|
||||
if not armatures:
|
||||
print("ERROR: No armature found!")
|
||||
exit(1)
|
||||
|
||||
armature = armatures[0]
|
||||
print(f"Armature: {armature.name}, bones: {len(armature.data.bones)}")
|
||||
|
||||
# Switch to pose mode
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
def create_anim_dense(name, bone_rotations_func, length_frames=60):
|
||||
action = bpy.data.actions.new(name=name)
|
||||
armature.animation_data.action = action
|
||||
action.use_fake_user = True
|
||||
|
||||
for frame in range(1, length_frames + 1):
|
||||
bpy.context.scene.frame_set(frame)
|
||||
rotations = bone_rotations_func(frame, length_frames)
|
||||
for bone_name, euler in rotations.items():
|
||||
if bone_name in armature.pose.bones:
|
||||
bone = armature.pose.bones[bone_name]
|
||||
bone.rotation_mode = 'XYZ'
|
||||
bone.rotation_euler = euler
|
||||
bone.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
return action
|
||||
|
||||
# WALK: Per-frame keyframes (60 frames, 1 second)
|
||||
def walk_fn(frame, length):
|
||||
t = frame / length * math.pi * 2
|
||||
return {
|
||||
"LeftUpperLeg": (math.sin(t) * 1.0, 0, 0),
|
||||
"RightUpperLeg": (math.sin(t + math.pi) * 1.0, 0, 0),
|
||||
"LeftUpperArm": (math.sin(t + math.pi) * 0.7, 0, 0),
|
||||
"RightUpperArm": (math.sin(t) * 0.7, 0, 0),
|
||||
"Spine": (0, math.sin(t) * 0.2, 0),
|
||||
}
|
||||
|
||||
create_anim_dense("Walk", walk_fn, 60)
|
||||
print("Walk created")
|
||||
|
||||
# RUN: Faster (40 frames)
|
||||
def run_fn(frame, length):
|
||||
t = frame / length * math.pi * 2
|
||||
return {
|
||||
"LeftUpperLeg": (math.sin(t) * 1.3, 0, 0),
|
||||
"RightUpperLeg": (math.sin(t + math.pi) * 1.3, 0, 0),
|
||||
"LeftUpperArm": (math.sin(t + math.pi) * 1.0, 0, 0),
|
||||
"RightUpperArm": (math.sin(t) * 1.0, 0, 0),
|
||||
"Spine": (0, math.sin(t) * 0.3, 0),
|
||||
}
|
||||
|
||||
create_anim_dense("Run", run_fn, 40)
|
||||
print("Run created")
|
||||
|
||||
# JUMP: Single jump cycle (40 frames)
|
||||
def jump_fn(frame, length):
|
||||
t = frame / length
|
||||
peak = max(0.0, 1.0 - abs(t - 0.5) * 3.0)
|
||||
return {
|
||||
"LeftUpperArm": (-2.5 * peak, 0, -0.3 * peak),
|
||||
"RightUpperArm": (-2.5 * peak, 0, 0.3 * peak),
|
||||
"LeftUpperLeg": (-0.5 * peak, 0, 0),
|
||||
"RightUpperLeg": (-0.5 * peak, 0, 0),
|
||||
"Spine": (0.15 * peak, 0, 0),
|
||||
"Hips": (0, 0.3 * peak, 0),
|
||||
}
|
||||
|
||||
create_anim_dense("Jump", jump_fn, 40)
|
||||
print("Jump created")
|
||||
|
||||
# IDLE: Gentle breathing (60 frames)
|
||||
def idle_fn(frame, length):
|
||||
t = frame / length * math.pi * 2
|
||||
return {
|
||||
"Spine": (0, math.sin(t) * 0.5, 0),
|
||||
"Head": (math.sin(t * 0.7) * 0.15, math.sin(t * 0.5) * 0.1, 0),
|
||||
"LeftUpperArm": (math.sin(t) * 0.2, 0, math.sin(t * 0.5) * 0.5),
|
||||
"RightUpperArm": (math.sin(t) * 0.2, 0, -math.sin(t * 0.5) * 0.5),
|
||||
"Hips": (0, math.sin(t) * 0.15, 0),
|
||||
}
|
||||
|
||||
create_anim_dense("Idle", idle_fn, 60)
|
||||
print("Idle created")
|
||||
|
||||
# Remove original stripped actions, keep only our custom ones
|
||||
for action in list(bpy.data.actions):
|
||||
if action.use_fake_user:
|
||||
# Rename Blender's auto-suffixed names
|
||||
if action.name == "Walk.001":
|
||||
action.name = "Walk"
|
||||
elif action.name == "Run.001":
|
||||
action.name = "Run"
|
||||
elif action.name == "Jump.001":
|
||||
action.name = "Jump"
|
||||
elif action.name == "Idle.002":
|
||||
action.name = "Idle"
|
||||
else:
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
print(f"Final actions: {[a.name for a in bpy.data.actions if a.use_fake_user]}")
|
||||
|
||||
# Export
|
||||
bpy.context.scene.render.fps = 60
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_animation_mode='ACTIONS',
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
export_optimize_animation_size=False,
|
||||
)
|
||||
|
||||
print(f"\nExported to {output_path}")
|
||||
print("Done!")
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add animations to an already-rigged Miku GLB.
|
||||
Keyframes ALL bones for each animation to ensure proper track export.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
|
||||
argv = sys.argv
|
||||
if '--' in argv:
|
||||
argv = argv[argv.index('--') + 1:]
|
||||
|
||||
input_path = argv[0] if len(argv) > 0 else ''
|
||||
output_path = argv[1] if len(argv) > 1 else ''
|
||||
|
||||
if not input_path or not output_path:
|
||||
sys.exit(1)
|
||||
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
|
||||
print(f"Importing {input_path}...")
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
armatures = [o for o in bpy.data.objects if o.type == 'ARMATURE']
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH' and o.name != 'Icosphere']
|
||||
|
||||
if not armatures:
|
||||
print("ERROR: No armature")
|
||||
sys.exit(1)
|
||||
|
||||
arm = armatures[0]
|
||||
print(f"Armature: {arm.name} ({len(arm.data.bones)} bones)")
|
||||
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
bone_names = [b.name for b in arm.data.bones if b.name != "neutral_bone"]
|
||||
print(f"Bones: {bone_names}")
|
||||
|
||||
def create_animation(name, frame_end, keyframes_func):
|
||||
"""Create an animation. keyframes_func(frame, bone_name) -> (loc, rot) or None."""
|
||||
print(f" Creating {name}...")
|
||||
action = bpy.data.actions.new(name=name)
|
||||
arm.animation_data.action = action
|
||||
action.frame_range = (1, frame_end)
|
||||
|
||||
# Keyframe ALL bones at every frame to ensure tracks are exported
|
||||
for frame in range(1, frame_end + 1):
|
||||
for bone_name in bone_names:
|
||||
bone = arm.pose.bones[bone_name]
|
||||
result = keyframes_func(frame, bone_name)
|
||||
if result is None:
|
||||
# Default: no movement
|
||||
bone.location = (0, 0, 0)
|
||||
bone.rotation_euler = (0, 0, 0)
|
||||
else:
|
||||
loc, rot = result
|
||||
bone.location = loc if loc else (0, 0, 0)
|
||||
bone.rotation_mode = 'XYZ'
|
||||
bone.rotation_euler = rot if rot else (0, 0, 0)
|
||||
|
||||
bone.keyframe_insert(data_path="location", frame=frame)
|
||||
bone.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
print(f" {name}: {frame_end} frames, {len(bone_names)} bones")
|
||||
|
||||
# Idle: visible swaying (large enough to not be optimized away)
|
||||
def idle_kf(frame, bone_name):
|
||||
t = (frame - 1) / 30.0
|
||||
phase = t * 2 * math.pi
|
||||
if bone_name == "Spine":
|
||||
return None, (math.sin(phase) * 0.5, 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (0, 0, 0.5 + math.sin(phase) * 0.3)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (0, 0, -0.5 - math.sin(phase) * 0.3)
|
||||
elif bone_name == "Neck":
|
||||
return None, (math.sin(phase) * 0.3, 0, 0)
|
||||
elif bone_name == "LeftLowerArm":
|
||||
return None, (-0.5, 0, 0)
|
||||
elif bone_name == "RightLowerArm":
|
||||
return None, (-0.5, 0, 0)
|
||||
return None, None
|
||||
|
||||
create_animation("Idle", 30, idle_kf)
|
||||
|
||||
# Walk
|
||||
def walk_kf(frame, bone_name):
|
||||
t = (frame - 1) / 24.0
|
||||
phase = t * 2 * math.pi
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, abs(math.sin(phase * 2)) * 0.02), None
|
||||
elif bone_name == "LeftUpperLeg":
|
||||
return None, (math.sin(phase) * 0.5, 0, 0)
|
||||
elif bone_name == "LeftLowerLeg":
|
||||
return None, (max(0, -math.sin(phase) * 0.4 + 0.3), 0, 0)
|
||||
elif bone_name == "RightUpperLeg":
|
||||
return None, (math.sin(phase + math.pi) * 0.5, 0, 0)
|
||||
elif bone_name == "RightLowerLeg":
|
||||
return None, (max(0, -math.sin(phase + math.pi) * 0.4 + 0.3), 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (-math.sin(phase) * 0.4, 0, 0.1)
|
||||
elif bone_name == "LeftLowerArm":
|
||||
return None, (-0.5 + max(0, math.sin(phase) * 0.3), 0, 0)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-math.sin(phase + math.pi) * 0.4, 0, -0.1)
|
||||
elif bone_name == "RightLowerArm":
|
||||
return None, (-0.5 + max(0, math.sin(phase + math.pi) * 0.3), 0, 0)
|
||||
elif bone_name == "Spine":
|
||||
return None, (0, math.sin(phase) * 0.08, 0)
|
||||
return None, None
|
||||
|
||||
create_animation("Walk", 24, walk_kf)
|
||||
|
||||
# Run
|
||||
def run_kf(frame, bone_name):
|
||||
t = (frame - 1) / 20.0
|
||||
phase = t * 2 * math.pi
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, abs(math.sin(phase * 2)) * 0.04), None
|
||||
elif bone_name == "LeftUpperLeg":
|
||||
return None, (math.sin(phase) * 0.9, 0, 0)
|
||||
elif bone_name == "LeftLowerLeg":
|
||||
return None, (max(0.1, -math.sin(phase) * 0.7 + 0.4), 0, 0)
|
||||
elif bone_name == "RightUpperLeg":
|
||||
return None, (math.sin(phase + math.pi) * 0.9, 0, 0)
|
||||
elif bone_name == "RightLowerLeg":
|
||||
return None, (max(0.1, -math.sin(phase + math.pi) * 0.7 + 0.4), 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (-math.sin(phase) * 0.8, 0, 0.15)
|
||||
elif bone_name == "LeftLowerArm":
|
||||
return None, (-1.2 + max(0, math.sin(phase) * 0.4), 0, 0)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-math.sin(phase + math.pi) * 0.8, 0, -0.15)
|
||||
elif bone_name == "RightLowerArm":
|
||||
return None, (-1.2 + max(0, math.sin(phase + math.pi) * 0.4), 0, 0)
|
||||
elif bone_name == "Spine":
|
||||
return None, (0.15, math.sin(phase) * 0.1, 0)
|
||||
return None, None
|
||||
|
||||
create_animation("Run", 20, run_kf)
|
||||
|
||||
# Jump
|
||||
def jump_kf(frame, bone_name):
|
||||
if frame <= 5:
|
||||
t = (frame - 1) / 4.0
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -t * 0.3), None
|
||||
elif bone_name in ("LeftUpperLeg", "RightUpperLeg"):
|
||||
return None, (-t * 0.7, 0, 0)
|
||||
elif bone_name in ("LeftLowerLeg", "RightLowerLeg"):
|
||||
return None, (t * 1.2, 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (0, 0, -t * 0.8)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (0, 0, t * 0.8)
|
||||
elif frame <= 8:
|
||||
t = (frame - 5) / 2.0
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -0.3 + t * 0.3), None
|
||||
elif bone_name in ("LeftUpperLeg", "RightUpperLeg"):
|
||||
return None, (-0.7 + t * 0.7, 0, 0)
|
||||
elif bone_name in ("LeftLowerLeg", "RightLowerLeg"):
|
||||
return None, (1.2 - t * 0.4, 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (-t * 2.5, 0, -0.8 - t)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-t * 2.5, 0, 0.8 + t)
|
||||
elif frame <= 15:
|
||||
if bone_name == "LeftUpperArm":
|
||||
return None, (-2.5, 0, -1.8)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-2.5, 0, 1.8)
|
||||
elif bone_name in ("LeftLowerArm", "RightLowerArm"):
|
||||
return None, (-0.3, 0, 0)
|
||||
elif bone_name == "LeftUpperLeg":
|
||||
return None, (0.3, 0, 0)
|
||||
elif bone_name == "LeftLowerLeg":
|
||||
return None, (0.6, 0, 0)
|
||||
elif bone_name == "RightUpperLeg":
|
||||
return None, (0.2, 0, 0)
|
||||
elif bone_name == "RightLowerLeg":
|
||||
return None, (0.4, 0, 0)
|
||||
else:
|
||||
t = (frame - 15) / 4.0
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -t * 0.3), None
|
||||
elif bone_name in ("LeftUpperLeg", "RightUpperLeg"):
|
||||
return None, (-t * 0.6, 0, 0)
|
||||
elif bone_name in ("LeftLowerLeg", "RightLowerLeg"):
|
||||
return None, (t * 1.0, 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (-2.5 + t * 2.5, 0, -1.8 + t * 1.8)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-2.5 + t * 2.5, 0, 1.8 - t * 1.8)
|
||||
return None, None
|
||||
|
||||
create_animation("Jump", 20, jump_kf)
|
||||
|
||||
# Crouch
|
||||
def crouch_kf(frame, bone_name):
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -0.5), None
|
||||
elif bone_name == "Spine":
|
||||
return None, (0.4, 0, 0)
|
||||
elif bone_name in ("LeftUpperLeg", "RightUpperLeg"):
|
||||
return None, (-0.9, 0, 0)
|
||||
elif bone_name in ("LeftLowerLeg", "RightLowerLeg"):
|
||||
return None, (1.5, 0, 0)
|
||||
elif bone_name == "LeftUpperArm":
|
||||
return None, (-0.6, 0, 0.3)
|
||||
elif bone_name == "RightUpperArm":
|
||||
return None, (-0.6, 0, -0.3)
|
||||
return None, None
|
||||
|
||||
create_animation("Crouch", 1, crouch_kf)
|
||||
|
||||
# Death
|
||||
def death_kf(frame, bone_name):
|
||||
if frame <= 14:
|
||||
t = (frame - 1) / 13.0
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -t * 0.6), (-t * 1.3, 0, t * 0.3)
|
||||
elif bone_name == "Spine":
|
||||
return None, (-t * 0.6, t * 0.4, 0)
|
||||
elif bone_name == "Chest":
|
||||
return None, (-t * 0.5, 0, 0)
|
||||
elif bone_name in ("LeftUpperArm", "RightUpperArm"):
|
||||
sign = -1 if "Left" in bone_name else 1
|
||||
return None, (t * 2.2, 0, sign * -t * 0.6)
|
||||
elif bone_name in ("LeftUpperLeg", "RightUpperLeg"):
|
||||
return None, (-t * 0.4, 0, 0)
|
||||
else:
|
||||
t = (frame - 14) / 15.0
|
||||
if bone_name == "Hips":
|
||||
return (0, 0, -0.6 - t * 0.3), (-1.3 - t * 0.6, 0.8 * (1 - t), 0.3 + t * 0.4)
|
||||
elif bone_name == "Spine":
|
||||
return None, (-0.8 - t * 0.4, 0.5 * (1 - t), 0)
|
||||
elif bone_name == "Chest":
|
||||
return None, (-0.7 - t * 0.3, 0, 0)
|
||||
elif bone_name == "LeftHand":
|
||||
return None, (t * 0.4, t * 0.6, -0.8 - t * 0.6)
|
||||
elif bone_name == "RightHand":
|
||||
return None, (t * 0.5, -t * 0.7, 0.9 + t * 0.5)
|
||||
return None, None
|
||||
|
||||
create_animation("Death", 30, death_kf)
|
||||
|
||||
print(f"\nTotal animations: {len(bpy.data.actions)}")
|
||||
for action in bpy.data.actions:
|
||||
print(f" {action.name} ({action.frame_range[0]:.0f}-{action.frame_range[1]:.0f})")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
arm.select_set(True)
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
|
||||
print(f"\nExporting to {output_path}...")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_animation_mode='ACTIONS',
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
)
|
||||
print(f"Exported! {os.path.getsize(output_path)} bytes")
|
||||
|
||||
import struct, json
|
||||
|
||||
with open(output_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
json_len = struct.unpack('<I', data[12:16])[0]
|
||||
json_data = data[20:20+json_len]
|
||||
gltf = json.loads(json_data)
|
||||
|
||||
nodes = gltf.get('nodes', [])
|
||||
skeleton_idx = None
|
||||
for i, n in enumerate(nodes):
|
||||
if n.get('name') == 'MikuRig':
|
||||
skeleton_idx = i
|
||||
break
|
||||
|
||||
if skeleton_idx is not None:
|
||||
skins = gltf.get('skins', [])
|
||||
if skins:
|
||||
skins[0]['skeleton'] = skeleton_idx
|
||||
|
||||
new_json = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
|
||||
while len(new_json) % 4 != 0:
|
||||
new_json += b' '
|
||||
|
||||
bin_start = 20 + json_len
|
||||
bin_len = struct.unpack('<I', data[bin_start:bin_start+4])[0]
|
||||
new_length = 12 + 8 + len(new_json) + 8 + bin_len
|
||||
|
||||
new_data = b'glTF'
|
||||
new_data += struct.pack('<I', 2)
|
||||
new_data += struct.pack('<I', new_length)
|
||||
new_data += struct.pack('<I', len(new_json))
|
||||
new_data += b'JSON'
|
||||
new_data += new_json
|
||||
new_data += data[bin_start:]
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(new_data)
|
||||
|
||||
print(f"Final: {os.path.getsize(output_path)} bytes")
|
||||
print("=== Complete ===")
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender auto-rigger for Papaya-Shooter.
|
||||
|
||||
Takes an UNRIGGED humanoid mesh (GLB/glTF/FBX/OBJ), fits a Mixamo-compatible
|
||||
skeleton to it, binds with automatic weights, and exports a rigged GLB.
|
||||
|
||||
Because the output skeleton uses standard Mixamo bone names, ANY Mixamo
|
||||
animation (or CC0 pack retargeted to Mixamo names) can be merged onto the
|
||||
result with tools/merge_animations.py — rig once, reuse every animation.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height] [library.glb]
|
||||
|
||||
Skeleton source, in order of preference:
|
||||
1. If a 4th arg (or assets/characters/animations/_library.glb) exists, the
|
||||
armature from that animation-library GLB is used as the template. This
|
||||
guarantees every library clip fits the character perfectly — same rig,
|
||||
no retargeting.
|
||||
2. Otherwise a Mixamo-named skeleton is built from heuristics (works with
|
||||
Mixamo-skeleton clip files instead).
|
||||
|
||||
If the input already contains an armature, it is kept as-is (bones are only
|
||||
renamed to Mixamo convention when obvious matches exist) and the mesh is
|
||||
re-exported normalized. Use tools/merge_animations.py next either way.
|
||||
|
||||
Heuristics assume a roughly upright humanoid in T-pose or A-pose. For models
|
||||
that fail (extreme proportions, non-humanoids), use an external auto-rigger
|
||||
(Mixamo web, AccuRig, Tripo/UniRig) and feed the rigged GLB straight to
|
||||
merge_animations.py instead.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT = argv[0]
|
||||
OUTPUT = argv[1]
|
||||
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
|
||||
_default_library = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"assets", "characters", "animations", "_library.glb")
|
||||
LIBRARY = argv[3] if len(argv) > 3 else (_default_library if os.path.exists(_default_library) else "")
|
||||
|
||||
# Mixamo bone names Godot/our animation library expects.
|
||||
MIX = "mixamorig:"
|
||||
|
||||
# Common bone-name aliases -> Mixamo names, used when a rig already exists.
|
||||
BONE_ALIASES = {
|
||||
"hips": "Hips", "pelvis": "Hips",
|
||||
"spine": "Spine", "spine1": "Spine1", "spine2": "Spine2",
|
||||
"chest": "Spine1", "upperchest": "Spine2",
|
||||
"neck": "Neck", "head": "Head",
|
||||
"leftshoulder": "LeftShoulder", "rightshoulder": "RightShoulder",
|
||||
"leftarm": "LeftArm", "leftupperarm": "LeftArm",
|
||||
"rightarm": "RightArm", "rightupperarm": "RightArm",
|
||||
"leftforearm": "LeftForeArm", "leftlowerarm": "LeftForeArm",
|
||||
"rightforearm": "RightForeArm", "rightlowerarm": "RightForeArm",
|
||||
"lefthand": "LeftHand", "righthand": "RightHand",
|
||||
"leftupleg": "LeftUpLeg", "leftupperleg": "LeftUpLeg", "leftthigh": "LeftUpLeg",
|
||||
"rightupleg": "RightUpLeg", "rightupperleg": "RightUpLeg", "rightthigh": "RightUpLeg",
|
||||
"leftleg": "LeftLeg", "leftlowerleg": "LeftLeg", "leftshin": "LeftLeg", "leftcalf": "LeftLeg",
|
||||
"rightleg": "RightLeg", "rightlowerleg": "RightLeg", "rightshin": "RightLeg", "rightcalf": "RightLeg",
|
||||
"leftfoot": "LeftFoot", "rightfoot": "RightFoot",
|
||||
"lefttoebase": "LeftToeBase", "lefttoe": "LeftToeBase",
|
||||
"righttoebase": "RightToeBase", "righttoe": "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.actions):
|
||||
for block in list(block_list):
|
||||
if block.users == 0:
|
||||
block_list.remove(block)
|
||||
|
||||
|
||||
def import_model(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
elif ext == ".obj":
|
||||
if hasattr(bpy.ops.wm, "obj_import"):
|
||||
bpy.ops.wm.obj_import(filepath=path)
|
||||
else:
|
||||
bpy.ops.import_scene.obj(filepath=path)
|
||||
else:
|
||||
print(f"ERROR: unsupported format {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def normalize_alias(name):
|
||||
key = name.lower().replace("mixamorig:", "").replace("_", "").replace(" ", "").replace(".", "")
|
||||
key = key.replace("l_", "left").replace("r_", "right")
|
||||
return BONE_ALIASES.get(key)
|
||||
|
||||
|
||||
def rename_existing_rig(arm):
|
||||
renamed = 0
|
||||
for bone in arm.data.bones:
|
||||
target = normalize_alias(bone.name)
|
||||
if target and not bone.name.startswith(MIX):
|
||||
bone.name = MIX + target
|
||||
renamed += 1
|
||||
print(f"Renamed {renamed} bones to Mixamo convention")
|
||||
|
||||
|
||||
def mesh_slice_width(obj, y_frac, height, min_z):
|
||||
"""Max |x| of vertices within a thin horizontal slice at y_frac of height."""
|
||||
z_lo = min_z + height * (y_frac - 0.03)
|
||||
z_hi = min_z + height * (y_frac + 0.03)
|
||||
max_x = 0.0
|
||||
mat = obj.matrix_world
|
||||
for v in obj.data.vertices:
|
||||
co = mat @ v.co
|
||||
if z_lo <= co.z <= z_hi:
|
||||
max_x = max(max_x, abs(co.x))
|
||||
return max_x
|
||||
|
||||
|
||||
def fit_arms_to_mesh(arm, mesh):
|
||||
"""Rotate each arm bone chain to lie along the mesh's actual arm direction.
|
||||
|
||||
Sketchfab humanoids are usually modelled in an A-pose (arms angled down),
|
||||
but the library skeleton rests in a T-pose (arms horizontal). If we bind the
|
||||
A-pose mesh to T-pose arm bones, bone-heat weighting fails (bones sit outside
|
||||
the arms) and the animations shove the already-drooped arms across the body.
|
||||
|
||||
Reorienting the arm bones down the real arm axis makes the rest pose fit the
|
||||
mesh, so bone-heat succeeds. The animations are then retargeted from the
|
||||
library's T-pose rest to this fitted rest in merge_animations.py.
|
||||
"""
|
||||
mw = mesh.matrix_world
|
||||
verts = [mw @ v.co for v in mesh.data.vertices]
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
eb = arm.data.edit_bones
|
||||
for side, sx in (("L", 1.0), ("R", -1.0)):
|
||||
chain = [eb.get(f"DEF-upper_arm.{side}"), eb.get(f"DEF-forearm.{side}"),
|
||||
eb.get(f"DEF-hand.{side}")]
|
||||
chain = [b for b in chain if b]
|
||||
if not chain:
|
||||
continue
|
||||
shoulder = chain[0].head.copy()
|
||||
# Hand tip = farthest mesh vertex to this side, near arm height.
|
||||
arm_z = shoulder.z
|
||||
cand = [v for v in verts if (v.x * sx) > abs(shoulder.x) * 0.8
|
||||
and abs(v.z - arm_z) < 0.35]
|
||||
if not cand:
|
||||
continue
|
||||
hand_tip = max(cand, key=lambda v: v.x * sx)
|
||||
cur = chain[-1].tail - shoulder
|
||||
tgt = hand_tip - shoulder
|
||||
if cur.length < 1e-4 or tgt.length < 1e-4:
|
||||
continue
|
||||
rot = cur.normalized().rotation_difference(tgt.normalized())
|
||||
# Rotate the whole chain about the shoulder joint.
|
||||
for b in chain:
|
||||
b.head = shoulder + rot @ (b.head - shoulder)
|
||||
b.tail = shoulder + rot @ (b.tail - shoulder)
|
||||
sh = eb.get(f"DEF-shoulder.{side}")
|
||||
if sh:
|
||||
sh.tail = chain[0].head
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
print("Fitted arm bones to mesh A-pose")
|
||||
|
||||
|
||||
def import_library_armature(height):
|
||||
"""Import the animation library's armature as the rig template.
|
||||
|
||||
Rigging to the exact skeleton the clips were authored on means clips need
|
||||
only a rest-pose retarget (handled in merge_animations.py).
|
||||
"""
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=LIBRARY)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
|
||||
arm = None
|
||||
for o in new_objects:
|
||||
if o.type == "ARMATURE":
|
||||
arm = o
|
||||
if not arm:
|
||||
print("WARNING: no armature in library, falling back to heuristic skeleton")
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
return None
|
||||
|
||||
# Drop the library's mannequin mesh and all bundled actions — we only
|
||||
# want the bare skeleton here; clips get merged in the next stage.
|
||||
for o in new_objects:
|
||||
if o is not arm:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
if arm.animation_data:
|
||||
arm.animation_data_clear()
|
||||
for a in [a for a in bpy.data.actions if a not in before_actions]:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
# Scale the rig uniformly so its height matches the character's.
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
arm.select_set(True)
|
||||
arm.location = (0, 0, 0)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
rig_top = max((arm.matrix_world @ b.head_local).z for b in arm.data.bones)
|
||||
rig_top = max(rig_top, max((arm.matrix_world @ b.tail_local).z for b in arm.data.bones))
|
||||
if rig_top > 0.01:
|
||||
s = height / rig_top
|
||||
arm.scale = (s, s, s)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
print(f"Library rig scaled by {s:.3f} to height {height:.2f}")
|
||||
return arm
|
||||
|
||||
|
||||
def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
|
||||
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds.
|
||||
|
||||
Proportions are standard humanoid ratios of total height; good enough for
|
||||
automatic weights on typical game characters.
|
||||
"""
|
||||
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
|
||||
arm = bpy.context.active_object
|
||||
arm.name = "Armature"
|
||||
eb = arm.data.edit_bones
|
||||
for b in list(eb):
|
||||
eb.remove(b)
|
||||
|
||||
def z(frac):
|
||||
return min_z + height * frac
|
||||
|
||||
def add(name, head, tail, parent=None, connect=False):
|
||||
b = eb.new(MIX + name)
|
||||
b.head = Vector(head)
|
||||
b.tail = Vector(tail)
|
||||
if parent:
|
||||
b.parent = eb[MIX + parent]
|
||||
b.use_connect = connect
|
||||
return b
|
||||
|
||||
sw = shoulder_w * 0.75 # shoulder joint x
|
||||
hw = max(hip_w * 0.45, height * 0.055) # hip joint x
|
||||
|
||||
add("Hips", (0, 0, z(0.53)), (0, 0, z(0.58)))
|
||||
add("Spine", (0, 0, z(0.58)), (0, 0, z(0.66)), "Hips", True)
|
||||
add("Spine1", (0, 0, z(0.66)), (0, 0, z(0.74)), "Spine", True)
|
||||
add("Spine2", (0, 0, z(0.74)), (0, 0, z(0.82)), "Spine1", True)
|
||||
add("Neck", (0, 0, z(0.82)), (0, 0, z(0.87)), "Spine2", True)
|
||||
add("Head", (0, 0, z(0.87)), (0, 0, z(1.00)), "Neck", True)
|
||||
|
||||
for side, sx in (("Left", 1), ("Right", -1)):
|
||||
add(f"{side}Shoulder", (sx * sw * 0.25, 0, z(0.80)), (sx * sw, 0, z(0.80)), "Spine2")
|
||||
# Arms along +/-X (T-pose-ish); automatic weights tolerate A-pose meshes.
|
||||
arm_len = height * 0.16
|
||||
add(f"{side}Arm", (sx * sw, 0, z(0.80)), (sx * (sw + arm_len), 0, z(0.78)), f"{side}Shoulder", True)
|
||||
add(f"{side}ForeArm", (sx * (sw + arm_len), 0, z(0.78)), (sx * (sw + arm_len * 2), 0, z(0.76)), f"{side}Arm", True)
|
||||
add(f"{side}Hand", (sx * (sw + arm_len * 2), 0, z(0.76)), (sx * (sw + arm_len * 2.4), 0, z(0.75)), f"{side}ForeArm", True)
|
||||
|
||||
add(f"{side}UpLeg", (sx * hw, 0, z(0.52)), (sx * hw, 0, z(0.29)), "Hips")
|
||||
add(f"{side}Leg", (sx * hw, 0, z(0.29)), (sx * hw, 0, z(0.06)), f"{side}UpLeg", True)
|
||||
add(f"{side}Foot", (sx * hw, 0, z(0.06)), (sx * hw, -height * 0.08, z(0.015)), f"{side}Leg", True)
|
||||
add(f"{side}ToeBase", (sx * hw, -height * 0.08, z(0.015)), (sx * hw, -height * 0.13, z(0.015)), f"{side}Foot", True)
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
return arm
|
||||
|
||||
|
||||
def _count_weighted_verts(mesh):
|
||||
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
|
||||
|
||||
|
||||
# Bones that must never deform the mesh: the root/master and any rig control
|
||||
# or mechanism bones. A deform-flagged root runs up the body centre-line, so
|
||||
# without this it captures inner-leg/heel verts and stretches them to centre.
|
||||
_NON_DEFORM_HINTS = ("root", "master", "mch-", "mch_", "ctrl", "org-", "-ik",
|
||||
"_ik", "pole", "target", "properties")
|
||||
|
||||
|
||||
def _deform_segments(arm):
|
||||
segs = []
|
||||
for b in arm.data.bones:
|
||||
if not b.use_deform:
|
||||
continue
|
||||
if any(h in b.name.lower() for h in _NON_DEFORM_HINTS):
|
||||
continue
|
||||
head = arm.matrix_world @ b.head_local
|
||||
tail = arm.matrix_world @ b.tail_local
|
||||
segs.append((b.name, head, tail))
|
||||
return segs
|
||||
|
||||
|
||||
def rigid_nearest_bone_weights(mesh, arm):
|
||||
"""Weight each vertex to its nearest deform bones (distance-falloff blend).
|
||||
|
||||
A guaranteed-to-work substitute when bone-heat weighting fails — which it
|
||||
does on most imported characters, whose joined hair/clothing/body meshes
|
||||
have the interior and overlapping geometry the heat solver chokes on.
|
||||
|
||||
Each vertex is blended across its nearest few bone segments with an
|
||||
inverse-distance falloff, so joints deform smoothly instead of tearing.
|
||||
Control/root bones are excluded (see _NON_DEFORM_HINTS) so the mesh follows
|
||||
real limbs, not the centre-line master bone.
|
||||
"""
|
||||
segs = _deform_segments(arm)
|
||||
if not segs:
|
||||
return 0
|
||||
|
||||
for vg in list(mesh.vertex_groups):
|
||||
mesh.vertex_groups.remove(vg)
|
||||
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
ab = b - a
|
||||
denom = ab.dot(ab)
|
||||
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
|
||||
return (p - (a + ab * t)).length
|
||||
|
||||
K = 4 # blend across up to this many nearest bones
|
||||
FALLOFF = 3.0 # higher = tighter to the single nearest bone
|
||||
mw = mesh.matrix_world
|
||||
for v in mesh.data.vertices:
|
||||
p = mw @ v.co
|
||||
dists = sorted(((dist_to_seg(p, s[1], s[2]), s[0]) for s in segs),
|
||||
key=lambda d: d[0])[:K]
|
||||
nearest = max(dists[0][0], 1e-5)
|
||||
# Inverse-distance weights, relative to the nearest bone.
|
||||
raw = [((nearest / max(d, 1e-5)) ** FALLOFF, name) for d, name in dists]
|
||||
total = sum(w for w, _ in raw)
|
||||
for w, name in raw:
|
||||
groups[name].add([v.index], w / total, "REPLACE")
|
||||
return _count_weighted_verts(mesh)
|
||||
|
||||
|
||||
def bind_mesh_to_armature(mesh, arm):
|
||||
"""Bind mesh to armature so the glTF exporter writes a COMPLETE skin
|
||||
(skin object + node.skin reference + per-vertex JOINTS/WEIGHTS).
|
||||
|
||||
1. Try Blender automatic (bone-heat) weights for smooth deformation.
|
||||
2. If that assigns (almost) nothing — common on layered hair/clothing
|
||||
meshes where bone-heat fails — fall back to rigid nearest-bone.
|
||||
|
||||
Keep the standard ARMATURE_AUTO result: an Armature modifier plus vertex
|
||||
groups (parent_type stays OBJECT). That is exactly what the exporter needs
|
||||
to write the vertex weights. (An earlier version stripped the modifier and
|
||||
used parent_type='ARMATURE'; that produced an ORPHAN skin with no weights,
|
||||
so the mesh rendered its bind pose — a permanent T-pose — in game.)
|
||||
"""
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
|
||||
|
||||
weighted = _count_weighted_verts(mesh)
|
||||
total = len(mesh.data.vertices)
|
||||
if weighted < total * 0.5:
|
||||
print(f"Automatic weights covered {weighted}/{total} verts — "
|
||||
"falling back to rigid nearest-bone weights")
|
||||
weighted = rigid_nearest_bone_weights(mesh, arm)
|
||||
print(f"Bound mesh: {weighted}/{total} verts weighted")
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing {INPUT}...")
|
||||
import_model(INPUT)
|
||||
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not meshes:
|
||||
print("ERROR: no mesh in input")
|
||||
sys.exit(1)
|
||||
|
||||
# Join meshes so weights/normalization apply uniformly.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
mesh = bpy.context.active_object
|
||||
|
||||
# Detach from the importer's hierarchy (Sketchfab wraps meshes in scaled/
|
||||
# rotated empties) so the mesh sits in clean world space, then bake its
|
||||
# own transform. Otherwise bind + normalization fight the parent transform.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
if mesh.parent:
|
||||
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type == "EMPTY":
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
if armatures:
|
||||
print("Input already rigged — keeping existing skeleton, renaming bones")
|
||||
rename_existing_rig(armatures[0])
|
||||
else:
|
||||
# Normalize: feet on ground, centered, target height.
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
max_z = max(c.z for c in bb)
|
||||
cur_h = max_z - min_z
|
||||
if cur_h <= 0:
|
||||
print("ERROR: degenerate mesh bounds")
|
||||
sys.exit(1)
|
||||
s = TARGET_HEIGHT / cur_h
|
||||
mesh.scale = (s, s, s)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
cx = (min(c.x for c in bb) + max(c.x for c in bb)) / 2
|
||||
cy = (min(c.y for c in bb) + max(c.y for c in bb)) / 2
|
||||
mesh.location.x -= cx
|
||||
mesh.location.y -= cy
|
||||
mesh.location.z -= min_z
|
||||
bpy.ops.object.transform_apply(location=True)
|
||||
min_z = 0.0
|
||||
height = TARGET_HEIGHT
|
||||
|
||||
arm = None
|
||||
from_library = False
|
||||
if LIBRARY:
|
||||
print(f"Using animation library skeleton: {LIBRARY}")
|
||||
arm = import_library_armature(height)
|
||||
from_library = arm is not None
|
||||
if not arm:
|
||||
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
|
||||
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
|
||||
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
|
||||
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
|
||||
|
||||
# Fit the arm bones to the mesh's real (usually A-pose) arm direction so
|
||||
# bone-heat weighting fits and the rest pose matches the geometry. The
|
||||
# library-clip retarget in merge_animations.py compensates for the
|
||||
# rest-pose change.
|
||||
if from_library:
|
||||
fit_arms_to_mesh(arm, mesh)
|
||||
|
||||
bind_mesh_to_armature(mesh, arm)
|
||||
|
||||
print(f"Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
# NOTE: export_apply must stay False — applying modifiers on a skinned
|
||||
# mesh bakes away the Armature modifier and drops the skin binding
|
||||
# (glTF then exports bones as plain nodes with skins:0). Transforms are
|
||||
# already applied in code before binding, so nothing is lost here.
|
||||
export_apply=False,
|
||||
export_animations=True,
|
||||
export_skins=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender animation merger for Papaya-Shooter.
|
||||
|
||||
Merges every animation clip from the shared animation library onto a rigged
|
||||
character (Mixamo-compatible bone names) and exports one game-ready GLB whose
|
||||
animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
|
||||
|
||||
Animation library — two supported layouts in <animations_dir>:
|
||||
|
||||
A. `_library.glb` — a single multi-clip library (e.g. Quaternius Universal
|
||||
Animation Library, CC0, committed at assets/characters/animations/).
|
||||
Clip names map through LIBRARY_CLIP_MAP below. The character must be
|
||||
rigged on this library's own skeleton (tools/autorig.py does this
|
||||
automatically when _library.glb is present).
|
||||
|
||||
B. Loose FBX/GLB files, ONE clip per file, all on the Mixamo skeleton
|
||||
(manual Mixamo downloads). Filenames map to canonical clip names:
|
||||
idle.fbx -> Idle
|
||||
run.fbx -> Run
|
||||
crouch_walk.fbx -> CrouchWalk
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/merge_animations.py -- \
|
||||
<rigged_character.glb> <animations_dir> <output.glb> [--keep-root-motion]
|
||||
|
||||
Locomotion clips are exported in place (hips horizontal motion stripped)
|
||||
unless --keep-root-motion is given, since the gameplay code moves the body.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print("Usage: blender --background --python tools/merge_animations.py -- "
|
||||
"<character.glb> <animations_dir> <output.glb> [--keep-root-motion]")
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER = argv[0]
|
||||
ANIM_DIR = argv[1]
|
||||
OUTPUT = argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
MIX = "mixamorig:"
|
||||
|
||||
# Quaternius Universal Animation Library clip -> game canonical clip name.
|
||||
# Unmapped clips are skipped to keep character GLBs small.
|
||||
LIBRARY_CLIP_MAP = {
|
||||
"Idle_Loop": "Idle",
|
||||
"Walk_Loop": "Walk",
|
||||
"Jog_Fwd_Loop": "Run",
|
||||
"Sprint_Loop": "Sprint",
|
||||
"Jump_Start": "Jump",
|
||||
"Jump_Loop": "Fall",
|
||||
"Jump_Land": "Land",
|
||||
"Crouch_Idle_Loop": "CrouchIdle",
|
||||
"Crouch_Fwd_Loop": "CrouchWalk",
|
||||
"Roll": "Dash",
|
||||
"Death01": "Death",
|
||||
"Hit_Chest": "Hit",
|
||||
"Dance_Loop": "Dance",
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def action_fcurves(action):
|
||||
"""Return an action's F-curves across Blender versions.
|
||||
|
||||
Blender 4.4+/5.x replaced `Action.fcurves` with the slotted-action system
|
||||
(layers -> strips -> channelbags -> fcurves). This yields the curves either
|
||||
way so the merge logic doesn't care which Blender it runs under.
|
||||
"""
|
||||
legacy = getattr(action, "fcurves", None)
|
||||
if legacy is not None:
|
||||
return list(legacy)
|
||||
out = []
|
||||
for layer in getattr(action, "layers", []):
|
||||
for strip in layer.strips:
|
||||
cbags = getattr(strip, "channelbags", None)
|
||||
if cbags is not None:
|
||||
for cbag in cbags:
|
||||
out.extend(cbag.fcurves)
|
||||
else:
|
||||
for slot in getattr(action, "slots", []):
|
||||
cbag = strip.channelbag(slot)
|
||||
if cbag:
|
||||
out.extend(cbag.fcurves)
|
||||
return out
|
||||
|
||||
|
||||
def to_pascal(stem):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for o in objects:
|
||||
if o.type == "ARMATURE":
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def normalize_prefix(name):
|
||||
"""mixamorig1:Hips / mixamorig_Hips / Hips -> mixamorig:Hips"""
|
||||
base = name
|
||||
for i in range(10):
|
||||
base = base.replace(f"mixamorig{i}:", MIX)
|
||||
base = base.replace("mixamorig_", MIX)
|
||||
if ":" not in base and base in CORE_BONES:
|
||||
base = MIX + base
|
||||
return base
|
||||
|
||||
|
||||
CORE_BONES = {
|
||||
"Hips", "Spine", "Spine1", "Spine2", "Neck", "Head",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action_fcurves(action):
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
bone = fc.data_path[start:end]
|
||||
fixed = normalize_prefix(bone)
|
||||
if fixed != bone:
|
||||
fc.data_path = fc.data_path[:start] + fixed + fc.data_path[end:]
|
||||
|
||||
|
||||
def hips_height(arm):
|
||||
for b in arm.data.bones:
|
||||
if b.name.lower().endswith("hips"):
|
||||
return (arm.matrix_world @ b.head_local).z
|
||||
return 1.0
|
||||
|
||||
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action_fcurves(action):
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
kp.handle_left.y *= ratio
|
||||
kp.handle_right.y *= ratio
|
||||
|
||||
|
||||
def strip_hips_horizontal(action):
|
||||
"""Zero horizontal root motion so clips play in place (gameplay code
|
||||
moves the body). Applies to hips AND any dedicated root bone; keeps the
|
||||
vertical channel so bob/land weight survives.
|
||||
"""
|
||||
for fc in action_fcurves(action):
|
||||
if not fc.data_path.endswith(".location"):
|
||||
continue
|
||||
path_lower = fc.data_path.lower()
|
||||
if ("hips" in path_lower or '"root"' in path_lower) and fc.array_index in (0, 2):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y = 0.0
|
||||
kp.handle_left.y = 0.0
|
||||
kp.handle_right.y = 0.0
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}...")
|
||||
bpy.ops.import_scene.gltf(filepath=CHARACTER)
|
||||
target_arm = find_armature(bpy.data.objects)
|
||||
if not target_arm:
|
||||
print("ERROR: character has no armature — run tools/autorig.py first")
|
||||
sys.exit(1)
|
||||
|
||||
# Normalize character bone names too.
|
||||
for bone in target_arm.data.bones:
|
||||
fixed = normalize_prefix(bone.name)
|
||||
if fixed != bone.name:
|
||||
bone.name = fixed
|
||||
|
||||
target_hips = hips_height(target_arm)
|
||||
print(f"Character hips height: {target_hips:.3f}")
|
||||
|
||||
if not target_arm.animation_data:
|
||||
target_arm.animation_data_create()
|
||||
|
||||
# Drop any pre-existing animation so only canonical clips ship.
|
||||
for track in list(target_arm.animation_data.nla_tracks):
|
||||
target_arm.animation_data.nla_tracks.remove(track)
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
library_path = os.path.join(ANIM_DIR, "_library.glb")
|
||||
if os.path.exists(library_path):
|
||||
merged = merge_from_library(target_arm, target_hips, library_path)
|
||||
else:
|
||||
merged = merge_from_files(target_arm, target_hips)
|
||||
|
||||
if merged == 0:
|
||||
print("ERROR: no animations merged")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
export_skins=True,
|
||||
export_bake_animation=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
def _add_clip_track(target_arm, action, clip_name):
|
||||
action.name = clip_name
|
||||
track = target_arm.animation_data.nla_tracks.new()
|
||||
track.name = clip_name
|
||||
strip = track.strips.new(clip_name, 0, action)
|
||||
strip.name = clip_name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
|
||||
|
||||
def _assign_action(obj, action):
|
||||
"""Assign an action to an object across Blender versions (slotted actions)."""
|
||||
if not obj.animation_data:
|
||||
obj.animation_data_create()
|
||||
obj.animation_data.action = action
|
||||
try:
|
||||
slots = action.slots
|
||||
if len(slots):
|
||||
obj.animation_data.action_slot = slots[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def retarget_action(target_arm, src_arm, src_action, clip_name):
|
||||
"""Retarget one source clip onto the character rig via world-space
|
||||
constraint baking.
|
||||
|
||||
The character rig's arms were fitted to the mesh's A-pose (autorig.py), so
|
||||
its rest differs from the library's T-pose. Copying each bone's WORLD
|
||||
orientation (not its local channel) reproduces the library's actual motion
|
||||
regardless of that rest difference, then baking captures it as a clean
|
||||
action on the character rig.
|
||||
"""
|
||||
_assign_action(src_arm, src_action)
|
||||
fr0 = int(src_action.frame_range[0])
|
||||
fr1 = int(src_action.frame_range[1])
|
||||
|
||||
for pb in target_arm.pose.bones:
|
||||
if pb.name not in src_arm.pose.bones:
|
||||
continue
|
||||
cr = pb.constraints.new("COPY_ROTATION")
|
||||
cr.target = src_arm
|
||||
cr.subtarget = pb.name
|
||||
cr.target_space = "WORLD"
|
||||
cr.owner_space = "WORLD"
|
||||
if pb.name.lower().endswith("hips"):
|
||||
cl = pb.constraints.new("COPY_LOCATION")
|
||||
cl.target = src_arm
|
||||
cl.subtarget = pb.name
|
||||
cl.target_space = "WORLD"
|
||||
cl.owner_space = "WORLD"
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
target_arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = target_arm
|
||||
if target_arm.animation_data:
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
bpy.ops.nla.bake(frame_start=fr0, frame_end=fr1, step=1,
|
||||
only_selected=False, visual_keying=True,
|
||||
clear_constraints=True, clear_parents=False,
|
||||
use_current_action=True, bake_types={"POSE"})
|
||||
|
||||
baked = target_arm.animation_data.action
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(baked)
|
||||
return baked
|
||||
|
||||
|
||||
def merge_from_library(target_arm, target_hips, library_path):
|
||||
"""Retarget every mapped clip from a single multi-clip library GLB onto the
|
||||
character rig (whose arm rest was fitted to the mesh)."""
|
||||
print(f"Using multi-clip library: {library_path}")
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
bpy.ops.import_scene.gltf(filepath=library_path)
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||
src_arm = find_armature(new_objects)
|
||||
if not src_arm:
|
||||
print("ERROR: no armature in animation library")
|
||||
sys.exit(1)
|
||||
|
||||
# Match the library rig's height to the character so hips bob / foot planting
|
||||
# translate correctly under the world-space location copy.
|
||||
src_hips = hips_height(src_arm)
|
||||
if src_hips > 1e-4:
|
||||
s = target_hips / src_hips
|
||||
src_arm.scale = (s, s, s)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
src_arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = src_arm
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
|
||||
merged = 0
|
||||
keep = []
|
||||
for action in new_actions:
|
||||
base = action.name.split(".")[0] # tolerate .001 suffixes
|
||||
clip_name = LIBRARY_CLIP_MAP.get(base)
|
||||
if not clip_name:
|
||||
continue
|
||||
print(f"--- {base} -> '{clip_name}' (retarget bake)")
|
||||
baked = retarget_action(target_arm, src_arm, action, clip_name)
|
||||
target_arm.animation_data.action = None
|
||||
_add_clip_track(target_arm, baked, clip_name)
|
||||
keep.append(baked)
|
||||
merged += 1
|
||||
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in list(bpy.data.actions):
|
||||
if a not in keep and a in new_actions:
|
||||
bpy.data.actions.remove(a)
|
||||
return merged
|
||||
|
||||
|
||||
def merge_from_files(target_arm, target_hips):
|
||||
"""Merge clips from loose one-clip-per-file FBX/GLB files."""
|
||||
anim_files = sorted(
|
||||
f for f in os.listdir(ANIM_DIR)
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith((".", "_"))
|
||||
)
|
||||
if not anim_files:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
merged = 0
|
||||
for fname in anim_files:
|
||||
clip_name = to_pascal(os.path.splitext(fname)[0])
|
||||
path = os.path.join(ANIM_DIR, fname)
|
||||
print(f"--- {fname} -> '{clip_name}'")
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
try:
|
||||
if fname.lower().endswith(".fbx"):
|
||||
bpy.ops.import_scene.fbx(filepath=path, ignore_leaf_bones=True)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
except Exception as e:
|
||||
print(f" SKIP: import failed: {e}")
|
||||
continue
|
||||
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||
src_arm = find_armature(new_objects)
|
||||
|
||||
if not new_actions:
|
||||
print(" SKIP: no action found in file")
|
||||
else:
|
||||
action = max(new_actions, key=lambda a: len(action_fcurves(a)))
|
||||
normalize_action_paths(action)
|
||||
if src_arm:
|
||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
||||
scale_location_curves(action, ratio)
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(action)
|
||||
_add_clip_track(target_arm, action, clip_name)
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in new_actions:
|
||||
if a.name != clip_name:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-command character pipeline for Papaya-Shooter:
|
||||
|
||||
Sketchfab model -> auto-rig -> merge animation library -> in game.
|
||||
|
||||
Examples:
|
||||
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN):
|
||||
python tools/pipeline.py --uid a1b2c3... --name space_marine
|
||||
|
||||
# From a local unrigged mesh:
|
||||
python tools/pipeline.py --input downloads/robot.glb --name robot
|
||||
|
||||
# From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig:
|
||||
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged
|
||||
|
||||
The result is assets/characters/skins/<name>.glb with the full canonical
|
||||
animation set, plus a registry entry in assets/characters/skins/skins.json
|
||||
that SkinManager picks up automatically — no code changes needed.
|
||||
|
||||
Requires Blender 3.6+ on PATH or BLENDER_PATH set.
|
||||
Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOOLS = os.path.join(PROJECT_ROOT, "tools")
|
||||
SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins")
|
||||
ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations")
|
||||
STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
SKINS_JSON = os.path.join(SKINS_DIR, "skins.json")
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
env = os.environ.get("BLENDER_PATH")
|
||||
if env and os.path.exists(env):
|
||||
return env
|
||||
on_path = shutil.which("blender")
|
||||
if on_path:
|
||||
return on_path
|
||||
candidates = []
|
||||
for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"):
|
||||
if os.path.isdir(pf):
|
||||
for d in sorted(os.listdir(pf), reverse=True):
|
||||
exe = os.path.join(pf, d, "blender.exe")
|
||||
if os.path.exists(exe):
|
||||
candidates.append(exe)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(cmd: list[str], step: str) -> None:
|
||||
print(f"\n=== {step} ===")
|
||||
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: step '{step}' failed (exit {result.returncode})")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None:
|
||||
registry = {"skins": []}
|
||||
if os.path.exists(SKINS_JSON):
|
||||
with open(SKINS_JSON, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
skins = registry.setdefault("skins", [])
|
||||
entry = {
|
||||
"id": name,
|
||||
"name": display_name,
|
||||
"description": description,
|
||||
"model": model_res_path,
|
||||
"unlocked": True,
|
||||
}
|
||||
for i, s in enumerate(skins):
|
||||
if s.get("id") == name:
|
||||
skins[i] = entry
|
||||
break
|
||||
else:
|
||||
skins.append(entry)
|
||||
os.makedirs(SKINS_DIR, exist_ok=True)
|
||||
with open(SKINS_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2)
|
||||
print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character")
|
||||
src = p.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--uid", help="Sketchfab model UID to download")
|
||||
src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)")
|
||||
p.add_argument("--name", required=True, help="skin id (snake_case)")
|
||||
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
|
||||
p.add_argument("--description", default="", help="skin description")
|
||||
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
|
||||
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
|
||||
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
|
||||
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
|
||||
args = p.parse_args()
|
||||
|
||||
name = args.name
|
||||
display_name = args.display_name or name.replace("_", " ").title()
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
|
||||
# 1. Acquire the model.
|
||||
if args.uid:
|
||||
run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"),
|
||||
"download", args.uid, "--name", name, "--out", STAGING],
|
||||
"Download from Sketchfab")
|
||||
input_path = os.path.join(STAGING, f"{name}.glb")
|
||||
if not os.path.exists(input_path):
|
||||
# glTF-zip fallback path used by the importer
|
||||
alt = os.path.join(STAGING, name)
|
||||
gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else []
|
||||
if not gltfs:
|
||||
print("ERROR: downloaded model not found in staging dir")
|
||||
sys.exit(1)
|
||||
input_path = os.path.join(alt, gltfs[0])
|
||||
else:
|
||||
input_path = os.path.abspath(args.input)
|
||||
if not os.path.exists(input_path):
|
||||
print(f"ERROR: input not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
blender = find_blender()
|
||||
print(f"Using Blender: {blender}")
|
||||
|
||||
# 2. Auto-rig (or pass through if already rigged).
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
if args.rigged:
|
||||
rigged_path = input_path
|
||||
print("Skipping autorig (--rigged)")
|
||||
else:
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
|
||||
# 3. Merge the shared animation library.
|
||||
if not os.path.isdir(args.anim_dir) or not any(
|
||||
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
|
||||
print(f"ERROR: animation library is empty: {args.anim_dir}")
|
||||
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
|
||||
sys.exit(1)
|
||||
|
||||
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
|
||||
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path]
|
||||
if args.keep_root_motion:
|
||||
merge_cmd.append("--keep-root-motion")
|
||||
run(merge_cmd, "Merge animation library (Blender)")
|
||||
|
||||
# 4. Carry the license file along if the model came from Sketchfab.
|
||||
lic_src = os.path.splitext(input_path)[0] + ".license.json"
|
||||
if os.path.exists(lic_src):
|
||||
shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json"))
|
||||
|
||||
# 5. Register so the game sees it.
|
||||
register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description)
|
||||
|
||||
print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}")
|
||||
print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-rig a humanoid mesh in Blender and add animations.
|
||||
Handles multi-part meshes (body, hair, accessories) by separating loose parts,
|
||||
rigging only the body, and parenting everything to the same skeleton.
|
||||
Exports as GLB with proper skeleton for Godot.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
import math
|
||||
|
||||
argv = sys.argv
|
||||
if '--' in argv:
|
||||
argv = argv[argv.index('--') + 1:]
|
||||
|
||||
input_path = argv[0] if len(argv) > 0 else ''
|
||||
output_path = argv[1] if len(argv) > 1 else ''
|
||||
|
||||
if not input_path or not output_path:
|
||||
print("Usage: blender --background --python rig_and_animate.py -- <input.glb> <output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
# Clear scene
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Import the mesh
|
||||
print(f"Importing {input_path}...")
|
||||
if input_path.endswith('.glb') or input_path.endswith('.gltf'):
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
elif input_path.endswith('.obj'):
|
||||
bpy.ops.import_scene.obj(filepath=input_path, use_split_objects=False)
|
||||
elif input_path.endswith('.fbx'):
|
||||
bpy.ops.import_scene.fbx(filepath=input_path)
|
||||
|
||||
imported = bpy.context.selected_objects
|
||||
print(f"Imported {len(imported)} objects")
|
||||
|
||||
# Find all mesh objects
|
||||
meshes = [o for o in imported if o.type == 'MESH' and o.name != 'Icosphere']
|
||||
if not meshes:
|
||||
print("ERROR: No mesh found")
|
||||
sys.exit(1)
|
||||
|
||||
# Join all meshes into one first
|
||||
if len(meshes) > 1:
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.join()
|
||||
meshes = [bpy.context.active_object]
|
||||
|
||||
mesh_obj = meshes[0]
|
||||
print(f"Combined mesh: {mesh_obj.name} ({len(mesh_obj.data.vertices)} verts)")
|
||||
|
||||
# Clean up mesh
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.remove_doubles()
|
||||
bpy.ops.mesh.normals_make_consistent(inside=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Center and scale
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
mesh_obj.location = (0, 0, 0)
|
||||
|
||||
dims = mesh_obj.dimensions
|
||||
max_dim = max(dims.x, dims.y, dims.z)
|
||||
print(f"Original dimensions: {dims}")
|
||||
if max_dim > 0 and max_dim < 10:
|
||||
target_height = 1.8
|
||||
scale = target_height / max_dim
|
||||
mesh_obj.scale = (scale, scale, scale)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
print(f"Scaled by {scale:.2f}x. New dims: {mesh_obj.dimensions}")
|
||||
|
||||
# Separate mesh into loose parts (body, hair, twintails, etc.)
|
||||
# This is key: we need to identify which vertices belong to the body vs hair/accessories
|
||||
print("\n=== Separating mesh into parts ===")
|
||||
bpy.context.view_layer.objects.active = mesh_obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# List separated parts
|
||||
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||
print(f"Separated into {len(parts)} parts:")
|
||||
for p in parts:
|
||||
print(f" {p.name}: {len(p.data.vertices)} verts, dims={p.dimensions}")
|
||||
|
||||
# Identify the body part (largest by vertex count, roughly humanoid proportions)
|
||||
# The body should be the largest part with roughly humanoid proportions
|
||||
body_part = None
|
||||
hair_parts = []
|
||||
other_parts = []
|
||||
|
||||
for p in parts:
|
||||
verts = len(p.data.vertices)
|
||||
dims = p.dimensions
|
||||
height = max(dims.x, dims.y, dims.z)
|
||||
width = min(dims.x, dims.y, dims.z)
|
||||
|
||||
# Body: largest part, height > width, reasonable humanoid proportions
|
||||
if height > 0.5 and verts > 1000:
|
||||
if body_part is None or verts > len(body_part.data.vertices):
|
||||
if body_part:
|
||||
other_parts.append(body_part)
|
||||
body_part = p
|
||||
else:
|
||||
other_parts.append(p)
|
||||
else:
|
||||
hair_parts.append(p)
|
||||
|
||||
if not body_part:
|
||||
# Fallback: just use the largest part
|
||||
body_part = max(parts, key=lambda p: len(p.data.vertices))
|
||||
hair_parts = [p for p in parts if p != body_part]
|
||||
other_parts = []
|
||||
|
||||
print(f"\nBody: {body_part.name} ({len(body_part.data.vertices)} verts)")
|
||||
print(f"Hair/accessories: {[p.name for p in hair_parts]}")
|
||||
print(f"Other: {[p.name for p in other_parts]}")
|
||||
|
||||
# Delete non-body parts (hair, twintails, accessories) - they'll be parented to bones later
|
||||
# Actually, keep them but we won't skin them to the body bones
|
||||
all_non_body = hair_parts + other_parts
|
||||
|
||||
# Create the armature
|
||||
print("\n=== Creating Rig ===")
|
||||
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
|
||||
armature = bpy.context.active_object
|
||||
armature.name = "Rig"
|
||||
armature.data.display_type = 'STICK'
|
||||
armature.data.show_axes = False
|
||||
|
||||
# Delete default bone
|
||||
for bone in armature.data.edit_bones:
|
||||
armature.data.edit_bones.remove(bone)
|
||||
|
||||
# Define humanoid skeleton (Mixamo-compatible names)
|
||||
# Blender is Z-up, so head is at higher Z
|
||||
bone_defs = [
|
||||
("mixamorig:Hips", (0.09, 0.0, 0.95), (0.09, 0.0, 1.05)),
|
||||
("mixamorig:Spine", (0.09, 0.0, 1.05), (0.09, 0.0, 1.20)),
|
||||
("mixamorig:Spine1", (0.09, 0.0, 1.20), (0.09, 0.0, 1.35)),
|
||||
("mixamorig:Spine2", (0.09, 0.0, 1.35), (0.09, 0.0, 1.45)),
|
||||
("mixamorig:Neck", (0.09, 0.0, 1.45), (0.09, 0.0, 1.52)),
|
||||
("mixamorig:Head", (0.09, 0.0, 1.52), (0.09, 0.0, 1.70)),
|
||||
("mixamorig:LeftArm", (-0.09, 0.0, 1.40), (-0.18, 0.0, 1.22)),
|
||||
("mixamorig:LeftForeArm", (-0.18, 0.0, 1.22), (-0.18, 0.0, 1.00)),
|
||||
("mixamorig:LeftHand", (-0.18, 0.0, 1.00), (-0.18, 0.0, 0.78)),
|
||||
("mixamorig:RightArm", (0.27, 0.0, 1.40), (0.18, 0.0, 1.22)),
|
||||
("mixamorig:RightForeArm", (0.18, 0.0, 1.22), (0.18, 0.0, 1.00)),
|
||||
("mixamorig:RightHand", (0.18, 0.0, 1.00), (0.18, 0.0, 0.78)),
|
||||
("mixamorig:LeftUpLeg", (0.0, 0.0, 0.95), (0.0, 0.0, 0.50)),
|
||||
("mixamorig:LeftLeg", (0.0, 0.0, 0.50), (0.0, 0.0, 0.10)),
|
||||
("mixamorig:LeftFoot", (0.0, 0.0, 0.10), (0.0, 0.0, 0.0)),
|
||||
("mixamorig:RightUpLeg", (0.18, 0.0, 0.95), (0.18, 0.0, 0.50)),
|
||||
("mixamorig:RightLeg", (0.18, 0.0, 0.50), (0.18, 0.0, 0.10)),
|
||||
("mixamorig:RightFoot", (0.18, 0.0, 0.10), (0.18, 0.0, 0.0)),
|
||||
]
|
||||
|
||||
bones = {}
|
||||
for name, head, tail in bone_defs:
|
||||
bone = armature.data.edit_bones.new(name)
|
||||
bone.head = head
|
||||
bone.tail = tail
|
||||
bones[name] = bone
|
||||
|
||||
# Set parent hierarchy
|
||||
bones["mixamorig:Spine"].parent = bones["mixamorig:Hips"]
|
||||
bones["mixamorig:Spine1"].parent = bones["mixamorig:Spine"]
|
||||
bones["mixamorig:Spine2"].parent = bones["mixamorig:Spine1"]
|
||||
bones["mixamorig:Neck"].parent = bones["mixamorig:Spine2"]
|
||||
bones["mixamorig:Head"].parent = bones["mixamorig:Neck"]
|
||||
bones["mixamorig:LeftArm"].parent = bones["mixamorig:Spine2"]
|
||||
bones["mixamorig:LeftForeArm"].parent = bones["mixamorig:LeftArm"]
|
||||
bones["mixamorig:LeftHand"].parent = bones["mixamorig:LeftForeArm"]
|
||||
bones["mixamorig:RightArm"].parent = bones["mixamorig:Spine2"]
|
||||
bones["mixamorig:RightForeArm"].parent = bones["mixamorig:RightArm"]
|
||||
bones["mixamorig:RightHand"].parent = bones["mixamorig:RightForeArm"]
|
||||
bones["mixamorig:LeftUpLeg"].parent = bones["mixamorig:Hips"]
|
||||
bones["mixamorig:LeftLeg"].parent = bones["mixamorig:LeftUpLeg"]
|
||||
bones["mixamorig:LeftFoot"].parent = bones["mixamorig:LeftLeg"]
|
||||
bones["mixamorig:RightUpLeg"].parent = bones["mixamorig:Hips"]
|
||||
bones["mixamorig:RightLeg"].parent = bones["mixamorig:RightUpLeg"]
|
||||
bones["mixamorig:RightFoot"].parent = bones["mixamorig:RightLeg"]
|
||||
|
||||
for name in ["mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2", "mixamorig:Neck",
|
||||
"mixamorig:Head", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
|
||||
"mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
|
||||
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot",
|
||||
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot"]:
|
||||
bones[name].use_connect = True
|
||||
|
||||
print(f"Created {len(bones)} bones")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Skin ONLY the body part to the armature with automatic weights
|
||||
print("\n=== Skinning body mesh ===")
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
body_part.select_set(True)
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.parent_set(type='ARMATURE_AUTO')
|
||||
print(f"Body '{body_part.name}' parented with automatic weights")
|
||||
|
||||
# Parent hair/accessories to the armature (no skinning, just follow)
|
||||
# They'll move with the Head bone
|
||||
print("\n=== Parenting hair/accessories ===")
|
||||
for part in all_non_body:
|
||||
# Find the closest bone to this part's center
|
||||
part_center = part.location
|
||||
closest_bone = "mixamorig:Head" # Default to head for hair
|
||||
closest_dist = float('inf')
|
||||
|
||||
for bone_name, bone in bones.items():
|
||||
bone_head = armature.matrix_world @ bone.head
|
||||
dist = (part_center - bone_head).length
|
||||
if dist < closest_dist:
|
||||
closest_dist = dist
|
||||
closest_bone = bone_name
|
||||
|
||||
# Parent to armature with empty weights (just follow the bone)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
part.select_set(True)
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects_active = armature
|
||||
bpy.ops.object.parent_set(type='ARMATURE_NAME', keep_transform=True)
|
||||
print(f" '{part.name}' -> parented to '{closest_bone}'")
|
||||
|
||||
# Now create animations
|
||||
print("\n=== Creating animations ===")
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
def set_bone_rest(armature):
|
||||
for bone in armature.pose.bones:
|
||||
bone.location = (0, 0, 0)
|
||||
bone.rotation_euler = (0, 0, 0)
|
||||
bone.scale = (1, 1, 1)
|
||||
|
||||
def key_bone(bone_name, frame, loc=None, rot=None):
|
||||
bone = armature.pose.bones.get(bone_name)
|
||||
if not bone:
|
||||
return
|
||||
if loc:
|
||||
bone.location = loc
|
||||
bone.keyframe_insert(data_path="location", frame=frame)
|
||||
if rot:
|
||||
bone.rotation_mode = 'XYZ'
|
||||
bone.rotation_euler = rot
|
||||
bone.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
# Idle animation
|
||||
print(" Idle...")
|
||||
if not armature.animation_data:
|
||||
armature.animation_data_create()
|
||||
|
||||
action_idle = bpy.data.actions.new(name="Idle")
|
||||
armature.animation_data.action = action_idle
|
||||
action_idle.frame_range = (1, 30)
|
||||
set_bone_rest(armature)
|
||||
|
||||
key_bone("mixamorig:Spine", 1, rot=(0.02, 0, 0))
|
||||
key_bone("mixamorig:Spine", 15, rot=(-0.02, 0, 0))
|
||||
key_bone("mixamorig:Spine", 30, rot=(0.02, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", 1, rot=(0, 0, 0.05))
|
||||
key_bone("mixamorig:LeftArm", 15, rot=(0, 0, -0.05))
|
||||
key_bone("mixamorig:LeftArm", 30, rot=(0, 0, 0.05))
|
||||
key_bone("mixamorig:RightArm", 1, rot=(0, 0, -0.05))
|
||||
key_bone("mixamorig:RightArm", 15, rot=(0, 0, 0.05))
|
||||
key_bone("mixamorig:RightArm", 30, rot=(0, 0, -0.05))
|
||||
|
||||
# Walk animation
|
||||
print(" Walk...")
|
||||
action_walk = bpy.data.actions.new(name="Walk")
|
||||
armature.animation_data.action = action_walk
|
||||
action_walk.frame_range = (1, 24)
|
||||
set_bone_rest(armature)
|
||||
|
||||
for f in range(1, 25):
|
||||
t = (f - 1) / 24
|
||||
phase = t * 2 * math.pi
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.01))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(math.sin(phase) * 0.4, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(max(0, -math.sin(phase) * 0.3 + 0.2), 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(math.sin(phase + math.pi) * 0.4, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(max(0, -math.sin(phase + math.pi) * 0.3 + 0.2), 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(-math.sin(phase) * 0.3, 0, 0.05))
|
||||
key_bone("mixamorig:LeftForeArm", f, rot=(-0.3 + max(0, math.sin(phase) * 0.2), 0, 0))
|
||||
key_bone("mixamorig:RightArm", f, rot=(-math.sin(phase + math.pi) * 0.3, 0, -0.05))
|
||||
key_bone("mixamorig:RightForeArm", f, rot=(-0.3 + max(0, math.sin(phase + math.pi) * 0.2), 0, 0))
|
||||
key_bone("mixamorig:Spine", f, rot=(0, math.sin(phase) * 0.05, 0))
|
||||
|
||||
# Run animation
|
||||
print(" Run...")
|
||||
action_run = bpy.data.actions.new(name="Run")
|
||||
armature.animation_data.action = action_run
|
||||
action_run.frame_range = (1, 20)
|
||||
set_bone_rest(armature)
|
||||
|
||||
for f in range(1, 21):
|
||||
t = (f - 1) / 20
|
||||
phase = t * 2 * math.pi
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, abs(math.sin(phase * 2)) * 0.03))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(math.sin(phase) * 0.8, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(max(0.1, -math.sin(phase) * 0.6 + 0.3), 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(math.sin(phase + math.pi) * 0.8, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(max(0.1, -math.sin(phase + math.pi) * 0.6 + 0.3), 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(-math.sin(phase) * 0.7, 0, 0.1))
|
||||
key_bone("mixamorig:LeftForeArm", f, rot=(-1.0 + max(0, math.sin(phase) * 0.3), 0, 0))
|
||||
key_bone("mixamorig:RightArm", f, rot=(-math.sin(phase + math.pi) * 0.7, 0, -0.1))
|
||||
key_bone("mixamorig:RightForeArm", f, rot=(-1.0 + max(0, math.sin(phase + math.pi) * 0.3), 0, 0))
|
||||
key_bone("mixamorig:Spine", f, rot=(0.1, math.sin(phase) * 0.08, 0))
|
||||
|
||||
# Jump animation
|
||||
print(" Jump...")
|
||||
action_jump = bpy.data.actions.new(name="Jump")
|
||||
armature.animation_data.action = action_jump
|
||||
action_jump.frame_range = (1, 20)
|
||||
set_bone_rest(armature)
|
||||
|
||||
for f in range(1, 6):
|
||||
t = (f - 1) / 4
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.2))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.6, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(t * 1.0, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.6, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(t * 1.0, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(0, 0, -t * 0.5))
|
||||
key_bone("mixamorig:RightArm", f, rot=(0, 0, t * 0.5))
|
||||
|
||||
for f in range(6, 9):
|
||||
t = (f - 6) / 2
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, -0.2 + t * 0.2))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(1.0 - t * 0.3, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(-0.6 + t * 0.6, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(1.0 - t * 0.3, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(-t * 2.0, 0, -0.5 - t))
|
||||
key_bone("mixamorig:RightArm", f, rot=(-t * 2.0, 0, 0.5 + t))
|
||||
|
||||
for f in range(9, 16):
|
||||
key_bone("mixamorig:LeftArm", f, rot=(-2.0, 0, -1.5))
|
||||
key_bone("mixamorig:RightArm", f, rot=(-2.0, 0, 1.5))
|
||||
key_bone("mixamorig:LeftForeArm", f, rot=(-0.2, 0, 0))
|
||||
key_bone("mixamorig:RightForeArm", f, rot=(-0.2, 0, 0))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(0.2, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(0.5, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(0.1, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(0.3, 0, 0))
|
||||
|
||||
for f in range(16, 21):
|
||||
t = (f - 16) / 4
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.2))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.5, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", f, rot=(t * 0.8, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.5, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", f, rot=(t * 0.8, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(-2.0 + t * 2.0, 0, -1.5 + t * 1.5))
|
||||
key_bone("mixamorig:RightArm", f, rot=(-2.0 + t * 2.0, 0, 1.5 - t * 1.5))
|
||||
|
||||
# Crouch animation
|
||||
print(" Crouch...")
|
||||
action_crouch = bpy.data.actions.new(name="Crouch")
|
||||
armature.animation_data.action = action_crouch
|
||||
action_crouch.frame_range = (1, 1)
|
||||
set_bone_rest(armature)
|
||||
|
||||
key_bone("mixamorig:Hips", 1, loc=(0, 0, -0.4))
|
||||
key_bone("mixamorig:Spine", 1, rot=(0.3, 0, 0))
|
||||
key_bone("mixamorig:LeftUpLeg", 1, rot=(-0.8, 0, 0))
|
||||
key_bone("mixamorig:LeftLeg", 1, rot=(1.4, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", 1, rot=(-0.8, 0, 0))
|
||||
key_bone("mixamorig:RightLeg", 1, rot=(1.4, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", 1, rot=(-0.5, 0, 0.2))
|
||||
key_bone("mixamorig:RightArm", 1, rot=(-0.5, 0, -0.2))
|
||||
|
||||
# Death animation
|
||||
print(" Death...")
|
||||
action_death = bpy.data.actions.new(name="Death")
|
||||
armature.animation_data.action = action_death
|
||||
action_death.frame_range = (1, 30)
|
||||
set_bone_rest(armature)
|
||||
|
||||
for f in range(1, 15):
|
||||
t = (f - 1) / 13
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, -t * 0.5), rot=(-t * 1.2, 0, t * 0.2))
|
||||
key_bone("mixamorig:Spine", f, rot=(-t * 0.5, t * 0.3, 0))
|
||||
key_bone("mixamorig:Spine2", f, rot=(-t * 0.4, 0, 0))
|
||||
key_bone("mixamorig:LeftArm", f, rot=(t * 2.0, 0, -t * 0.5))
|
||||
key_bone("mixamorig:RightArm", f, rot=(t * 2.0, 0, t * 0.5))
|
||||
key_bone("mixamorig:LeftUpLeg", f, rot=(-t * 0.3, 0, 0))
|
||||
key_bone("mixamorig:RightUpLeg", f, rot=(-t * 0.3, 0, 0))
|
||||
|
||||
for f in range(15, 31):
|
||||
t = (f - 15) / 15
|
||||
key_bone("mixamorig:Hips", f, loc=(0, 0, -0.5 - t * 0.2), rot=(-1.2 - t * 0.5, 0.8 * (1 - t), 0.2 + t * 0.3))
|
||||
key_bone("mixamorig:Spine", f, rot=(-0.7 - t * 0.3, 0.5 * (1 - t), 0))
|
||||
key_bone("mixamorig:Spine2", f, rot=(-0.6 - t * 0.2, 0, 0))
|
||||
key_bone("mixamorig:LeftHand", f, rot=(t * 0.3, t * 0.5, -0.7 - t * 0.5))
|
||||
key_bone("mixamorig:RightHand", f, rot=(t * 0.4, -t * 0.6, 0.8 + t * 0.4))
|
||||
|
||||
print(f"\nCreated {len(bpy.data.actions)} animations:")
|
||||
for action in bpy.data.actions:
|
||||
print(f" {action.name} ({action.frame_range[0]:.0f}-{action.frame_range[1]:.0f})")
|
||||
|
||||
# Switch to object mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Select all for export
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
body_part.select_set(True)
|
||||
for part in all_non_body:
|
||||
part.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Export as GLB
|
||||
print(f"\n=== Exporting to {output_path} ===")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
export_format='GLB',
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_animation_mode='ACTIONS',
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
)
|
||||
print(f"Exported! {os.path.getsize(output_path)} bytes")
|
||||
|
||||
# Patch skeleton reference
|
||||
import struct
|
||||
import json
|
||||
|
||||
with open(output_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
json_len = struct.unpack('<I', data[12:16])[0]
|
||||
json_data = data[20:20+json_len]
|
||||
gltf = json.loads(json_data)
|
||||
|
||||
nodes = gltf.get('nodes', [])
|
||||
skeleton_idx = None
|
||||
for i, n in enumerate(nodes):
|
||||
if n.get('name') == 'Rig':
|
||||
skeleton_idx = i
|
||||
break
|
||||
|
||||
if skeleton_idx is not None:
|
||||
skins = gltf.get('skins', [])
|
||||
if skins:
|
||||
skins[0]['skeleton'] = skeleton_idx
|
||||
print(f"Set skeleton to {skeleton_idx}")
|
||||
|
||||
new_json = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
|
||||
while len(new_json) % 4 != 0:
|
||||
new_json += b' '
|
||||
|
||||
bin_start = 20 + json_len
|
||||
bin_len = struct.unpack('<I', data[bin_start:bin_start+4])[0]
|
||||
new_length = 12 + 8 + len(new_json) + 8 + bin_len
|
||||
|
||||
new_data = b'glTF'
|
||||
new_data += struct.pack('<I', 2)
|
||||
new_data += struct.pack('<I', new_length)
|
||||
new_data += struct.pack('<I', len(new_json))
|
||||
new_data += b'JSON'
|
||||
new_data += new_json
|
||||
new_data += data[bin_start:]
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(new_data)
|
||||
|
||||
print(f"Final: {os.path.getsize(output_path)} bytes, {len(gltf.get('animations', []))} animations")
|
||||
print("=== Complete ===")
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sketchfab model importer for Papaya-Shooter.
|
||||
|
||||
Searches and downloads models via the official Sketchfab Download API,
|
||||
extracts the GLB/glTF, and stages it for the rigging pipeline.
|
||||
|
||||
Auth (any one of):
|
||||
- env var SKETCHFAB_API_TOKEN
|
||||
- --token <token>
|
||||
- a file named .sketchfab_token in the project root (gitignored)
|
||||
|
||||
Usage:
|
||||
python tools/sketchfab_import.py search "anime character" [--limit 10]
|
||||
python tools/sketchfab_import.py download <model-uid> [--out assets/characters/incoming]
|
||||
python tools/sketchfab_import.py info <model-uid>
|
||||
|
||||
Notes:
|
||||
- Only models marked "downloadable" can be fetched (license permitting).
|
||||
- License + attribution metadata is saved next to the model as <name>.license.json.
|
||||
CC-BY requires crediting the author — keep those files!
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
API_BASE = "https://api.sketchfab.com/v3"
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_OUT = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
|
||||
|
||||
def get_token(cli_token: str | None) -> str:
|
||||
if cli_token:
|
||||
return cli_token
|
||||
tok = os.environ.get("SKETCHFAB_API_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file, "rb") as f:
|
||||
raw = f.read()
|
||||
# PowerShell's `echo`/`>` write UTF-16 with a BOM by default on
|
||||
# Windows; tolerate that as well as plain UTF-8.
|
||||
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
|
||||
tok = raw.decode("utf-16").strip()
|
||||
else:
|
||||
tok = raw.decode("utf-8-sig").strip()
|
||||
if tok:
|
||||
return tok
|
||||
print("ERROR: no Sketchfab API token found.")
|
||||
print("Set SKETCHFAB_API_TOKEN, pass --token, or create .sketchfab_token in the project root.")
|
||||
print("Your token: https://sketchfab.com/settings/password (API token section)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def api_get(path: str, token: str, params: dict | None = None) -> dict:
|
||||
url = API_BASE + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||
print(f"ERROR: API request failed ({e.code}) for {url}\n{body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
||||
return slug or "model"
|
||||
|
||||
|
||||
def cmd_search(args) -> None:
|
||||
token = get_token(args.token)
|
||||
params = {
|
||||
"type": "models",
|
||||
"q": args.query,
|
||||
"downloadable": "true",
|
||||
"count": str(args.limit),
|
||||
"sort_by": "-likeCount",
|
||||
}
|
||||
if args.animated:
|
||||
params["animated"] = "true"
|
||||
if args.rigged:
|
||||
params["rigged"] = "true"
|
||||
data = api_get("/search", token, params)
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
print("No downloadable results.")
|
||||
return
|
||||
print(f"{'UID':<34} {'License':<18} {'Faces':>9} Name")
|
||||
print("-" * 90)
|
||||
for m in results:
|
||||
lic = (m.get("license") or {}).get("label", "?")
|
||||
faces = m.get("faceCount", 0)
|
||||
print(f"{m['uid']:<34} {lic:<18} {faces:>9} {m['name']}")
|
||||
print("\nDownload one with: python tools/sketchfab_import.py download <UID>")
|
||||
|
||||
|
||||
def cmd_info(args) -> None:
|
||||
token = get_token(args.token)
|
||||
m = api_get(f"/models/{args.uid}", token)
|
||||
lic = m.get("license") or {}
|
||||
print(f"Name: {m.get('name')}")
|
||||
print(f"Author: {(m.get('user') or {}).get('displayName')}")
|
||||
print(f"License: {lic.get('label')} ({lic.get('slug')})")
|
||||
print(f"Downloadable:{m.get('isDownloadable')}")
|
||||
print(f"Faces: {m.get('faceCount')} Verts: {m.get('vertexCount')}")
|
||||
print(f"Animations: {m.get('animationCount')}")
|
||||
print(f"URL: {m.get('viewerUrl')}")
|
||||
|
||||
|
||||
def cmd_download(args) -> None:
|
||||
token = get_token(args.token)
|
||||
meta = api_get(f"/models/{args.uid}", token)
|
||||
name = args.name or slugify(meta.get("name", args.uid))
|
||||
out_dir = os.path.abspath(args.out)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
dl = api_get(f"/models/{args.uid}/download", token)
|
||||
|
||||
# Prefer the single-file GLB; fall back to the glTF zip.
|
||||
glb_path = os.path.join(out_dir, f"{name}.glb")
|
||||
if "glb" in dl and dl["glb"].get("url"):
|
||||
print(f"Downloading GLB ({dl['glb'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
urllib.request.urlretrieve(dl["glb"]["url"], glb_path)
|
||||
elif "gltf" in dl and dl["gltf"].get("url"):
|
||||
print(f"Downloading glTF zip ({dl['gltf'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
with urllib.request.urlopen(dl["gltf"]["url"], timeout=300) as resp:
|
||||
zdata = resp.read()
|
||||
extract_dir = os.path.join(out_dir, name)
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(io.BytesIO(zdata)) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
gltfs = [f for f in os.listdir(extract_dir) if f.endswith((".gltf", ".glb"))]
|
||||
if not gltfs:
|
||||
print(f"ERROR: no .gltf/.glb found in archive at {extract_dir}")
|
||||
sys.exit(1)
|
||||
glb_path = os.path.join(extract_dir, gltfs[0])
|
||||
else:
|
||||
print("ERROR: model has no downloadable GLB/glTF archive.")
|
||||
sys.exit(1)
|
||||
|
||||
# Save license/attribution metadata — required for CC-BY credit.
|
||||
lic = meta.get("license") or {}
|
||||
license_info = {
|
||||
"name": meta.get("name"),
|
||||
"uid": args.uid,
|
||||
"author": (meta.get("user") or {}).get("displayName"),
|
||||
"author_url": (meta.get("user") or {}).get("profileUrl"),
|
||||
"license": lic.get("label"),
|
||||
"license_slug": lic.get("slug"),
|
||||
"source_url": meta.get("viewerUrl"),
|
||||
}
|
||||
license_path = os.path.splitext(glb_path)[0] + ".license.json"
|
||||
with open(license_path, "w", encoding="utf-8") as f:
|
||||
json.dump(license_info, f, indent=2)
|
||||
|
||||
print(f"Saved: {glb_path}")
|
||||
print(f"License: {license_path} ({lic.get('label')})")
|
||||
print(f"\nNext step (auto-rig + animations + register in game):")
|
||||
print(f" python tools/pipeline.py --input \"{glb_path}\" --name {name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab importer")
|
||||
p.add_argument("--token", help="Sketchfab API token (else SKETCHFAB_API_TOKEN)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("search", help="search downloadable models")
|
||||
s.add_argument("query")
|
||||
s.add_argument("--limit", type=int, default=10)
|
||||
s.add_argument("--animated", action="store_true", help="only models with animations")
|
||||
s.add_argument("--rigged", action="store_true", help="only rigged models")
|
||||
s.set_defaults(func=cmd_search)
|
||||
|
||||
i = sub.add_parser("info", help="show model metadata")
|
||||
i.add_argument("uid")
|
||||
i.set_defaults(func=cmd_info)
|
||||
|
||||
d = sub.add_parser("download", help="download a model by UID")
|
||||
d.add_argument("uid")
|
||||
d.add_argument("--out", default=DEFAULT_OUT)
|
||||
d.add_argument("--name", help="output base name (default: slugified model name)")
|
||||
d.set_defaults(func=cmd_download)
|
||||
|
||||
args = p.parse_args()
|
||||
# Propagate the global --token even when given after the subcommand.
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -61,6 +61,9 @@ func _ready() -> void:
|
||||
_add_button(_ui_vbox, "Settings", _on_settings_pressed)
|
||||
_add_button(_ui_vbox, "Exit Game", _on_exit_pressed)
|
||||
|
||||
# Skin selector (skins come from SkinManager: built-ins + skins.json)
|
||||
_build_skin_selector(_ui_vbox)
|
||||
|
||||
# ==========================================
|
||||
# LEVEL SELECTOR PANEL
|
||||
# ==========================================
|
||||
@@ -355,6 +358,34 @@ func _add_button(parent: Container, text: String, callback: Callable) -> void:
|
||||
btn.pressed.connect(callback)
|
||||
parent.add_child(btn)
|
||||
|
||||
func _build_skin_selector(parent: Container) -> void:
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
if not skin_mgr:
|
||||
return
|
||||
var row = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
parent.add_child(row)
|
||||
|
||||
var lbl = Label.new()
|
||||
lbl.text = "Skin"
|
||||
lbl.add_theme_font_size_override("font_size", 24)
|
||||
row.add_child(lbl)
|
||||
|
||||
var opt = OptionButton.new()
|
||||
opt.custom_minimum_size = Vector2(240, 44)
|
||||
var ids: Array = skin_mgr.get_skin_ids()
|
||||
ids.sort()
|
||||
for i in range(ids.size()):
|
||||
var skin = skin_mgr.get_skin(ids[i])
|
||||
opt.add_item(skin.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 _on_back_pressed() -> void:
|
||||
_level_selector_panel.hide()
|
||||
_ui_vbox.show()
|
||||
|
||||
@@ -171,7 +171,7 @@ func _shoot_hitscan() -> void:
|
||||
final_target = result.position
|
||||
_spawn_tracer(origin, final_target)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
|
||||
|
||||
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
|
||||
|
||||
@@ -129,7 +129,7 @@ func _shoot_projectile() -> void:
|
||||
|
||||
_spawn_custom_projectile(origin, fire_dir)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_play_fire_effects.rpc_id(1, origin, fire_dir, weapon_name, false)
|
||||
|
||||
func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void:
|
||||
|
||||
@@ -120,7 +120,7 @@ func _apply_impulse() -> void:
|
||||
|
||||
# Apply impulse to player velocity
|
||||
var final_force = push_dir * impulse_strength
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_apply_impulse.rpc_id(1, final_force)
|
||||
else:
|
||||
player.apply_impulse(final_force)
|
||||
@@ -177,7 +177,8 @@ func _shoot_hitscan() -> void:
|
||||
if result.collider.has_method("take_damage"):
|
||||
result.collider.take_damage(damage, result.position, player)
|
||||
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
|
||||
ImpactSpawner.spawn(get_tree(), "bullet", result.position, result.normal, 0.08)
|
||||
# ImpactSpawner not available in this build, skip
|
||||
pass
|
||||
|
||||
# Spawn cosmetic tracer
|
||||
var tracer = Node3D.new()
|
||||
|
||||
@@ -193,7 +193,7 @@ func _explode(pos: Vector3) -> void:
|
||||
|
||||
# Apply knockback if it has server_apply_impulse (for rocket jumping)
|
||||
if col.has_method("server_apply_impulse") and col == owner_player:
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
col.server_apply_impulse.rpc_id(1, dir * final_knockback)
|
||||
else:
|
||||
col.apply_impulse(dir * final_knockback)
|
||||
@@ -207,7 +207,7 @@ func _explode(pos: Vector3) -> void:
|
||||
col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback)
|
||||
|
||||
# Broadcast Visual Explosion Effect to other peers
|
||||
if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and owner_player.multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and not(owner_player.multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
|
||||
|
||||
# Local Visual Effect
|
||||
|
||||
@@ -151,7 +151,7 @@ func _fire() -> void:
|
||||
var fire_dir = (base_dir + right * x_dir + up * y_dir).normalized()
|
||||
_spawn_custom_projectile(proj_origin, fire_dir)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_play_fire_effects.rpc_id(1, proj_origin, fire_dir, weapon_name, false)
|
||||
|
||||
var _current_aim_dir: Vector3 = Vector3.ZERO
|
||||
|
||||
@@ -313,11 +313,11 @@ func _equip_slot(slot: int) -> void:
|
||||
w.visible = true
|
||||
w.set_process_input(true)
|
||||
|
||||
# Sync 3rd person weapon
|
||||
if player and player.has_node("HumanoidModel"):
|
||||
var humanoid = player.get_node("HumanoidModel")
|
||||
if humanoid.has_method("set_weapon") and w.has_meta("script_path"):
|
||||
humanoid.set_weapon(w.get_meta("script_path"))
|
||||
# Sync 3rd person weapon on the visual model (skinned or procedural)
|
||||
if player and w.has_meta("script_path"):
|
||||
var visual = player.get_visual_model() if player.has_method("get_visual_model") else player.get_node_or_null("HumanoidModel")
|
||||
if visual and visual.has_method("set_weapon"):
|
||||
visual.set_weapon(w.get_meta("script_path"))
|
||||
# Update the synced variable so remote peers pick it up
|
||||
if "synced_weapon_path" in player:
|
||||
player.synced_weapon_path = w.get_meta("script_path")
|
||||
|
||||