feat: add animated net gathering

This commit is contained in:
Alexander Sellite 2026-08-17 17:50:30 -04:00
parent b252b2f20c
commit f0ddcccaad
38 changed files with 1298 additions and 76 deletions

View file

@ -0,0 +1,139 @@
extends SceneTree
const CHARACTER_SCENE: PackedScene = preload(
"res://art/exported/characters/base/netfishing_base_character.glb"
)
const EXPECTED_ANIMATIONS: Array[StringName] = [
&"casting",
&"casting_sit",
&"draw",
&"fighting",
&"fighting_sit",
&"fishing",
&"fishing_sit",
&"idle",
&"idle_show",
&"idle_sit",
&"idle_sit_show",
&"idle_sneak",
&"pocket_idle_idle",
&"pocket_idle_show",
&"pocket_show_idle",
&"pocket_show_show",
&"pocket_sit_idle_idle",
&"pocket_sit_idle_show",
&"pocket_sit_show_idle",
&"pocket_sit_show_show",
&"pocket_walking_idle_idle",
&"pocket_walking_idle_show",
&"pocket_walking_show_idle",
&"pocket_walking_show_show",
&"release",
&"release_sit",
&"retract",
&"retract_sit",
&"running",
&"running_show",
&"sneaking",
&"strike",
&"walking",
&"walking_show",
]
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var character := CHARACTER_SCENE.instantiate()
root.add_child(character)
var skeleton := _find_first(character, "Skeleton3D") as Skeleton3D
var animation_player := _find_first(
character,
"AnimationPlayer",
) as AnimationPlayer
assert(skeleton != null)
assert(animation_player != null)
var socket_index := skeleton.find_bone("rod_socket")
assert(socket_index >= 0)
var socket_direction_index := skeleton.find_bone("rod_socket.001")
assert(socket_direction_index >= 0)
assert(skeleton.get_bone_parent(socket_direction_index) == socket_index)
var animation_names := animation_player.get_animation_list()
assert(animation_names.size() == EXPECTED_ANIMATIONS.size())
for expected in EXPECTED_ANIMATIONS:
assert(expected in animation_names, "Missing animation: %s" % expected)
var animation := animation_player.get_animation(expected)
if expected in [&"casting", &"draw", &"idle_sneak", &"sneaking", &"strike"]:
var has_socket_track: bool = false
for track_index in animation.get_track_count():
has_socket_track = has_socket_track or String(
animation.track_get_path(track_index)
).contains("rod_socket")
assert(
has_socket_track,
"Animation %s lost its authored rod socket pose." % expected,
)
var arms := character.find_child(
"body_arms",
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_arm_vertices := _validate_mesh_weights(arms)
assert(blended_arm_vertices > 0, "body_arms has no blended joint weights.")
var body := character.find_child(
"body_main",
true,
false,
) as MeshInstance3D
assert(body != null)
_validate_mesh_weights(body)
print(
"Character rig validation: PASS "
+ "(animations=%d, blended arm vertices=%d)"
% [EXPECTED_ANIMATIONS.size(), blended_arm_vertices]
)
character.queue_free()
quit()
func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int:
var blended_vertices := 0
for surface_index in mesh_instance.mesh.get_surface_count():
var arrays := mesh_instance.mesh.surface_get_arrays(surface_index)
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
assert(not weights.is_empty())
assert(not vertices.is_empty())
var components_per_vertex := weights.size() / vertices.size()
assert(components_per_vertex == 4 or components_per_vertex == 8)
for offset in range(0, weights.size(), components_per_vertex):
var total := 0.0
var influence_count := 0
for component in range(components_per_vertex):
var weight := weights[offset + component]
total += weight
if weight > 0.000001:
influence_count += 1
assert(absf(total - 1.0) <= 0.0001)
assert(influence_count <= 2)
if influence_count == 2:
blended_vertices += 1
return blended_vertices
func _find_first(parent: Node, class_name_to_find: String) -> Node:
if parent.is_class(class_name_to_find):
return parent
for child in parent.get_children():
var found := _find_first(child, class_name_to_find)
if found != null:
return found
return null

View file

@ -0,0 +1 @@
uid://di6r1gphajq8u

View file

@ -0,0 +1,66 @@
extends SceneTree
const PlayerScene: PackedScene = preload("res://player/player.tscn")
const GatheringControllerType = preload(
"res://gathering/gathering_controller.gd"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var player := PlayerScene.instantiate() as Player
root.add_child(player)
player.set_process(false)
player.set_physics_process(false)
var controller := GatheringControllerType.new()
root.add_child(controller)
controller.set("_player", player)
var wall := _make_surface(
Vector3(2.0, 2.0, 0.1),
Vector3(0.0, 0.9, -1.5),
)
root.add_child(wall)
await physics_frame
controller.call("_update_marker_target")
var marker := controller.get("_marker") as MeshInstance3D
assert(marker != null)
assert(bool(controller.get("_marker_has_surface")))
assert(marker.global_basis.y.dot(Vector3.BACK) > 0.99)
wall.queue_free()
await process_frame
var floor := _make_surface(
Vector3(10.0, 0.2, 10.0),
Vector3(0.0, -0.1, -1.7),
)
root.add_child(floor)
await physics_frame
controller.call("_update_marker_target")
assert(bool(controller.get("_marker_has_surface")))
assert(marker.global_basis.y.dot(Vector3.UP) > 0.99)
assert(is_equal_approx(absf(marker.global_basis.determinant()), 1.0))
floor.queue_free()
controller.queue_free()
player.queue_free()
await process_frame
print("Gathering marker surface validation: PASS")
quit()
func _make_surface(size: Vector3, position: Vector3) -> StaticBody3D:
var body := StaticBody3D.new()
body.position = position
body.collision_layer = 1
body.collision_mask = 0
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = size
collision.shape = shape
body.add_child(collision)
return body

View file

@ -0,0 +1 @@
uid://ddsf88kuxgsqp

View file

@ -241,7 +241,25 @@ func _run_host() -> void:
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(2, false))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(3, false, false))
player.apply_authoritative_network_input(
_movement_input(3, false, true, true)
)
await create_timer(2.0).timeout
player.apply_authoritative_network_input(
_movement_input(4, false, false, true)
)
await create_timer(2.0).timeout
player.apply_authoritative_network_input(
_movement_input(5, false, false, true, &"draw", 1)
)
await create_timer(1.0).timeout
player.apply_authoritative_network_input(
_movement_input(6, false, false, true, &"strike", 2)
)
await create_timer(0.5).timeout
player.apply_authoritative_network_input(
_movement_input(7, false, false, false, &"", 3)
)
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
@ -284,16 +302,24 @@ func _run_client() -> void:
var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail
var saw_running: bool = false
var saw_walking: bool = false
var saw_sneaking: bool = false
var saw_idle_sneak: bool = false
var saw_net_draw: bool = false
var saw_net_strike: bool = false
var saw_animation_advance: bool = false
var saw_dust: bool = false
var previous_animation: StringName = &""
var previous_animation_position: float = -1.0
var observation_deadline: int = Time.get_ticks_msec() + 7000
var observation_deadline: int = Time.get_ticks_msec() + 14000
while Time.get_ticks_msec() < observation_deadline:
await process_frame
var current_animation: StringName = animation_player.current_animation
saw_running = saw_running or current_animation.begins_with("running")
saw_walking = saw_walking or current_animation.begins_with("walking")
saw_sneaking = saw_sneaking or current_animation == &"sneaking"
saw_idle_sneak = saw_idle_sneak or current_animation == &"idle_sneak"
saw_net_draw = saw_net_draw or current_animation == &"draw"
saw_net_strike = saw_net_strike or current_animation == &"strike"
var current_position: float = (
animation_player.current_animation_position
)
@ -306,10 +332,23 @@ func _run_client() -> void:
previous_animation = current_animation
previous_animation_position = current_position
saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0
if saw_running and saw_walking and saw_animation_advance and saw_dust:
if (
saw_running
and saw_walking
and saw_sneaking
and saw_idle_sneak
and saw_net_draw
and saw_net_strike
and saw_animation_advance
and saw_dust
):
break
assert(saw_running)
assert(saw_walking)
assert(saw_sneaking)
assert(saw_idle_sneak)
assert(saw_net_draw)
assert(saw_net_strike)
assert(saw_animation_advance)
assert(saw_dust)
print("Movement multiplayer client validation: PASS")
@ -325,6 +364,9 @@ func _movement_input(
sequence: int,
sprinting: bool,
moving: bool = true,
sneaking: bool = false,
action_id: StringName = &"",
action_sequence: int = 0,
) -> Dictionary:
return {
"sequence": sequence,
@ -332,12 +374,13 @@ func _movement_input(
"camera_yaw": 0.0,
"jump": false,
"sprint": sprinting,
"sneak": false,
"sneak": sneaking,
"slow_walk": false,
"sitting": false,
"casting": false,
"animation_action": (
NetworkPlayerAnimationProtocol.make_action_state()
"animation_action": NetworkPlayerAnimationProtocol.make_action_state(
action_id,
action_sequence,
),
}

View file

@ -0,0 +1,157 @@
extends SceneTree
const NetAttachmentScene: PackedScene = preload(
"res://player/net_attachment.tscn"
)
const FishingRodAttachmentScene: PackedScene = preload(
"res://player/fishing_rod_attachment.tscn"
)
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var host_skeleton := Skeleton3D.new()
host_skeleton.name = "HostSkeleton"
host_skeleton.add_bone("rod_socket")
host_skeleton.add_bone("rod_socket.001")
host_skeleton.set_bone_parent(1, 0)
host_skeleton.set_bone_rest(
1,
Transform3D(
Basis(Quaternion(Vector3.UP, Vector3.RIGHT)),
Vector3(0.0, 0.25, 0.0),
),
)
root.add_child(host_skeleton)
var attachment := NetAttachmentScene.instantiate() as BoneAttachment3D
host_skeleton.add_child(attachment)
await process_frame
await process_frame
_check(
attachment.bone_name == "rod_socket",
"Attachment moved off rod_socket.",
)
var catching_net := attachment.get_node_or_null("CatchingNet") as Node3D
_check(catching_net != null, "CatchingNet presentation root is missing.")
if catching_net == null:
_finish(host_skeleton)
return
_check(not catching_net.visible, "Net must start hidden.")
_check(
catching_net.position.is_zero_approx(),
"Net direction alignment moved its hand anchor.",
)
_check(
catching_net.basis.y.is_equal_approx(Vector3.UP),
"Net working axis no longer follows rod_socket.",
)
_check(
catching_net.basis.z.is_equal_approx(Vector3.RIGHT),
"Net rear face did not follow the socket direction guide.",
)
var model := catching_net.get_node_or_null("ModelMount/NetModel") as Node3D
_check(model != null, "Imported net model is missing.")
if model == null:
_finish(host_skeleton)
return
var skeleton := model.find_child("Skeleton3D", true, false) as Skeleton3D
_check(skeleton != null, "Imported net Skeleton3D is missing.")
if skeleton == null:
_finish(host_skeleton)
return
_check(
skeleton.find_bone("net_root") >= 0,
"Imported net_root bone is missing.",
)
_check(
skeleton.find_bone("net_rim") >= 0,
"Imported net_rim bone is missing.",
)
_check(
skeleton.find_bone("net_mid") >= 0,
"Imported net_mid bone is missing.",
)
_check(
skeleton.find_bone("net_tip") >= 0,
"Imported net_tip bone is missing.",
)
var spring := skeleton.get_node_or_null("NetSpring") as SpringBoneSimulator3D
_check(spring != null, "Net spring simulator was not created.")
if spring != null:
_check(spring.get_setting_count() == 1, "Net spring settings changed.")
_check(
spring.get_root_bone_name(0) == &"net_mid",
"Net spring root must remain net_mid.",
)
_check(
spring.get_end_bone_name(0) == &"net_tip",
"Net spring end must remain net_tip.",
)
_check(not spring.active, "Hidden net spring must be suspended.")
catching_net.visible = true
await process_frame
await process_frame
_check(spring.active, "Visible net spring must be active.")
_check(spring.get_joint_count(0) == 2, "Net spring chain is incomplete.")
catching_net.visible = false
await process_frame
_check(not spring.active, "Hidden net spring did not suspend.")
for child: Node in model.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
for surface_index: int in mesh_instance.mesh.get_surface_count():
var material := mesh_instance.mesh.surface_get_material(surface_index)
if material is BaseMaterial3D:
_check(
(material as BaseMaterial3D).texture_filter
== BaseMaterial3D.TEXTURE_FILTER_NEAREST,
"Net material does not use nearest texture sampling.",
)
var rod_attachment := (
FishingRodAttachmentScene.instantiate() as BoneAttachment3D
)
host_skeleton.add_child(rod_attachment)
await process_frame
var fishing_rod := rod_attachment.get_node("FishingRod") as Node3D
_check(
fishing_rod.position.is_zero_approx(),
"Rod direction alignment moved its hand anchor.",
)
_check(
fishing_rod.basis.y.is_equal_approx(Vector3.UP),
"Rod working axis no longer follows rod_socket.",
)
_check(
fishing_rod.basis.z.is_equal_approx(Vector3.RIGHT),
"Rod rear face did not follow the socket direction guide.",
)
_finish(host_skeleton)
func _finish(host_skeleton: Skeleton3D) -> void:
host_skeleton.queue_free()
if failures.is_empty():
print("Net attachment validation: PASS")
quit(0)
return
for failure: String in failures:
printerr("Net attachment validation: ", failure)
quit(1)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)

View file

@ -0,0 +1 @@
uid://clnr8ofu1fqw5

View file

@ -15,11 +15,24 @@ func _initialize() -> void:
1.25,
)
assert(NetworkPlayerAnimationProtocol.validate_state(emote))
var paused_action := NetworkPlayerAnimationProtocol.make_action_state(
&"strike",
18,
0.72,
true,
)
assert(NetworkPlayerAnimationProtocol.validate_action_state(paused_action))
assert(bool(paused_action["paused"]))
var future_locomotion := NetworkPlayerAnimationProtocol.make_state(
&"swimming_fast",
false,
)
assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion))
var sneaking := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_SNEAKING,
true,
)
assert(NetworkPlayerAnimationProtocol.validate_state(sneaking))
var extra_future_field: Dictionary = emote.duplicate(true)
extra_future_field["future_layer"] = {"id": "umbrella"}
assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field))
@ -45,5 +58,8 @@ func _initialize() -> void:
"elapsed": INF,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed))
var invalid_paused: Dictionary = emote.duplicate(true)
invalid_paused["action"]["paused"] = "yes"
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_paused))
print("Network player animation protocol validation: PASS")
quit()

