class_name Player extends CharacterBody3D ## Player-vs-player stacking. A rider standing on us would otherwise block our ## own move_and_slide (its collider reads as a slanted wall), so while carried we ## drop the players layer from our mask — the rider keeps colliding with us, the ## test is one-way. Carrying the rider along is CharacterBody3D's own ## moving-platform handling; adding it again here just launches them off. const PLAYERS_LAYER := 2 const CARRIED_LINGER := 0.1 ## The character model ships textured in red. Player 1 wears the blue variant, ## which exists only as a pair of base-color maps sitting beside the red ones ## under the same name — every other map (normal/roughness/metallic/height) is ## shared between the two, so albedo is all that gets swapped. const BLUE_SKIN_PLAYER_ID := 1 const RED_MATERIAL_PREFIX := "low_chara_mat_" const BLUE_MATERIAL_PREFIX := "low_blue_mat_" @export var player_id := 1 @export var gravity_vector := Vector3(0, -15, 0) @export var move_speed := 8.0 @export var accel := 50.0 @export var friction := 16.0 @export var jump_speed := 8.0 @export var air_control := 0.65 @export var coyote := 0.1 @export var buffer := 0.1 # Coyote time and jump buffer remaining, in seconds. var coyote_t := 0.0 var buffer_t := 0.0 # Time left on the one-way stacking exemption. See PLAYERS_LAYER. var carried_t := 0.0 ## Cleared when the run ends. Physics keeps running — the player still falls, ## slides to a stop and gets dragged by whatever is left of the rope — only ## input is ignored. var controls_enabled := true : set = set_controls_enabled # Drops the buffered jump on the way out, so a press from the frame before the # controls were cut does not still fire afterwards. func set_controls_enabled(value : bool) -> void: controls_enabled = value if not value: buffer_t = 0.0 func _ready() -> void: add_to_group("players") if player_id == BLUE_SKIN_PLAYER_ID: _apply_blue_skin(self) # Walks the model and re-points every surface that uses a red base-color map at # its blue counterpart. Surfaces without one (the visor, the hands the two # variants share) keep the material they were imported with. func _apply_blue_skin(node : Node) -> void: for child in node.get_children(): _apply_blue_skin(child) var mesh_instance := node as MeshInstance3D if mesh_instance == null or mesh_instance.mesh == null: return for surface in mesh_instance.mesh.get_surface_count(): var material := mesh_instance.get_active_material(surface) as BaseMaterial3D if material == null or material.albedo_texture == null: continue var blue_path := material.albedo_texture.resource_path.replace( RED_MATERIAL_PREFIX, BLUE_MATERIAL_PREFIX ) if not ResourceLoader.exists(blue_path): continue var blue_material := material.duplicate() as BaseMaterial3D blue_material.albedo_texture = load(blue_path) mesh_instance.set_surface_override_material(surface, blue_material) func _physics_process(delta : float) -> void: var up := Vector3.UP if gravity_vector.length_squared() < 0.0001 else -gravity_vector.normalized() up_direction = up velocity += gravity_vector * delta coyote_t = coyote if is_on_floor() else maxf(coyote_t - delta, 0.0) buffer_t = maxf(buffer_t - delta, 0.0) if controls_enabled and Input.is_action_just_pressed("p%d_jump" % player_id): buffer_t = buffer var input_x := Input.get_axis("p%d_left" % player_id, "p%d_right" % player_id) if controls_enabled else 0.0 var rate := accel if is_on_floor() else accel * air_control if absf(input_x) > 0.01: velocity.x = move_toward(velocity.x, input_x * move_speed, rate * delta) else: velocity.x = move_toward(velocity.x, 0.0, friction * delta) if buffer_t > 0.0 and coyote_t > 0.0: velocity = velocity.slide(up) + up * jump_speed buffer_t = 0.0 coyote_t = 0.0 carried_t = maxf(carried_t - delta, 0.0) set_collision_mask_value(PLAYERS_LAYER, carried_t <= 0.0) # plane lock: 2D platformer physics in a 3D scene. Sliding along an angled # collision normal can otherwise walk the player off the Z=0 plane. velocity.z = 0.0 move_and_slide() # Riding is handled by CharacterBody3D's own moving-platform support, so the # only thing to do here is tell whoever we landed on that it is carrying us. for i in get_slide_collision_count(): var collision := get_slide_collision(i) if collision.get_normal().dot(up) < 0.7: continue var below := collision.get_collider() as Player if below != null: below.carried_t = CARRIED_LINGER global_position.z = 0.0