Make crab behavior reflect catch quality

This commit is contained in:
Alexander Sellite 2026-08-15 09:53:45 -04:00
parent abedd64782
commit 13fc64b80c
4 changed files with 161 additions and 11 deletions

View file

@ -2,6 +2,7 @@ class_name GatherableData
extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
@export var type_id: StringName
@export var catch_data: FishDataType
@ -12,6 +13,20 @@ const FishDataType = preload("res://fish/fish_data.gd")
@export_range(0.0, 10.0, 0.05) var movement_speed: float = 0.35
@export_range(0.1, 20.0, 0.1) var roam_radius: float = 3.5
@export_range(0.1, 20.0, 0.1) var scare_radius: float = 2.8
@export var quality_movement_speed_multipliers: Array[float] = [
1.0,
1.15,
1.35,
1.6,
2.0,
]
@export var quality_scare_radius_multipliers: Array[float] = [
1.0,
1.1,
1.25,
1.45,
1.7,
]
@export_range(0.1, 5.0, 0.05) var capture_radius: float = 0.7
@export_range(0.1, 10.0, 0.05) var interaction_range: float = 2.6
@export_range(0.1, 10.0, 0.05) var charge_duration: float = 2.0
@ -37,6 +52,12 @@ func is_valid() -> bool:
and movement_speed >= 0.0
and roam_radius > 0.0
and scare_radius > 0.0
and _quality_multipliers_are_valid(
quality_movement_speed_multipliers
)
and _quality_multipliers_are_valid(
quality_scare_radius_multipliers
)
and capture_radius > 0.0
and interaction_range > 0.0
and charge_duration > 0.0
@ -46,6 +67,20 @@ func is_valid() -> bool:
)
func get_movement_speed_for_quality(quality: int) -> float:
return movement_speed * _quality_multiplier(
quality_movement_speed_multipliers,
quality,
)
func get_scare_radius_for_quality(quality: int) -> float:
return scare_radius * _quality_multiplier(
quality_scare_radius_multipliers,
quality,
)
func get_respawn_delay(reason: StringName, rng: RandomNumberGenerator) -> float:
var minimum_seconds: float = capture_respawn_min_seconds
var maximum_seconds: float = capture_respawn_max_seconds
@ -59,3 +94,23 @@ func get_respawn_delay(reason: StringName, rng: RandomNumberGenerator) -> float:
func is_available() -> bool:
return catch_data != null and catch_data.active and is_valid()
func _quality_multiplier(values: Array[float], quality: int) -> float:
if values.size() != FishQualityType.TIER_COUNT:
return 1.0
var safe_quality: int = (
quality
if FishQualityType.is_valid(quality)
else FishQualityType.Tier.BORING
)
return maxf(values[safe_quality], 0.0)
func _quality_multipliers_are_valid(values: Array[float]) -> bool:
if values.size() != FishQualityType.TIER_COUNT:
return false
for multiplier: float in values:
if not is_finite(multiplier) or multiplier <= 0.0:
return false
return true

View file