View file

@ -0,0 +1,93 @@
extends SceneTree
const PlayerScene: PackedScene = preload("res://player/player.tscn")
const CrabBrown: FishData = preload(
"res://fish/species/crab_brown/crab_brown.tres"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var player := PlayerScene.instantiate() as Player
root.add_child(player)
await process_frame
player.set_process(false)
player.set_physics_process(false)
var animation_player := player.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
assert(animation_player != null)
for required_animation: StringName in [
&"draw",
&"idle_sneak",
&"sneaking",
&"strike",
]:
assert(animation_player.has_animation(required_animation))
Input.action_press(&"sneak")
player.velocity = Vector3.ZERO
player.call("_update_character_animation")
assert(animation_player.current_animation == &"idle_sneak")
player.velocity = Vector3(player.sneak_speed, 0.0, 0.0)
player.call("_update_character_animation")
assert(animation_player.current_animation == &"sneaking")
player.velocity = Vector3.ZERO
assert(player.begin_net_draw_visual())
assert(animation_player.current_animation == &"draw")
player.call("_on_character_animation_finished", &"draw")
assert(StringName(player.get("_animation_action_id")) == &"draw")
assert(animation_player.current_animation == &"draw")
assert(player.play_net_strike_visual())
assert(animation_player.current_animation == &"strike")
player.call("_on_character_animation_finished", &"strike")
assert(StringName(player.get("_animation_action_id")) == &"strike")
assert(bool(player.get("_animation_action_paused")))
assert(player.is_net_strike_held())
assert(player.release_net_strike_hold())
assert(StringName(player.get("_animation_action_id")).is_empty())
assert(animation_player.current_animation == &"idle_sneak")
Input.action_release(&"sneak")
assert(player.begin_net_draw_visual())
assert(animation_player.current_animation == &"casting")
player.cancel_net_action_visual()
assert(StringName(player.get("_animation_action_id")).is_empty())
assert(animation_player.current_animation == &"idle")
Input.action_press(&"sneak")
assert(player.play_net_strike_visual())
player.resolve_net_strike_visual(true)
var crab_catch := FishCatch.new()
crab_catch.fish = CrabBrown
crab_catch.fish_id = CrabBrown.id
crab_catch.weight_lb = CrabBrown.get_minimum_weight()
crab_catch.display_scale = CrabBrown.get_display_scale_for_weight(
crab_catch.weight_lb
)
crab_catch.sale_value = CrabBrown.get_sale_value_for_weight(
crab_catch.weight_lb
)
crab_catch.ensure_identity()
player.begin_catch_showcase(crab_catch)
assert(not bool(player.get("_showcase_animation_active")))
assert(player.get("_pending_net_showcase_catch") == crab_catch)
player.call("_on_character_animation_finished", &"strike")
assert(StringName(player.get("_animation_action_id")).is_empty())
assert(bool(player.get("_showcase_animation_active")))
var catch_display := player.get("_catch_display") as Node3D
assert(catch_display != null and catch_display.visible)
player.end_catch_showcase(Callable(), true)
Input.action_release(&"sneak")
player.queue_free()
await process_frame
print("Player gathering animation validation: PASS")
quit()

View file

@ -0,0 +1 @@
uid://cba1j1dfxk5t4

View file

@ -4,6 +4,12 @@ const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
const GatheringControllerType = preload(
"res://gathering/gathering_controller.gd"
)
const REDUCED_CRAB_PIXEL_SIZE: float = 0.00025
const REDUCED_CATCH_RING_RADIUS: float = 0.175
func _initialize() -> void:
@ -27,6 +33,12 @@ func _validate_catalog_statuses() -> void:
assert(brown.catch_data.logbook_section == FishData.LogbookSection.SHELLFISH)
assert(brown.population == 2)
assert(is_equal_approx(brown.charge_duration, 2.0))
assert(
is_equal_approx(
brown.sprite_pixel_size,
REDUCED_CRAB_PIXEL_SIZE,
)
)
_validate_quality_behavior(brown)
assert(is_equal_approx(brown.capture_respawn_min_seconds, 480.0))
assert(is_equal_approx(brown.capture_respawn_max_seconds, 720.0))
@ -47,6 +59,12 @@ func _validate_catalog_statuses() -> void:
assert(entry.is_valid())
assert(not entry.catch_data.active)
assert(not entry.is_available())
assert(
is_equal_approx(
entry.sprite_pixel_size,
REDUCED_CRAB_PIXEL_SIZE,
)
)
assert(entry.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(
entry.catch_data.logbook_section
@ -63,6 +81,15 @@ func _validate_catalog_statuses() -> void:
assert(captured_delay >= 480.0 and captured_delay <= 720.0)
assert(scared_delay >= 45.0 and scared_delay <= 90.0)
var gathering_controller := GatheringControllerType.new()
assert(
is_equal_approx(
gathering_controller.marker_radius,
REDUCED_CATCH_RING_RADIUS,
)
)
gathering_controller.free()
func _validate_quality_behavior(entry: GatherableData) -> void:
var prior_speed: float = -1.0