Merge pull request 'Feat/14 movement overhaul' (#20) from feat/14-movement-overhaul into main

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-07-17 10:44:23 -07:00
92 changed files with 5118 additions and 799 deletions
+13
View File
@@ -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/
Binary file not shown.
@@ -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
Binary file not shown.
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"skins": [
{
"id": "miku",
"name": "Miku",
"description": "Hatsune Miku \u2014 Virtual Idol",
"model": "res://assets/characters/skins/miku.glb",
"unlocked": true
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
uid://l1bq145iht8
+80
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
uid://rm8cyl45cawk
+17
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
uid://dn2biq23l8yuv
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+50
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bb4cnikn41oot
+60
View File
@@ -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
+81 -9
View File
@@ -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()
@@ -60,16 +107,25 @@ func _process(delta: float) -> void:
# Smoothly scale FOV based on speed above walking speed
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))
# ── 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 ──────────────────────────────────────────────────────────
var sm = player.get_node_or_null("MovementStateMachine")
var is_sliding = sm and sm.current_state == "slide"
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
## Adds an offset to the camera so that when the player physics body snaps up a step,
## 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
+12
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cjpuejdwwevdn
+100
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://corbsludoaige
+518
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b3sy8q0weklf
+4 -9
View File
@@ -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
View File
@@ -1 +1 @@
uid://dn5ndscghwovm
uid://5ayptqm4yjih
+137
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dqc7midjff148
+18 -51
View File
@@ -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
View File
@@ -1 +1 @@
uid://c7ltcn37gfd71
uid://btkp2l168jj45
+127
View File
@@ -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`.
+55
View File
@@ -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`.
+67
View File
@@ -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`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

+1 -3
View File
@@ -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)
+1 -1
View File
@@ -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
+173
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c87jctwfqhyui
+56 -35
View File
@@ -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
+152 -50
View File
@@ -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:
@@ -84,10 +101,20 @@ func _physics_process(delta: float) -> void:
jump_buffer_time = params.jump_buffer
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", {})
@@ -181,12 +221,12 @@ func register_chain_mechanic(_mechanic_name: String) -> void:
chain_count += 1
else:
chain_count = 1
# Shrink the chain window as the chain gets longer to make it increasingly punishing
# Drops by 0.05 seconds per successful chain, down to a minimum of 0.4 seconds
var current_window = maxf(0.4, params.chain_window - (float(chain_count) * 0.05))
chain_timer = current_window
var raw_bonus = float(chain_count) * params.chain_bonus_per_success
if raw_bonus <= params.chain_bonus_cap:
current_chain_bonus = raw_bonus
@@ -194,7 +234,7 @@ func register_chain_mechanic(_mechanic_name: String) -> void:
# Soft cap: diminishing returns past the cap
var over_bonus = raw_bonus - params.chain_bonus_cap
current_chain_bonus = params.chain_bonus_cap + (over_bonus / (1.0 + over_bonus * 3.0))
movement_event.emit("chain_updated", {
"count": chain_count,
"bonus": current_chain_bonus
@@ -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
@@ -260,17 +300,17 @@ func detect_wall_horizontal() -> Vector3:
func detect_wall_forward() -> Dictionary:
if not player:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var move_dir := -player.global_transform.basis.z
move_dir.y = 0.0
if move_dir.length_squared() > 0.01:
move_dir = move_dir.normalized()
else:
return {"hit": false, "normal": Vector3.ZERO, "is_short": false}
var space_state := player.get_world_3d().direct_space_state
var dist := 1.2 # Increased to reliably detect walls at an angle
# Lower ray (feet/knees)
var origin_low := player.global_position + Vector3.UP * params.wall_ray_down_height
var ray_low := PhysicsRayQueryParameters3D.create(origin_low, origin_low + move_dir * dist)
@@ -292,22 +332,44 @@ func detect_wall_forward() -> Dictionary:
var collider = best_hit.get("collider")
if collider is PlayerMovementController or collider is CharacterBody3D or collider.has_method("take_damage"):
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
var origin_high := player.global_position + Vector3.UP * (params.wall_ray_up_height + params.wall_climb_vault_height_check)
var ray_high := PhysicsRayQueryParameters3D.create(origin_high, origin_high + move_dir * dist)
ray_high.exclude = [player.get_rid()]
var hit_high := space_state.intersect_ray(ray_high)
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
+310 -74
View File
@@ -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
@@ -508,11 +654,10 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
@rpc("any_peer", "call_local", "reliable")
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
@@ -705,8 +837,36 @@ 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)
# Write synced state for remote peers
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)
# 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,23 +970,56 @@ 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)
var wman = camera.get_node_or_null("WeaponManager")
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
@@ -962,7 +1183,11 @@ func _setup_hud() -> void:
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():
+34 -45
View File
@@ -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()
# 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
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
# 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,51 +113,32 @@ 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
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))
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:
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:
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
+23 -35
View File
@@ -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,55 +32,49 @@ 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()
var proj := current_hvel.dot(direction)
var base_dash_speed = machine.get_effective_speed(params.dash_speed)
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)
machine.player.velocity = direction * _exit_speed
_dash_speed = lerpf(base_dash_speed, maxf(current_speed, base_dash_speed), backward_factor)
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
+62 -38
View File
@@ -1,42 +1,65 @@
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
# If player releases the button, disconnect
if not machine.input_grapple:
machine.switch_to("air")
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
player.velocity = vel
machine.jump_cooldown_timer = params.jump_cooldown
machine.register_chain_mechanic("grapple_jump")
@@ -44,43 +67,44 @@ func update(delta: float) -> void:
if player.jump_player:
player.jump_player.play()
return
# 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
# 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
# Spring correction toward the sphere surface
var diff = current_dist - _rope_length
vel += dir_to_point * diff * params.grapple_spring_strength * 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
+75 -136
View File
@@ -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:
@@ -91,59 +72,20 @@ func update(delta: float) -> void:
player.velocity = vel
player.move_and_slide()
# ── 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
+83 -62
View File
@@ -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,54 +31,70 @@ 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
var player := machine.player
if player.slide_player and not player.slide_player.playing:
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()
# 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
if new_hvel.length_squared() > 0.01:
_slide_direction = new_hvel.normalized()
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()
# ── Gravity (for slopes) ──────────────────────────────────────────────
vel.x = hvel.x
vel.z = hvel.z
# ── 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
+27 -41
View File
@@ -15,10 +15,10 @@ 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
player.velocity.z = hvel.z * 0.5
@@ -30,7 +30,7 @@ func exit() -> void:
func update(delta: float) -> void:
elapsed += delta
var player := machine.player
if player.wallrun_player and not player.wallrun_player.playing:
player.wallrun_player.play()
@@ -39,15 +39,15 @@ 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
var looking_towards := look_dir.dot(-machine.wall_normal) > 0.0
var moving_sideways := false
if wish_dir.length_squared() > 0.01:
moving_sideways = wish_dir.dot(-machine.wall_normal) <= cos(deg_to_rad(params.wall_climb_max_angle))
if not looking_towards or moving_sideways:
# We are looking away or moving sideways, transition to wall run
# The wall run state will figure out tangent and side
@@ -58,22 +58,36 @@ func update(delta: float) -> void:
else:
_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:
# The forward lower ray missed, we fell off the wall completely
_detach()
return
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")
+5 -2
View File
@@ -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
+43 -45
View File
@@ -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:
@@ -45,7 +42,7 @@ func exit() -> void:
var rig = _get_camera_rig()
if rig:
rig.clear_wall_tilt()
if machine.player.wallrun_player:
machine.player.wallrun_player.stop()
@@ -53,61 +50,51 @@ 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
var vel: Vector3 = player.velocity
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
var h_look := Vector3(look_dir.x, 0.0, look_dir.z)
if h_look.length_squared() > 0.01:
jump_vel += h_look.normalized() * params.wall_run_jump_horizontal
jump_vel.y = params.wall_run_auto_jump_speed
player.velocity = jump_vel
machine.wall_normal = Vector3.ZERO
@@ -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")
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://d07u4xreb41rf
+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dhu2es4slyh2i
+168 -119
View File
@@ -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")
+13 -1
View File
@@ -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]
+11
View File
@@ -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.
+84
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cpl4d7ndkc4ft
+93
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b1v4lq35py3gd
+15 -8
View File
@@ -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)
@@ -18,7 +18,17 @@ var _debug_ui_panel: PanelContainer
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")
-6
View File
@@ -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")
+136
View File
@@ -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!")
+317
View File
@@ -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 ===")
+468
View File
@@ -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()
+393
View File
@@ -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()
+171
View File
@@ -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()
+478
View File
@@ -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 ===")
+201
View File
@@ -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()
+31
View File
@@ -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()
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+3 -2
View File
@@ -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()
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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")