@ -5,6 +5,7 @@ const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd")
const FishExperienceType = preload("res://fish/fish_experience.gd")
const FishPoolType = preload("res://fish/fish_pool.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
const FishSelectorType = preload("res://fish/fish_selector.gd")
const GatherableCatalogType = preload("res://gathering/gatherable_catalog.gd")
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
@ -278,6 +279,7 @@ func _spawn_entity(entry: GatherableDataType) -> void:
var position: Vector3 = _sample_surface_position(entry)
if not position.is_finite():
return
var quality: int = FishQualityType.roll(_rng)
var entity_id: String = _new_id("world")
var state: Dictionary = {
"entity_id": entity_id,
@ -286,6 +288,7 @@ func _spawn_entity(entry: GatherableDataType) -> void:
"position": position,
"target": _sample_surface_position(entry, position, entry.roam_radius),
"yaw": _rng.randf_range(-PI, PI),
"quality": quality,
"revision": 1,
"locked": false,
}
@ -308,6 +311,7 @@ func _update_host_entities(delta: float) -> void:
var entry := state.get("data") as GatherableDataType
if entry == null:
continue
var quality: int = _get_state_quality(state)
var position: Vector3 = state["position"]
var target: Vector3 = state["target"]
var horizontal_delta := Vector3(
@ -331,7 +335,7 @@ func _update_host_entities(delta: float) -> void:
)
if not horizontal_delta.is_zero_approx():
var step: float = minf(
entry.movement_speed * delta,
entry.get_movement_speed_for_quality(quality) * delta,
horizontal_delta.length(),
)
var direction: Vector3 = horizontal_delta.normalized()
@ -345,12 +349,17 @@ func _update_host_entities(delta: float) -> void:
state["yaw"] = atan2(-direction.x, -direction.z)
state["position"] = position
_entities[entity_id] = state
if _should_scare(entry, position):
if _should_scare(entry, position, quality):
_despawn_entity(entity_id, &"scared", true, true)
func _should_scare(entry: GatherableDataType, position: Vector3) -> bool:
var radius_squared: float = entry.scare_radius * entry.scare_radius
func _should_scare(
entry: GatherableDataType,
position: Vector3,
quality: int,
) -> bool:
var scare_radius: float = entry.get_scare_radius_for_quality(quality)
var radius_squared: float = scare_radius * scare_radius
for peer_id: int in _spawn_service.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if (
@ -571,9 +580,7 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
if not error.is_empty():
_send_interaction_result(peer_id, request_id, false, error)
return
var selector := FishSelectorType.new()
selector.begin_roll()
var fish_catch: FishCatch = selector.create_catch(entry.catch_data)
var fish_catch: FishCatch = _create_catch_for_state(entry, state)
if fish_catch == null or not fish_catch.is_valid():
_send_interaction_result(peer_id, request_id, false, "The catch could not be recorded.")
return
@ -600,6 +607,35 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
receive_capacity_probe.rpc_id(peer_id, probe)
func _create_catch_for_state(
entry: GatherableDataType,
state: Dictionary,
) -> FishCatch:
var selector := FishSelectorType.new()
selector.begin_roll()
var fish_catch: FishCatch = selector.create_catch(entry.catch_data)
if fish_catch == null:
return null
var quality: int = _get_state_quality(state)
fish_catch.quality = quality
fish_catch.sale_value = FishQualityType.apply_sale_value(
entry.catch_data.get_sale_value_for_weight(fish_catch.weight_lb),
quality,
)
return fish_catch
func _get_state_quality(state: Dictionary) -> int:
var quality: int = int(
state.get("quality", FishQualityType.Tier.BORING)
)
return (
quality
if FishQualityType.is_valid(quality)
else FishQualityType.Tier.BORING
)
@rpc(
"any_peer",
"call_remote",

View file

@ -28,7 +28,7 @@ func _run_host() -> void:
assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
assert(session.set_host_open(true))
await _wait_for_population(service)
_validate_population(service)
_validate_population(service, true)
var remote_peer_id: int = 0
var join_deadline: int = Time.get_ticks_msec() + 20000
@ -51,7 +51,7 @@ func _run_host() -> void:
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
_validate_population(service)
_validate_population(service, true)
_validate_respawn_budget(service)
print("World spawn multiplayer host validation: PASS")
session.disconnect_session("")
@ -82,7 +82,7 @@ func _run_client() -> void:
NetworkProtocol.WORLD_SPAWN_CAPABILITY
))
await _wait_for_population(service)
_validate_population(service)
_validate_population(service, false)
print("World spawn multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
@ -101,7 +101,7 @@ func _wait_for_population(service: Node) -> void:
assert(false, "Timed out waiting for the world spawn population.")
func _validate_population(service: Node) -> void:
func _validate_population(service: Node, expect_authoritative_state: bool) -> void:
var entities: Dictionary = service.get("_entities")
var presentations: Dictionary = service.get("_presentations")
assert(entities.size() == EXPECTED_POPULATION)
@ -112,6 +112,13 @@ func _validate_population(service: Node) -> void:
assert(typeof(position) == TYPE_VECTOR3)
assert((position as Vector3).is_finite())
assert((position as Vector3).y > 0.08)
if expect_authoritative_state:
var quality: int = int(state.get("quality", -1))
assert(FishQuality.is_valid(quality))
var entry := state.get("data") as GatherableData
assert(entry != null)
assert(entry.get_movement_speed_for_quality(quality) > 0.0)
assert(entry.get_scare_radius_for_quality(quality) > 0.0)
func _validate_respawn_budget(service: Node) -> void:

View file

@ -27,6 +27,7 @@ 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))
_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))
assert(is_equal_approx(brown.scare_respawn_min_seconds, 45.0))
@ -63,6 +64,57 @@ func _validate_catalog_statuses() -> void:
assert(scared_delay >= 45.0 and scared_delay <= 90.0)
func _validate_quality_behavior(entry: GatherableData) -> void:
var prior_speed: float = -1.0
var prior_scare_radius: float = -1.0
for quality: int in FishQuality.TIER_COUNT:
var movement_speed: float = entry.get_movement_speed_for_quality(
quality
)
var scare_radius: float = entry.get_scare_radius_for_quality(quality)
assert(movement_speed > prior_speed)
assert(scare_radius > prior_scare_radius)
prior_speed = movement_speed
prior_scare_radius = scare_radius
assert(
is_equal_approx(
entry.get_movement_speed_for_quality(FishQuality.Tier.BORING),
entry.movement_speed,
)
)
assert(
is_equal_approx(
entry.get_movement_speed_for_quality(FishQuality.Tier.SHINY),
entry.movement_speed * 2.0,
)
)
assert(
is_equal_approx(
entry.get_scare_radius_for_quality(FishQuality.Tier.SHINY),
entry.scare_radius * 1.7,
)
)
var service := NetworkWorldSpawnService.new()
var spawned_catch := service.call(
"_create_catch_for_state",
entry,
{"quality": FishQuality.Tier.EXCEPTIONAL},
) as FishCatch
assert(spawned_catch != null and spawned_catch.is_valid())
assert(spawned_catch.quality == FishQuality.Tier.EXCEPTIONAL)
assert(
spawned_catch.sale_value
== FishQuality.apply_sale_value(
entry.catch_data.get_sale_value_for_weight(
spawned_catch.weight_lb
),
FishQuality.Tier.EXCEPTIONAL,
)
)
spawned_catch = null
service.free()
func _validate_envelopes() -> void:
var entity: Dictionary = {
"entity_id": "world:sample",