Add networked crab gathering prototype

This commit is contained in:
Alexander Sellite 2026-08-14 22:11:49 -04:00
parent f55663c105
commit a62cce6404
56 changed files with 2692 additions and 56 deletions

View file

@ -10,6 +10,7 @@ const WORM_MAX_STACK: int = 10
const FISH_FINDER_ID: StringName = &"fish_finder" const FISH_FINDER_ID: StringName = &"fish_finder"
const MAGNET_ID: StringName = &"magnet" const MAGNET_ID: StringName = &"magnet"
const BATTERIES_ID: StringName = &"batteries" const BATTERIES_ID: StringName = &"batteries"
const CRAB_NET_ID: StringName = &"crab_net"
const ITEM_PRICES: Dictionary[StringName, int] = { const ITEM_PRICES: Dictionary[StringName, int] = {
&"worms": 1, &"worms": 1,
@ -26,6 +27,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = {
FISH_FINDER_ID: 500, FISH_FINDER_ID: 500,
MAGNET_ID: 250, MAGNET_ID: 250,
BATTERIES_ID: 15, BATTERIES_ID: 15,
CRAB_NET_ID: 50,
} }
const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
&"snails": 400, &"snails": 400,
@ -46,6 +48,7 @@ const ITEM_ORDER: Array[StringName] = [
&"the_standby", &"the_standby",
FISH_FINDER_ID, FISH_FINDER_ID,
MAGNET_ID, MAGNET_ID,
CRAB_NET_ID,
&"coffee", &"coffee",
&"energy_drink", &"energy_drink",
&"snack", &"snack",
@ -108,6 +111,7 @@ static func is_permanent_unlock(
item.is_lure() item.is_lure()
or item_id == FISH_FINDER_ID or item_id == FISH_FINDER_ID
or item_id == MAGNET_ID or item_id == MAGNET_ID
or item_id == CRAB_NET_ID
or item is FishingRodDataType or item is FishingRodDataType
) )
) )

View file

@ -24,6 +24,17 @@ enum Rarity {
LEGENDARY, LEGENDARY,
} }
enum CollectionMethod {
FISHING,
NET,
DIGGING,
}
enum LogbookSection {
AUTOMATIC,
SHELLFISH,
}
@export var id: StringName @export var id: StringName
@export var display_name: String @export var display_name: String
@export_category("Developer Catalog") @export_category("Developer Catalog")
@ -32,6 +43,9 @@ enum Rarity {
@export_range(1, 999999, 1) var catalog_number: int = 1 @export_range(1, 999999, 1) var catalog_number: int = 1
@export var collection_group: StringName @export var collection_group: StringName
@export_multiline var logbook_fact: String @export_multiline var logbook_fact: String
@export var collection_method: CollectionMethod = CollectionMethod.FISHING
@export var logbook_section: LogbookSection = LogbookSection.AUTOMATIC
@export var habitat_label: String = ""
@export_category("Fishing") @export_category("Fishing")
@export_flags("Fresh Water", "Salt Water") @export_flags("Fresh Water", "Salt Water")
var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK
@ -53,9 +67,12 @@ var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK
func is_selectable() -> bool: func is_selectable() -> bool:
return active and is_valid_catalog_entry()
func is_valid_catalog_entry() -> bool:
return ( return (
active not id.is_empty()
and not id.is_empty()
and base_catch_weight > 0.0 and base_catch_weight > 0.0
and catch_profile != null and catch_profile != null
and weight_min_lb > 0.0 and weight_min_lb > 0.0
@ -69,6 +86,16 @@ func is_selectable() -> bool:
) )
func is_fishable() -> bool:
return is_selectable() and collection_method == CollectionMethod.FISHING
func get_habitat_label() -> String:
if not habitat_label.strip_edges().is_empty():
return habitat_label.strip_edges().to_lower()
return WaterType.label(get_primary_water_type()).to_lower()
func is_allowed_in_water(type: WaterType.Type) -> bool: func is_allowed_in_water(type: WaterType.Type) -> bool:
return (allowed_water_types & WaterType.mask_for(type)) != 0 return (allowed_water_types & WaterType.mask_for(type)) != 0

View file

@ -46,7 +46,7 @@ func select_fish(
var weights: Array[float] = [] var weights: Array[float] = []
var total_weight: float = 0.0 var total_weight: float = 0.0
for fish: FishDataType in pool.candidates: for fish: FishDataType in pool.candidates:
if fish == null or not fish.is_selectable(): if fish == null or not fish.is_fishable():
continue continue
if not fish.is_allowed_in_water(context.water_type): if not fish.is_allowed_in_water(context.water_type):
continue continue

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,9 @@ active = false
catalog_number = 3902 catalog_number = 3902
collection_group = &"Marine crabs" collection_group = &"Marine crabs"
logbook_fact = "blue crabs swim with flattened rear legs shaped like paddles. bright blue claws and an olive shell conceal a quick, defensive temperament." logbook_fact = "blue crabs swim with flattened rear legs shaped like paddles. bright blue claws and an olive shell conceal a quick, defensive temperament."
collection_method = 1
logbook_section = 1
habitat_label = "shallow coastal water"
allowed_water_types = 2 allowed_water_types = 2
rarity = 0 rarity = 0
base_catch_weight = 1.15 base_catch_weight = 1.15

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmn3rafvxtnb1"
path="res://.godot/imported/crab_brown.png-4241f8408bf426d406e45f4cce16bbc8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/crab_brown/crab_brown.png"
dest_files=["res://.godot/imported/crab_brown.png-4241f8408bf426d406e45f4cce16bbc8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View file

@ -0,0 +1,27 @@
[gd_resource type="Resource" script_class="FishData" load_steps=4 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
[ext_resource type="Texture2D" path="res://fish/species/crab_brown/crab_brown.png" id="3_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"crab_brown"
display_name = "brown crab"
active = true
catalog_number = 3906
collection_group = &"Marine crabs"
logbook_fact = "brown crabs hide among rocks and sand along sheltered coasts. broad claws help them feed on shellfish and other small shore animals."
collection_method = 1
logbook_section = 1
habitat_label = "dry beach sand"
allowed_water_types = 2
rarity = 0
base_catch_weight = 1.0
catch_profile = ExtResource("2_profile")
weight_min_lb = 0.5
weight_max_lb = 3.0
sell_value_min = 5
sell_value_max = 15
sell_value_curve = 1.0
display_texture = ExtResource("3_texture")

View file

@ -17,6 +17,9 @@ active = false
catalog_number = 3903 catalog_number = 3903
collection_group = &"Marine crabs" collection_group = &"Marine crabs"
logbook_fact = "dungeness crabs bury beneath sand with only their eyes and antennae exposed. broad toothed shells protect them along cool pacific coasts." logbook_fact = "dungeness crabs bury beneath sand with only their eyes and antennae exposed. broad toothed shells protect them along cool pacific coasts."
collection_method = 1
logbook_section = 1
habitat_label = "shallow coastal sand"
allowed_water_types = 2 allowed_water_types = 2
rarity = 1 rarity = 1
base_catch_weight = 0.65 base_catch_weight = 0.65

View file

@ -17,6 +17,9 @@ active = false
catalog_number = 3901 catalog_number = 3901
collection_group = &"Marine crabs" collection_group = &"Marine crabs"
logbook_fact = "atlantic ghost crabs dash across beaches on long pale legs and retreat into deep burrows. raised eyes scan nearly every direction." logbook_fact = "atlantic ghost crabs dash across beaches on long pale legs and retreat into deep burrows. raised eyes scan nearly every direction."
collection_method = 1
logbook_section = 1
habitat_label = "dry beach sand"
allowed_water_types = 2 allowed_water_types = 2
rarity = 2 rarity = 2
base_catch_weight = 0.28 base_catch_weight = 0.28

View file

@ -42,6 +42,7 @@ const FishingSurfaceResolverType = preload(
"res://fishing/fishing_surface_resolver.gd" "res://fishing/fishing_surface_resolver.gd"
) )
const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload( const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd" "res://world/world_weather_service.gd"
@ -504,6 +505,13 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if not event.is_action("fish_primary"): if not event.is_action("fish_primary"):
return return
var selected_item: ItemDataType = _get_active_item()
if (
state == FishingState.READY
and selected_item != null
and selected_item.item_id == FishingShopStockType.CRAB_NET_ID
):
return
if event.is_pressed(): if event.is_pressed():
match state: match state:
FishingState.READY: FishingState.READY:
@ -737,6 +745,36 @@ func refresh_active_item_status() -> void:
status_changed.emit("") status_changed.emit("")
func report_external_status(message: String) -> void:
status_changed.emit(message)
func present_external_catch(fish_catch: FishCatchType) -> bool:
if (
fish_catch == null
or not fish_catch.is_valid()
or state != FishingState.READY
or _local_player == null
):
return false
_active_player = _local_player
_pending_catch = fish_catch
_active_player.set_movement_enabled(false)
state = FishingState.SHOWING_CATCH
_showcase_ready = true
_showcase_outcome_completed = true
_put_away_press_armed = false
_active_player.begin_catch_showcase(fish_catch)
showcase_changed.emit(
fish_catch.fish.display_name,
fish_catch.fish.get_rarity_name(),
fish_catch.weight_lb,
fish_catch.quality,
true,
)
return true
func _update_cast_charge(delta: float) -> void: func _update_cast_charge(delta: float) -> void:
if state != FishingState.AIMING_CAST: if state != FishingState.AIMING_CAST:
return return

View file

@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3]
[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"]
[ext_resource type="Resource" path="res://fish/species/crab_blue/crab_blue.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"crab_blue"
catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = -0.8
population = 4

View file

@ -0,0 +1,26 @@
[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3]
[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"]
[ext_resource type="Resource" path="res://fish/species/crab_brown/crab_brown.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"crab_brown"
catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = 0.08
population = 2
movement_speed = 0.35
roam_radius = 3.5
scare_radius = 2.8
capture_radius = 0.7
interaction_range = 2.6
charge_duration = 2.0
sprite_pixel_size = 0.001
sprite_tilt_degrees = -45.0
capture_respawn_min_seconds = 480.0
capture_respawn_max_seconds = 720.0
scare_respawn_min_seconds = 45.0
scare_respawn_max_seconds = 90.0
minimum_respawn_spacing_seconds = 180.0

View file

@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3]
[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"]
[ext_resource type="Resource" path="res://fish/species/crab_dungeness/crab_dungeness.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"crab_dungeness"
catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = -0.5
population = 4

View file

@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3]
[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"]
[ext_resource type="Resource" path="res://fish/species/crab_ghost/crab_ghost.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"crab_ghost"
catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
surface_materials = Array[StringName]([&"sand"])
minimum_surface_y = 0.08
population = 4

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=6 format=3]
[ext_resource type="Script" path="res://gathering/gatherable_catalog.gd" id="1_catalog"]
[ext_resource type="Resource" path="res://gathering/catalog/crab_brown.tres" id="2_brown"]
[ext_resource type="Resource" path="res://gathering/catalog/crab_ghost.tres" id="3_ghost"]
[ext_resource type="Resource" path="res://gathering/catalog/crab_blue.tres" id="4_blue"]
[ext_resource type="Resource" path="res://gathering/catalog/crab_dungeness.tres" id="5_dungeness"]
[resource]
script = ExtResource("1_catalog")
entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness")]

View file

@ -0,0 +1,23 @@
class_name GatherableCatalog
extends Resource
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
@export var entries: Array[GatherableDataType] = []
func get_entry(type_id: StringName) -> GatherableDataType:
if type_id.is_empty():
return null
for entry: GatherableDataType in entries:
if entry != null and entry.type_id == type_id:
return entry
return null
func get_available_entries() -> Array[GatherableDataType]:
var available: Array[GatherableDataType] = []
for entry: GatherableDataType in entries:
if entry != null and entry.is_available():
available.append(entry)
return available

View file

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

View file

@ -0,0 +1,61 @@
class_name GatherableData
extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
@export var type_id: StringName
@export var catch_data: FishDataType
@export var required_tool_id: StringName
@export var surface_materials: Array[StringName] = []
@export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08
@export_range(0, 64, 1) var population: int = 0
@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_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
@export_range(0.1, 5.0, 0.01) var sprite_pixel_size: float = 0.001
@export_range(-90.0, 90.0, 1.0) var sprite_tilt_degrees: float = -45.0
@export_category("Respawn Budget")
@export_range(0.0, 3600.0, 1.0) var capture_respawn_min_seconds: float = 480.0
@export_range(0.0, 3600.0, 1.0) var capture_respawn_max_seconds: float = 720.0
@export_range(0.0, 3600.0, 1.0) var scare_respawn_min_seconds: float = 45.0
@export_range(0.0, 3600.0, 1.0) var scare_respawn_max_seconds: float = 90.0
@export_range(0.0, 3600.0, 1.0) var minimum_respawn_spacing_seconds: float = 180.0
func is_valid() -> bool:
return (
not type_id.is_empty()
and catch_data != null
and catch_data.is_valid_catalog_entry()
and catch_data.collection_method != FishDataType.CollectionMethod.FISHING
and not required_tool_id.is_empty()
and not surface_materials.is_empty()
and population > 0
and movement_speed >= 0.0
and roam_radius > 0.0
and scare_radius > 0.0
and capture_radius > 0.0
and interaction_range > 0.0
and charge_duration > 0.0
and sprite_pixel_size > 0.0
and capture_respawn_max_seconds >= capture_respawn_min_seconds
and scare_respawn_max_seconds >= scare_respawn_min_seconds
)
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
if reason == &"scared":
minimum_seconds = scare_respawn_min_seconds
maximum_seconds = scare_respawn_max_seconds
if rng == null or maximum_seconds <= minimum_seconds:
return minimum_seconds
return rng.randf_range(minimum_seconds, maximum_seconds)
func is_available() -> bool:
return catch_data != null and catch_data.active and is_valid()

View file

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

View file

@ -0,0 +1,247 @@
class_name GatheringController
extends Node3D
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const NetworkWorldSpawnServiceType = preload(
"res://network/network_world_spawn_service.gd"
)
signal status_changed(message: String)
@export_range(0.5, 5.0, 0.05) var marker_distance: float = 1.7
@export_range(0.1, 2.0, 0.05) var marker_radius: float = 0.7
@export_flags_3d_physics var terrain_collision_mask: int = 1
@export_range(0.5, 20.0, 0.5) var ray_height: float = 5.0
@export_range(0.5, 20.0, 0.5) var ray_depth: float = 8.0
var _player: Player
var _bag: PlayerBag
var _hotbar: PlayerHotbar
var _fishing_spot: FishingSpotType
var _service: NetworkWorldSpawnServiceType
var _marker: MeshInstance3D
var _marker_invalid_material: StandardMaterial3D
var _marker_valid_material: StandardMaterial3D
var _marker_position: Vector3
var _marker_has_surface: bool = false
var _target_entity_id: String = ""
var _charging: bool = false
var _charge_elapsed: float = 0.0
var _charge_duration: float = 2.0
var _request_id: String = ""
var _gameplay_input_enabled: bool = false
func _ready() -> void:
_build_marker()
set_process_unhandled_input(true)
func setup(
player: Player,
bag: PlayerBag,
hotbar: PlayerHotbar,
fishing_spot: FishingSpotType,
service: NetworkWorldSpawnServiceType,
) -> void:
_player = player
_bag = bag
_hotbar = hotbar
_fishing_spot = fishing_spot
_service = service
_service.local_interaction_finished.connect(
_on_interaction_finished
)
func set_gameplay_input_enabled(enabled: bool) -> void:
_gameplay_input_enabled = enabled
if not enabled:
_cancel_charge()
func is_net_selected() -> bool:
return (
_bag != null
and _hotbar != null
and _hotbar.get_selected_item_id()
== FishingShopStockType.CRAB_NET_ID
and _bag.owns_item(FishingShopStockType.CRAB_NET_ID)
)
func _process(delta: float) -> void:
var input_available: bool = (
_gameplay_input_enabled
and is_net_selected()
and _player != null
and _player.is_local_control_enabled()
and _fishing_spot != null
and _fishing_spot.can_change_hotbar_selection()
)
if not input_available:
if _charging:
_cancel_charge()
_set_marker_visible(false)
return
if not _charging:
_set_marker_visible(false)
return
_charge_elapsed += delta
_update_marker_target()
_update_target_entity()
var target_entry: GatherableData = _service.get_entry_for_entity(
_target_entity_id
)
if target_entry != null:
_charge_duration = target_entry.charge_duration
var fully_charged: bool = _charge_elapsed >= _charge_duration
var valid_target: bool = (
fully_charged
and not _target_entity_id.is_empty()
and _player.is_sneaking()
)
_marker.material_override = (
_marker_valid_material if valid_target else _marker_invalid_material
)
_set_marker_visible(_marker_has_surface)
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action("fish_primary"):
return
if event.is_pressed():
if (
_charging
or not _gameplay_input_enabled
or not is_net_selected()
or _fishing_spot == null
or not _fishing_spot.can_change_hotbar_selection()
):
return
_request_id = _service.begin_local_interaction()
if _request_id.is_empty():
return
_charging = true
_charge_elapsed = 0.0
_charge_duration = maxf(
_service.get_charge_duration_for_tool(
FishingShopStockType.CRAB_NET_ID
),
0.1,
)
_update_marker_target()
_update_target_entity()
get_viewport().set_input_as_handled()
return
if not _charging:
return
var fully_charged: bool = _charge_elapsed >= _charge_duration
if (
fully_charged
and _marker_has_surface
and not _target_entity_id.is_empty()
and _player.is_sneaking()
):
_service.finish_local_interaction(
_request_id,
_target_entity_id,
_marker_position,
)
else:
_service.cancel_local_interaction(_request_id)
status_changed.emit(
"Sneak close, pull the net all the way back, and line up the marker."
)
_reset_charge_state()
get_viewport().set_input_as_handled()
func _update_marker_target() -> void:
_marker_has_surface = false
if _player == null or get_world_3d() == null:
return
var facing: Vector3 = _player.get_facing_direction()
facing.y = 0.0
if facing.is_zero_approx():
facing = -_player.global_basis.z
facing.y = 0.0
if facing.is_zero_approx():
return
facing = facing.normalized()
var query_center: Vector3 = _player.global_position + facing * marker_distance
var query := PhysicsRayQueryParameters3D.create(
query_center + Vector3.UP * ray_height,
query_center - Vector3.UP * ray_depth,
terrain_collision_mask,
)
query.collide_with_areas = false
query.collide_with_bodies = true
var hit: Dictionary = get_world_3d().direct_space_state.intersect_ray(query)
if hit.is_empty():
return
var position: Variant = hit.get("position")
if typeof(position) != TYPE_VECTOR3:
return
_marker_position = (position as Vector3) + Vector3.UP * 0.045
_marker.global_position = _marker_position
_marker_has_surface = true
func _update_target_entity() -> void:
_target_entity_id = (
_service.find_capture_target(
_marker_position,
FishingShopStockType.CRAB_NET_ID,
)
if _marker_has_surface and _service != null
else ""
)
func _cancel_charge() -> void:
if _charging and not _request_id.is_empty() and _service != null:
_service.cancel_local_interaction(_request_id)
_reset_charge_state()
func _reset_charge_state() -> void:
_charging = false
_charge_elapsed = 0.0
_request_id = ""
_target_entity_id = ""
_marker_has_surface = false
_set_marker_visible(false)
func _on_interaction_finished(accepted: bool, message: String) -> void:
_reset_charge_state()
if not accepted or not message.is_empty():
status_changed.emit(message)
func _build_marker() -> void:
_marker_invalid_material = StandardMaterial3D.new()
_marker_invalid_material.albedo_color = Color(0.86, 0.24, 0.19, 0.9)
_marker_invalid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_marker_valid_material = StandardMaterial3D.new()
_marker_valid_material.albedo_color = Color(0.25, 0.9, 0.36, 0.95)
_marker_valid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
var ring := TorusMesh.new()
ring.inner_radius = marker_radius * 0.78
ring.outer_radius = marker_radius
ring.rings = 16
ring.ring_segments = 24
_marker = MeshInstance3D.new()
_marker.name = "GatheringTargetMarker"
_marker.mesh = ring
_marker.material_override = _marker_invalid_material
_marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_marker.visible = false
add_child(_marker)
func _set_marker_visible(value: bool) -> void:
if _marker != null:
_marker.visible = value

View file

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

View file

@ -0,0 +1,113 @@
class_name WorldGatherable
extends Node3D
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
var entity_id: String = ""
var type_id: StringName
var data: GatherableDataType
var _sprite: Sprite3D
var _target_position: Vector3
var _target_yaw: float = 0.0
var _has_state: bool = false
var _despawning: bool = false
func configure(
configured_entity_id: String,
configured_data: GatherableDataType,
position: Vector3,
yaw: float,
) -> void:
entity_id = configured_entity_id
data = configured_data
type_id = data.type_id if data != null else StringName()
_ensure_visual()
if data != null and data.catch_data != null:
_sprite.texture = data.catch_data.display_texture
_sprite.pixel_size = data.sprite_pixel_size
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
apply_network_state(position, yaw, true)
func apply_network_state(
position: Vector3,
yaw: float,
immediate: bool = false,
) -> void:
if _despawning or not position.is_finite() or not is_finite(yaw):
return
_target_position = position
_target_yaw = yaw
if immediate or not _has_state:
global_position = position
rotation.y = yaw
_has_state = true
func play_despawn(with_dust: bool) -> void:
if _despawning:
return
_despawning = true
if with_dust:
_emit_dust()
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.set_trans(Tween.TRANS_QUAD)
tween.set_ease(Tween.EASE_IN)
if _sprite != null:
tween.tween_property(_sprite, "position:y", -0.3, 0.3)
tween.tween_property(_sprite, "scale", Vector3(0.75, 0.75, 0.75), 0.3)
tween.chain().tween_callback(queue_free)
func _process(delta: float) -> void:
if not _has_state or _despawning:
return
global_position = global_position.lerp(
_target_position,
1.0 - exp(-10.0 * delta),
)
rotation.y = lerp_angle(
rotation.y,
_target_yaw,
1.0 - exp(-8.0 * delta),
)
func _ensure_visual() -> void:
if _sprite != null:
return
_sprite = Sprite3D.new()
_sprite.name = "GatherableSprite"
_sprite.position.y = 0.22
_sprite.shaded = false
_sprite.double_sided = true
_sprite.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
add_child(_sprite)
func _emit_dust() -> void:
var particles := CPUParticles3D.new()
particles.name = "DustPoof"
particles.amount = 9
particles.lifetime = 0.55
particles.one_shot = true
particles.explosiveness = 1.0
particles.direction = Vector3.UP
particles.spread = 58.0
particles.gravity = Vector3(0.0, -2.4, 0.0)
particles.initial_velocity_min = 0.65
particles.initial_velocity_max = 1.25
particles.scale_amount_min = 0.7
particles.scale_amount_max = 1.35
var dust_material := StandardMaterial3D.new()
dust_material.albedo_color = Color(0.58, 0.46, 0.31, 0.85)
dust_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
dust_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
var dust_mesh := BoxMesh.new()
dust_mesh.size = Vector3(0.1, 0.1, 0.1)
dust_mesh.material = dust_material
particles.mesh = dust_mesh
add_child(particles)
particles.emitting = true

View file

@ -0,0 +1 @@
uid://3t8ygawpkgwj

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
[resource]
script = ExtResource("1_item")
item_id = &"crab_net"
display_name = "crab net"
description = "a hand net for catching crabs and other small shore animals."
active = true
category = 1
stackable = false
max_stack = 1
usable = false
equippable = true
hotbar_allowed = true

View file

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=38 format=3] [gd_resource type="Resource" script_class="ItemCatalog" load_steps=39 format=3]
[ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"] [ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"]
[ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"] [ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"]
@ -37,7 +37,8 @@
[ext_resource type="Resource" path="res://items/catalog/rods/oddity_string_rod.tres" id="36_oddity"] [ext_resource type="Resource" path="res://items/catalog/rods/oddity_string_rod.tres" id="36_oddity"]
[ext_resource type="Resource" path="res://items/catalog/rods/aurora_rod.tres" id="37_aurora"] [ext_resource type="Resource" path="res://items/catalog/rods/aurora_rod.tres" id="37_aurora"]
[ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"] [ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"]
[ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet")] items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net")]

View file

@ -90,7 +90,7 @@ static func generate_daily_jobs(
)) ))
var species: Array[FishDataType] = [] var species: Array[FishDataType] = []
for fish: FishDataType in candidates: for fish: FishDataType in candidates:
if fish != null and fish.is_selectable(): if fish != null and fish.is_fishable():
species.append(fish) species.append(fish)
if not species.is_empty(): if not species.is_empty():
species.sort_custom( species.sort_custom(

View file

@ -454,7 +454,7 @@ func _generate_host_board(cycle: int) -> void:
var candidates: Array[FishDataType] = [] var candidates: Array[FishDataType] = []
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_selectable(): if fish != null and fish.is_fishable():
candidates.append(fish) candidates.append(fish)
var schedule_anchor_index: int = _current_weather_segment() var schedule_anchor_index: int = _current_weather_segment()
var allow_weather_jobs: bool = ( var allow_weather_jobs: bool = (
@ -688,7 +688,7 @@ func _registered_species_count() -> int:
var count: int = 0 var count: int = 0
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_selectable(): if fish != null and fish.is_fishable():
count += 1 count += 1
return count return count
@ -727,7 +727,7 @@ func _matches_canonical_board(board: Dictionary) -> bool:
var candidates: Array[FishDataType] = [] var candidates: Array[FishDataType] = []
if _catalog != null: if _catalog != null:
for fish: FishDataType in _catalog.candidates: for fish: FishDataType in _catalog.candidates:
if fish != null and fish.is_selectable(): if fish != null and fish.is_fishable():
candidates.append(fish) candidates.append(fish)
var anchor_index: int = int(board.get("schedule_anchor_index", -1)) var anchor_index: int = int(board.get("schedule_anchor_index", -1))
var allow_weather_jobs: bool = ( var allow_weather_jobs: bool = (

View file

@ -25,6 +25,7 @@ const ItemCatalogType = preload("res://items/item_catalog.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const ItemDataType = preload("res://items/item_data.gd") const ItemDataType = preload("res://items/item_data.gd")
const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopType = preload("res://ui/fishing_shop.gd")
const FishingShopInteractionType = preload( const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd" "res://world/fishing_shop_interaction.gd"
@ -100,6 +101,15 @@ const NetworkWorldWeatherServiceType = preload(
const RainAmbienceType = preload("res://world/rain_ambience.gd") const RainAmbienceType = preload("res://world/rain_ambience.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const NetworkJobServiceType = preload("res://network/network_job_service.gd") const NetworkJobServiceType = preload("res://network/network_job_service.gd")
const GatherableCatalogType = preload(
"res://gathering/gatherable_catalog.gd"
)
const GatheringControllerType = preload(
"res://gathering/gathering_controller.gd"
)
const NetworkWorldSpawnServiceType = preload(
"res://network/network_world_spawn_service.gd"
)
const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_SILENCE_DB: float = -80.0
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
@ -111,6 +121,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@export var pelican_buyer_profile: FishBuyerProfileType @export var pelican_buyer_profile: FishBuyerProfileType
@export var main_shop_buyer_profile: FishBuyerProfileType @export var main_shop_buyer_profile: FishBuyerProfileType
@export var item_catalog: ItemCatalogType @export var item_catalog: ItemCatalogType
@export var gatherable_catalog: GatherableCatalogType
@export_category("Title Music") @export_category("Title Music")
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0 @export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0 @export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
@ -150,6 +161,12 @@ const SHOP_PATTERN_SCALE: float = 1.75
) )
@onready var _player_jobs: PlayerJobServiceType = %PlayerJobService @onready var _player_jobs: PlayerJobServiceType = %PlayerJobService
@onready var _network_jobs: NetworkJobServiceType = %NetworkJobService @onready var _network_jobs: NetworkJobServiceType = %NetworkJobService
@onready var _network_world_spawns: NetworkWorldSpawnServiceType = (
%NetworkWorldSpawnService
)
@onready var _gathering_controller: GatheringControllerType = (
%GatheringController
)
@onready var _data_root: PlayerDataRoot = %PlayerDataRoot @onready var _data_root: PlayerDataRoot = %PlayerDataRoot
@onready var _identity_backups: IdentityBackupService = %IdentityBackupService @onready var _identity_backups: IdentityBackupService = %IdentityBackupService
@onready var _network_profile: NetworkProfilePreferencesType = ( @onready var _network_profile: NetworkProfilePreferencesType = (
@ -191,6 +208,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
) )
@onready var _players_root: Node3D = $Players @onready var _players_root: Node3D = $Players
@onready var _surface_drawings_root: Node3D = $SurfaceDrawings @onready var _surface_drawings_root: Node3D = $SurfaceDrawings
@onready var _world_gatherables_root: Node3D = $WorldGatherables
@onready var _title_background: ColorRect = %TitleBackground @onready var _title_background: ColorRect = %TitleBackground
@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop @onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
@onready var _shop_backdrop: ColorRect = %ShopBackdrop @onready var _shop_backdrop: ColorRect = %ShopBackdrop
@ -542,6 +560,33 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager, _save_manager,
_asset_reservations _asset_reservations
) )
_network_world_spawns.setup(
_network_session,
_player_spawn_service,
_test_world,
_world_gatherables_root,
gatherable_catalog,
fish_catalog,
_player.inventory,
_player.collection_log,
_player.cooler_capacity,
_player.experience,
_save_manager,
_network_item_use,
)
_gathering_controller.setup(
_player,
_player.bag,
_player.hotbar,
_fishing_spot,
_network_world_spawns,
)
_network_world_spawns.local_capture_received.connect(
_fishing_spot.present_external_catch
)
_gathering_controller.status_changed.connect(
_fishing_spot.report_external_status
)
_network_fish_showcase.setup( _network_fish_showcase.setup(
_network_session, _network_session,
_player_spawn_service, _player_spawn_service,
@ -1326,6 +1371,7 @@ func _set_gameplay_active(active: bool) -> void:
_player.set_camera_input_enabled(active) _player.set_camera_input_enabled(active)
_player.set_camera_active(active) _player.set_camera_active(active)
_fishing_spot.set_gameplay_input_enabled(active) _fishing_spot.set_gameplay_input_enabled(active)
_gathering_controller.set_gameplay_input_enabled(active)
_water_recovery.set_recovery_enabled(active) _water_recovery.set_recovery_enabled(active)
_game_ui.set_gameplay_ui_enabled(active) _game_ui.set_gameplay_ui_enabled(active)
_save_manager.set_autosave_enabled(active) _save_manager.set_autosave_enabled(active)
@ -1775,6 +1821,12 @@ func _on_active_hotbar_item_changed(
and item.is_available() and item.is_available()
and _player.bag.owns_item(item_id) and _player.bag.owns_item(item_id)
) )
var active_is_catching_net: bool = (
item_id == FishingShopStockType.CRAB_NET_ID
and item != null
and item.is_available()
and _player.bag.owns_item(item_id)
)
_player.set_active_fishing_rod( _player.set_active_fishing_rod(
item as FishingRodDataType if active_is_rod else null, item as FishingRodDataType if active_is_rod else null,
true, true,
@ -1783,6 +1835,7 @@ func _on_active_hotbar_item_changed(
item.icon if item != null else null, item.icon if item != null else null,
active_is_art_kit, active_is_art_kit,
) )
_player.set_active_catching_net(active_is_catching_net)
_game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit) _game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit)
_network_item_use.submit_local_equipped(item_id, active_is_rod or ( _network_item_use.submit_local_equipped(item_id, active_is_rod or (
item != null and _player.bag.owns_item(item_id) item != null and _player.bag.owns_item(item_id)

View file

@ -57,6 +57,9 @@
[ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] [ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"]
[ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"] [ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"]
[ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"] [ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"]
[ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"]
[ext_resource type="Script" path="res://network/network_world_spawn_service.gd" id="59_world_spawns"]
[ext_resource type="Script" path="res://gathering/gathering_controller.gd" id="60_gathering_controller"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"]
shader = ExtResource("40_title_water") shader = ExtResource("40_title_water")
@ -142,6 +145,7 @@ fish_catalog = ExtResource("6_pool")
pelican_buyer_profile = ExtResource("7_pelicans") pelican_buyer_profile = ExtResource("7_pelicans")
main_shop_buyer_profile = ExtResource("12_main_shop") main_shop_buyer_profile = ExtResource("12_main_shop")
item_catalog = ExtResource("11_items") item_catalog = ExtResource("11_items")
gatherable_catalog = ExtResource("58_gatherable_catalog")
[node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702] [node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702]
unique_name_in_owner = true unique_name_in_owner = true
@ -283,6 +287,10 @@ script = ExtResource("24_network_item")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("45_fish_showcase") script = ExtResource("45_fish_showcase")
[node name="NetworkWorldSpawnService" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("59_world_spawns")
[node name="NetworkSurfaceDrawingService" type="Node" parent="." unique_id=80122665] [node name="NetworkSurfaceDrawingService" type="Node" parent="." unique_id=80122665]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("46_surface_drawing") script = ExtResource("46_surface_drawing")
@ -378,6 +386,12 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081)
[node name="SurfaceDrawings" type="Node3D" parent="." unique_id=234671307] [node name="SurfaceDrawings" type="Node3D" parent="." unique_id=234671307]
[node name="WorldGatherables" type="Node3D" parent="."]
[node name="GatheringController" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("60_gathering_controller")
[node name="FishingSpot" parent="." unique_id=1461328864 instance=ExtResource("4_spot")] [node name="FishingSpot" parent="." unique_id=1461328864 instance=ExtResource("4_spot")]
unique_name_in_owner = true unique_name_in_owner = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.02, -18) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.02, -18)

View file

@ -4,6 +4,7 @@ extends Node
const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const MAX_LEDGER_ENTRIES: int = 64 const MAX_LEDGER_ENTRIES: int = 64
@ -292,6 +293,13 @@ func get_effects_for_peer(peer_id: int) -> PlayerItemEffects:
return avatar.item_effects if avatar != null else null return avatar.item_effects if avatar != null else null
func get_equipped_item_id(peer_id: int) -> StringName:
var state: Dictionary = _equipped_states.get(peer_id, {})
if state.is_empty() or not bool(state.get("owns_item", false)):
return StringName()
return StringName(str(state.get("item_id", "")))
func submit_local_equipped(item_id: StringName, owns_item: bool) -> void: func submit_local_equipped(item_id: StringName, owns_item: bool) -> void:
if _session == null or not _session.is_gameplay_session_active(): if _session == null or not _session.is_gameplay_session_active():
return return
@ -377,6 +385,10 @@ func _apply_equipped(data: Dictionary) -> void:
item.icon if item != null else null, item.icon if item != null else null,
item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]), item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]),
) )
avatar.set_active_catching_net(
item_id == FishingShopStockType.CRAB_NET_ID
and bool(data["owns_item"]),
)
equipped_state_changed.emit( equipped_state_changed.emit(
peer_id, StringName(str(data["item_id"])), int(data["category"]) peer_id, StringName(str(data["item_id"])), int(data["category"])
) )

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol class_name NetworkProtocol
extends RefCounted extends RefCounted
const PROTOCOL_VERSION: int = 4 const PROTOCOL_VERSION: int = 5
const GAME_BUILD: String = "prealpha" const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -25,6 +25,7 @@ const WORLD_TIME_CAPABILITY: String = "world_time_v1"
const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
const JOBS_CAPABILITY: String = "jobs_v1" const JOBS_CAPABILITY: String = "jobs_v1"
const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1"
enum RejectionCode { enum RejectionCode {
NONE, NONE,
@ -172,6 +173,7 @@ static func make_client_hello(
WORLD_TIME_CAPABILITY, WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY, WORLD_WEATHER_CAPABILITY,
JOBS_CAPABILITY, JOBS_CAPABILITY,
WORLD_SPAWN_CAPABILITY,
]), ]),
"cosmetic_snapshot": cosmetic_snapshot, "cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint, "identity_fingerprint": identity_fingerprint,
@ -313,6 +315,7 @@ static func make_server_hello(
WORLD_TIME_CAPABILITY, WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY, WORLD_WEATHER_CAPABILITY,
JOBS_CAPABILITY, JOBS_CAPABILITY,
WORLD_SPAWN_CAPABILITY,
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",

View file

@ -267,6 +267,7 @@ func _register_player_host() -> void:
NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.JOBS_CAPABILITY, NetworkProtocol.JOBS_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
]), ]),
) )
_registry.update_appearance(1, _local_appearance_snapshot) _registry.update_appearance(1, _local_appearance_snapshot)
@ -555,6 +556,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",

View file

@ -0,0 +1,94 @@
class_name NetworkWorldSpawnProtocol
extends RefCounted
const CAPABILITY: StringName = &"world_spawn_envelope_v1"
const ENVELOPE_VERSION: int = 1
const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL
const SNAPSHOT_CHANNEL: int = 4
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_EVENT_ID_LENGTH: int = 48
const MAX_ENTITY_ID_LENGTH: int = 128
const MAX_TYPE_ID_LENGTH: int = 96
const MAX_ENTITIES_PER_SNAPSHOT: int = 128
## Keeps unreliable movement envelopes below ENet's practical MTU while
## allowing reliable population envelopes to carry a larger initial state.
const SNAPSHOT_ENTITIES_PER_ENVELOPE: int = 4
static func make_envelope(
session_id: String,
sequence: int,
event_id: StringName,
payload: Dictionary,
) -> Dictionary:
return {
"envelope_version": ENVELOPE_VERSION,
"session_id": session_id,
"sequence": sequence,
"event_id": str(event_id),
"payload": payload.duplicate(true),
}
static func validate_envelope(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
return (
typeof(data.get("envelope_version")) == TYPE_INT
and int(data["envelope_version"]) == ENVELOPE_VERSION
and typeof(data.get("session_id")) == TYPE_STRING
and not str(data["session_id"]).is_empty()
and str(data["session_id"]).length() <= MAX_SESSION_ID_LENGTH
and typeof(data.get("sequence")) == TYPE_INT
and int(data["sequence"]) >= 0
and typeof(data.get("event_id")) == TYPE_STRING
and not str(data["event_id"]).is_empty()
and str(data["event_id"]).length() <= MAX_EVENT_ID_LENGTH
and typeof(data.get("payload")) == TYPE_DICTIONARY
)
static func validate_entity_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
var position: Variant = data.get("position")
if typeof(position) != TYPE_ARRAY or position.size() != 3:
return false
for component: Variant in position:
if typeof(component) not in [TYPE_FLOAT, TYPE_INT]:
return false
if not is_finite(float(component)):
return false
return (
typeof(data.get("entity_id")) == TYPE_STRING
and not str(data["entity_id"]).is_empty()
and str(data["entity_id"]).length() <= MAX_ENTITY_ID_LENGTH
and typeof(data.get("type_id")) == TYPE_STRING
and not str(data["type_id"]).is_empty()
and str(data["type_id"]).length() <= MAX_TYPE_ID_LENGTH
and typeof(data.get("yaw")) in [TYPE_FLOAT, TYPE_INT]
and is_finite(float(data["yaw"]))
and typeof(data.get("revision")) == TYPE_INT
and int(data["revision"]) >= 0
)
static func vector3_to_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
static func array_to_vector3(value: Variant) -> Vector3:
if typeof(value) != TYPE_ARRAY or value.size() != 3:
return Vector3(INF, INF, INF)
for component: Variant in value:
if typeof(component) not in [TYPE_FLOAT, TYPE_INT]:
return Vector3(INF, INF, INF)
if not is_finite(float(component)):
return Vector3(INF, INF, INF)
return _array_to_vector3(value)
static func _array_to_vector3(value: Array) -> Vector3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,55 @@
[gd_scene load_steps=7 format=3]
[sub_resource type="StandardMaterial3D" id="HandleMaterial"]
albedo_color = Color(0.25, 0.13, 0.055, 1)
roughness = 0.9
[sub_resource type="CylinderMesh" id="HandleMesh"]
material = SubResource("HandleMaterial")
top_radius = 0.025
bottom_radius = 0.035
height = 1.25
[sub_resource type="StandardMaterial3D" id="HoopMaterial"]
albedo_color = Color(0.34, 0.39, 0.4, 1)
metallic = 0.25
roughness = 0.65
[sub_resource type="TorusMesh" id="HoopMesh"]
material = SubResource("HoopMaterial")
inner_radius = 0.24
outer_radius = 0.29
rings = 12
ring_segments = 18
[sub_resource type="StandardMaterial3D" id="NetMaterial"]
transparency = 1
albedo_color = Color(0.65, 0.78, 0.75, 0.42)
shading_mode = 0
cull_mode = 2
[sub_resource type="CylinderMesh" id="NetMesh"]
material = SubResource("NetMaterial")
top_radius = 0.24
bottom_radius = 0.08
height = 0.34
radial_segments = 12
rings = 1
[node name="NetAttachment" type="BoneAttachment3D"]
bone_name = "rod_socket"
[node name="CatchingNet" type="Node3D" parent="."]
visible = false
[node name="Handle" type="MeshInstance3D" parent="CatchingNet"]
position = Vector3(0, 0.55, 0)
mesh = SubResource("HandleMesh")
[node name="Hoop" type="MeshInstance3D" parent="CatchingNet"]
position = Vector3(0, 1.17, 0)
mesh = SubResource("HoopMesh")
[node name="Bag" type="MeshInstance3D" parent="CatchingNet"]
position = Vector3(0, 1.0, 0)
mesh = SubResource("NetMesh")

View file

@ -31,6 +31,7 @@ const ControllerMappingManagerType = preload(
const FishingRodAttachmentScene = preload( const FishingRodAttachmentScene = preload(
"res://player/fishing_rod_attachment.tscn" "res://player/fishing_rod_attachment.tscn"
) )
const NetAttachmentScene = preload("res://player/net_attachment.tscn")
const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const HeldItemAttachmentScene = preload( const HeldItemAttachmentScene = preload(
"res://player/held_item_attachment.tscn" "res://player/held_item_attachment.tscn"
@ -382,6 +383,7 @@ var _remote_recovery_visual_origin: Vector3
var _remote_recovery_elapsed: float = 0.0 var _remote_recovery_elapsed: float = 0.0
var _target_zoom: float = 5.0 var _target_zoom: float = 5.0
var _showcase_rod_visibility: bool = true var _showcase_rod_visibility: bool = true
var _showcase_net_visibility: bool = false
var _remote_presentation_visible := true var _remote_presentation_visible := true
var _showcase_rod_state_stored: bool = false var _showcase_rod_state_stored: bool = false
var _showcase_visual_rotation: Vector3 var _showcase_visual_rotation: Vector3
@ -445,12 +447,14 @@ var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE
var _fishing_after_release_pending: bool = false var _fishing_after_release_pending: bool = false
var _retract_animation_completed: bool = true var _retract_animation_completed: bool = true
var _fishing_rod: Node3D var _fishing_rod: Node3D
var _catching_net: Node3D
var _fishing_rod_tip: Marker3D var _fishing_rod_tip: Marker3D
var _fishing_rod_model_mount: Node3D var _fishing_rod_model_mount: Node3D
var _fishing_rod_fallback_visual: GeometryInstance3D var _fishing_rod_fallback_visual: GeometryInstance3D
var _custom_fishing_rod_visual: Node3D var _custom_fishing_rod_visual: Node3D
var _active_fishing_rod_id: StringName var _active_fishing_rod_id: StringName
var _active_item_is_rod: bool = false var _active_item_is_rod: bool = false
var _active_item_is_net: bool = false
var _controller_mapping_manager: ControllerMappingManagerType var _controller_mapping_manager: ControllerMappingManagerType
var _sprint_dust_landing_ready: bool = false var _sprint_dust_landing_ready: bool = false
var _sprint_dust_airborne: bool = false var _sprint_dust_airborne: bool = false
@ -475,6 +479,7 @@ func _ready() -> void:
bag.contents_changed.connect(_on_bag_contents_changed) bag.contents_changed.connect(_on_bag_contents_changed)
_apply_presented_appearance() _apply_presented_appearance()
_initialize_fishing_rod() _initialize_fishing_rod()
_initialize_catching_net()
_target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom) _target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom)
_spring_arm.spring_length = _target_zoom _spring_arm.spring_length = _target_zoom
_camera.current = local_control_enabled _camera.current = local_control_enabled
@ -590,6 +595,23 @@ func _initialize_fishing_rod() -> void:
) as GeometryInstance3D ) as GeometryInstance3D
func _initialize_catching_net() -> void:
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
) as Skeleton3D
if skeleton == null:
push_error("Player character skeleton is unavailable for the net.")
return
var attachment := NetAttachmentScene.instantiate() as BoneAttachment3D
if attachment == null:
push_error("Catching net attachment could not be instantiated.")
return
skeleton.add_child(attachment)
_catching_net = attachment.get_node("CatchingNet") as Node3D
if _catching_net != null:
_catching_net.visible = false
func _initialize_held_item_attachment() -> void: func _initialize_held_item_attachment() -> void:
var skeleton := get_node_or_null( var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D" "Visuals/CharacterRig/CharacterRig/Skeleton3D"
@ -1765,6 +1787,19 @@ func is_local_control_enabled() -> bool:
return local_control_enabled return local_control_enabled
func is_sneaking() -> bool:
if local_control_enabled:
return (
_is_movement_input_enabled()
and Input.is_action_pressed("sneak")
)
return _network_sneak
func is_moving_horizontally() -> bool:
return Vector2(velocity.x, velocity.z).length_squared() > 0.01
func set_movement_enabled(enabled: bool) -> void: func set_movement_enabled(enabled: bool) -> void:
_movement_enabled = enabled _movement_enabled = enabled
if not enabled: if not enabled:
@ -1921,6 +1956,18 @@ func set_active_fishing_rod(
set_active_item_is_rod(rod != null and rod.is_available(), animate_transition) set_active_item_is_rod(rod != null and rod.is_available(), animate_transition)
func set_active_catching_net(should_show: bool) -> void:
_active_item_is_net = should_show
if _catching_net == null:
return
if _showcase_rod_state_stored:
_showcase_net_visibility = should_show
return
_catching_net.visible = (
should_show and not _has_held_show_item() and not _showcase_animation_active
)
func _apply_fishing_rod_model(rod: FishingRodDataType) -> void: func _apply_fishing_rod_model(rod: FishingRodDataType) -> void:
var next_id: StringName = rod.item_id if rod != null else StringName() var next_id: StringName = rod.item_id if rod != null else StringName()
if next_id == _active_fishing_rod_id: if next_id == _active_fishing_rod_id:
@ -1983,6 +2030,12 @@ func _apply_active_rod_visibility() -> void:
_showcase_rod_visibility = _active_item_is_rod _showcase_rod_visibility = _active_item_is_rod
return return
_fishing_rod.visible = _active_item_is_rod _fishing_rod.visible = _active_item_is_rod
if _catching_net != null:
_catching_net.visible = (
_active_item_is_net
and not _has_held_show_item()
and not _showcase_animation_active
)
func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: func set_active_art_kit(icon: Texture2D, should_show: bool) -> void:
@ -2011,6 +2064,8 @@ func set_active_art_kit(icon: Texture2D, should_show: bool) -> void:
if not previous_show_pose: if not previous_show_pose:
_held_art_kit_display.visible = false _held_art_kit_display.visible = false
_fishing_rod.visible = false _fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if item_changed: if item_changed:
_begin_pocket_visual( _begin_pocket_visual(
PocketVisualTarget.ART_KIT, PocketVisualTarget.ART_KIT,
@ -2059,6 +2114,8 @@ func set_held_fish(
if not previous_show_pose: if not previous_show_pose:
_held_fish_display.visible = false _held_fish_display.visible = false
_fishing_rod.visible = false _fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if item_changed: if item_changed:
_begin_pocket_visual( _begin_pocket_visual(
PocketVisualTarget.HELD_FISH, PocketVisualTarget.HELD_FISH,
@ -2283,8 +2340,13 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void:
_turn_showcase_toward_position(showcase_camera_position) _turn_showcase_toward_position(showcase_camera_position)
if not _showcase_rod_state_stored: if not _showcase_rod_state_stored:
_showcase_rod_visibility = _fishing_rod.visible _showcase_rod_visibility = _fishing_rod.visible
_showcase_net_visibility = (
_catching_net.visible if _catching_net != null else false
)
_showcase_rod_state_stored = true _showcase_rod_state_stored = true
_fishing_rod.visible = false _fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
_catch_sprite.texture = fish_catch.fish.display_texture _catch_sprite.texture = fish_catch.fish.display_texture
_catch_display.scale = ( _catch_display.scale = (
Vector3.ONE Vector3.ONE
@ -2302,8 +2364,13 @@ func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void:
_showcase_animation_active = true _showcase_animation_active = true
set_fishing_visual(false) set_fishing_visual(false)
_showcase_rod_visibility = _fishing_rod.visible _showcase_rod_visibility = _fishing_rod.visible
_showcase_net_visibility = (
_catching_net.visible if _catching_net != null else false
)
_showcase_rod_state_stored = true _showcase_rod_state_stored = true
_fishing_rod.visible = false _fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
_catch_sprite.texture = fish_catch.fish.display_texture _catch_sprite.texture = fish_catch.fish.display_texture
_catch_display.scale = ( _catch_display.scale = (
Vector3.ONE Vector3.ONE
@ -2343,7 +2410,10 @@ func end_catch_showcase(
_catch_sprite.texture = null _catch_sprite.texture = null
if _showcase_rod_state_stored: if _showcase_rod_state_stored:
_fishing_rod.visible = _showcase_rod_visibility _fishing_rod.visible = _showcase_rod_visibility
if _catching_net != null:
_catching_net.visible = _showcase_net_visibility
_showcase_rod_visibility = true _showcase_rod_visibility = true
_showcase_net_visibility = false
_showcase_rod_state_stored = false _showcase_rod_state_stored = false
if ( if (
not _showcase_visual_rotation_stored not _showcase_visual_rotation_stored

View file

@ -24,6 +24,7 @@ readonly -a QUICK_TESTS=(
"tests/terrain_blender_material_validation.gd" "tests/terrain_blender_material_validation.gd"
"tests/texture_sampling_validation.gd" "tests/texture_sampling_validation.gd"
"tests/world_time_validation.gd" "tests/world_time_validation.gd"
"tests/world_spawn_protocol_validation.gd"
"tests/world_weather_validation.gd" "tests/world_weather_validation.gd"
) )
@ -41,6 +42,7 @@ readonly -a HOST_TESTS=(
"tests/economy_regression_validation.gd" "tests/economy_regression_validation.gd"
"tests/fish_hotbar_showcase_validation.gd" "tests/fish_hotbar_showcase_validation.gd"
"tests/fishing_authority_validation.gd" "tests/fishing_authority_validation.gd"
"tests/gathering_showcase_validation.gd"
"tests/job_system_validation.gd" "tests/job_system_validation.gd"
"tests/surface_drawing_runtime_validation.gd" "tests/surface_drawing_runtime_validation.gd"
) )
@ -54,6 +56,7 @@ readonly -a NETWORK_TESTS=(
"tests/operator_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"
"tests/world_spawn_multiplayer_validation.gd"
) )
cleanup() { cleanup() {

View file

@ -48,8 +48,8 @@ func _run() -> void:
as PlayerAssetReservationService as PlayerAssetReservationService
) )
assert(player != null) assert(player != null)
assert(catalog != null and catalog.candidates.size() == 313) assert(catalog != null and catalog.candidates.size() == 314)
assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 53) assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 54)
assert(sale_service != null) assert(sale_service != null)
assert(shop_service != null) assert(shop_service != null)
assert(session != null and session.is_host()) assert(session != null and session.is_host())

View file

@ -121,7 +121,7 @@ func _validate_weight_based_display_scale() -> void:
func _validate_catalog_and_pools() -> void: func _validate_catalog_and_pools() -> void:
assert(Catalog.candidates.size() == 313) assert(Catalog.candidates.size() == 314)
assert(PondPool.candidates.size() == 19) assert(PondPool.candidates.size() == 19)
assert(OceanPool.candidates.size() == 34) assert(OceanPool.candidates.size() == 34)
var active_count: int = 0 var active_count: int = 0
@ -142,7 +142,7 @@ func _validate_catalog_and_pools() -> void:
inactive_count += 1 inactive_count += 1
assert(not fish.is_selectable()) assert(not fish.is_selectable())
assert(fish.display_texture == null) assert(fish.display_texture == null)
assert(active_count == 53) assert(active_count == 54)
assert(inactive_count == 260) assert(inactive_count == 260)
var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"bowfin") var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"bowfin")
assert(inactive_fish != null and not inactive_fish.active) assert(inactive_fish != null and not inactive_fish.active)
@ -173,14 +173,20 @@ func _validate_catalog_and_pools() -> void:
assert(OceanPool.get_fish_by_id(fish_id) == null) assert(OceanPool.get_fish_by_id(fish_id) == null)
assert(fresh_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) assert(fresh_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER))
assert(not fresh_fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) assert(not fresh_fish.is_allowed_in_water(WaterType.Type.SALT_WATER))
assert(LogbookCatalog.category_for(fresh_fish) == WaterType.Type.FRESH_WATER) assert(
LogbookCatalog.category_for(fresh_fish)
== LogbookCatalog.Category.FRESH_WATER
)
for fish_id: StringName in SALT_WATER_IDS: for fish_id: StringName in SALT_WATER_IDS:
var salt_fish: FishDataType = Catalog.get_fish_by_id(fish_id) var salt_fish: FishDataType = Catalog.get_fish_by_id(fish_id)
assert(OceanPool.get_fish_by_id(fish_id) == salt_fish) assert(OceanPool.get_fish_by_id(fish_id) == salt_fish)
assert(PondPool.get_fish_by_id(fish_id) == null) assert(PondPool.get_fish_by_id(fish_id) == null)
assert(salt_fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) assert(salt_fish.is_allowed_in_water(WaterType.Type.SALT_WATER))
assert(not salt_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) assert(not salt_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER))
assert(LogbookCatalog.category_for(salt_fish) == WaterType.Type.SALT_WATER) assert(
LogbookCatalog.category_for(salt_fish)
== LogbookCatalog.Category.SALT_WATER
)
for fish_id: StringName in CATFISH_IDS: for fish_id: StringName in CATFISH_IDS:
var fish: FishDataType = Catalog.get_fish_by_id(fish_id) var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
assert(fish != null) assert(fish != null)
@ -188,13 +194,19 @@ func _validate_catalog_and_pools() -> void:
assert(PondPool.get_fish_by_id(fish_id) == fish) assert(PondPool.get_fish_by_id(fish_id) == fish)
assert(OceanPool.get_fish_by_id(fish_id) == null) assert(OceanPool.get_fish_by_id(fish_id) == null)
assert(fish.availability.allowed_location_tags == [&"starter_pond"]) assert(fish.availability.allowed_location_tags == [&"starter_pond"])
assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER) assert(
LogbookCatalog.category_for(fish)
== LogbookCatalog.Category.FRESH_WATER
)
for fish_id: StringName in NEW_FRESH_WATER_IDS: for fish_id: StringName in NEW_FRESH_WATER_IDS:
var fish: FishDataType = Catalog.get_fish_by_id(fish_id) var fish: FishDataType = Catalog.get_fish_by_id(fish_id)
assert(fish != null and fish.is_selectable()) assert(fish != null and fish.is_selectable())
assert(PondPool.get_fish_by_id(fish_id) == fish) assert(PondPool.get_fish_by_id(fish_id) == fish)
assert(OceanPool.get_fish_by_id(fish_id) == null) assert(OceanPool.get_fish_by_id(fish_id) == null)
assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER) assert(
LogbookCatalog.category_for(fish)
== LogbookCatalog.Category.FRESH_WATER
)
var expected_values: Dictionary[StringName, Array] = { var expected_values: Dictionary[StringName, Array] = {
&"catfish_blue": [1, 1.25, 3.0, 12.0, 6, 9], &"catfish_blue": [1, 1.25, 3.0, 12.0, 6, 9],

View file

@ -181,7 +181,7 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true) var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0 invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 4) assert(NetworkProtocol.PROTOCOL_VERSION == 5)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert( assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY NetworkProtocol.FISH_QUALITY_CAPABILITY

View file

@ -25,7 +25,7 @@ func _run() -> void:
_validate_mail_round_trip() _validate_mail_round_trip()
_validate_collection_mastery() _validate_collection_mastery()
_validate_version_four_migration() _validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 4) assert(NetworkProtocol.PROTOCOL_VERSION == 5)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS") print("Fish quality validation: PASS")

View file

@ -0,0 +1,89 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18142
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
var session := main.get_node("%NetworkSession") as NetworkSession
assert(session.start_private_host(TEST_PORT))
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 8:
await process_frame
var player := main.get("_player") as Player
var fishing_spot := main.get_node("%FishingSpot") as FishingSpot
var catalog := main.get("fish_catalog") as FishPool
assert(player != null and fishing_spot != null and catalog != null)
assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0))
await process_frame
assert(player.hotbar.get_selected_item_id() == &"crab_net")
assert(bool(player.get("_active_item_is_net")))
var crab: FishData = catalog.get_fish_by_id(&"crab_brown")
assert(crab != null and crab.is_selectable())
var crab_catch := FishCatch.new()
crab_catch.fish = crab
crab_catch.fish_id = crab.id
crab_catch.weight_lb = crab.get_minimum_weight()
crab_catch.display_scale = crab.get_display_scale_for_weight(
crab_catch.weight_lb
)
crab_catch.sale_value = crab.get_sale_value_for_weight(
crab_catch.weight_lb
)
crab_catch.ensure_identity()
player.inventory.add_catch(crab_catch)
player.collection_log.mark_discovered(crab.id)
assert(fishing_spot.present_external_catch(crab_catch))
assert(fishing_spot.state == FishingSpot.FishingState.SHOWING_CATCH)
assert(not player.is_movement_enabled())
Input.action_release("fish_primary")
await process_frame
assert(bool(fishing_spot.get("_put_away_press_armed")))
var pocket_event := InputEventAction.new()
pocket_event.action = &"fish_primary"
pocket_event.pressed = true
Input.parse_input_event(pocket_event)
await process_frame
assert(fishing_spot.get("_pending_catch") == null)
var pocket_deadline: int = Time.get_ticks_msec() + 6000
while (
Time.get_ticks_msec() < pocket_deadline
and fishing_spot.state != FishingSpot.FishingState.READY
):
await process_frame
assert(fishing_spot.state == FishingSpot.FishingState.READY)
assert(player.is_movement_enabled())
assert(player.inventory.contains_catch_id(crab_catch.catch_id))
assert(bool(player.get("_active_item_is_net")))
print("Gathering showcase validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()

View file

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

View file

@ -108,9 +108,9 @@ func _validate_save_round_trip(
var player := main.get("_player") as Player var player := main.get("_player") as Player
var catalog := main.get("fish_catalog") as FishPool var catalog := main.get("fish_catalog") as FishPool
assert(catalog != null) assert(catalog != null)
assert(catalog.candidates.size() == 313) assert(catalog.candidates.size() == 314)
var active_species := LogbookCatalog.ordered_species(catalog.candidates) var active_species := LogbookCatalog.ordered_species(catalog.candidates)
assert(active_species.size() == 53) assert(active_species.size() == 54)
for index: int in 4: for index: int in 4:
_add_test_catch(player, active_species[index]) _add_test_catch(player, active_species[index])
assert(save_manager.save_now()) assert(save_manager.save_now())
@ -131,7 +131,7 @@ func _validate_save_round_trip(
assert(player.inventory.replace_all_catches(no_catches, 1)) assert(player.inventory.replace_all_catches(no_catches, 1))
assert(player.collection_log.replace_discovered_ids(no_discoveries)) assert(player.collection_log.replace_discovered_ids(no_discoveries))
assert(save_manager.load_player_data()) assert(save_manager.load_player_data())
assert(player.inventory.get_all_catches().size() == 53) assert(player.inventory.get_all_catches().size() == 54)
for fish: FishData in active_species: for fish: FishData in active_species:
assert(player.inventory.get_count(fish.id) == 1) assert(player.inventory.get_count(fish.id) == 1)
assert(player.collection_log.has_discovered(fish.id)) assert(player.collection_log.has_discovered(fish.id))

View file

@ -40,9 +40,9 @@ func _run() -> void:
func _validate_catalog() -> void: func _validate_catalog() -> void:
assert(CatalogResource.candidates.size() == 313) assert(CatalogResource.candidates.size() == 314)
var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates) var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates)
assert(ordered.size() == 53) assert(ordered.size() == 54)
var previous_number: int = 0 var previous_number: int = 0
var catalog_numbers: Dictionary[int, bool] = {} var catalog_numbers: Dictionary[int, bool] = {}
for fish: FishDataType in CatalogResource.candidates: for fish: FishDataType in CatalogResource.candidates:
@ -62,7 +62,7 @@ func _validate_catalog() -> void:
assert(LogbookCatalog.catalog_number(fish) == fish.catalog_number) assert(LogbookCatalog.catalog_number(fish) == fish.catalog_number)
previous_number = fish.catalog_number previous_number = fish.catalog_number
assert( assert(
LogbookCatalog.empty_state(WaterType.Type.SALT_WATER) LogbookCatalog.empty_state(LogbookCatalog.Category.SALT_WATER)
== "No saltwater catches cataloged yet." == "No saltwater catches cataloged yet."
) )
@ -78,7 +78,9 @@ func _validate_page() -> void:
page.setup(collection, inventory, CatalogResource) page.setup(collection, inventory, CatalogResource)
page.activate() page.activate()
await process_frame await process_frame
assert(page.get("_category") == WaterType.Type.FRESH_WATER) assert(
page.get("_category") == LogbookCatalog.Category.FRESH_WATER
)
assert((page.get("_catalog_grid") as GridContainer).columns == 4) assert((page.get("_catalog_grid") as GridContainer).columns == 4)
var initial_detail_body := page.get("_detail_body") as VBoxContainer var initial_detail_body := page.get("_detail_body") as VBoxContainer
assert(not initial_detail_body.get_parent() is ScrollContainer) assert(not initial_detail_body.get_parent() is ScrollContainer)
@ -89,14 +91,17 @@ func _validate_page() -> void:
var shared_material: Material var shared_material: Material
var silhouette_count: int = 0 var silhouette_count: int = 0
for category: WaterType.Type in [ for category: LogbookCatalog.Category in [
WaterType.Type.FRESH_WATER, LogbookCatalog.Category.FRESH_WATER,
WaterType.Type.SALT_WATER, LogbookCatalog.Category.SALT_WATER,
]: ]:
page.call("_select_category", category) page.call("_select_category", category)
await create_timer(0.25).timeout await create_timer(0.25).timeout
var entries: Dictionary = page.get("_entry_buttons") var entries: Dictionary = page.get("_entry_buttons")
assert(entries.size() == (19 if category == WaterType.Type.FRESH_WATER else 34)) assert(
entries.size()
== (19 if category == LogbookCatalog.Category.FRESH_WATER else 34)
)
for fish: FishDataType in LogbookCatalog.ordered_species( for fish: FishDataType in LogbookCatalog.ordered_species(
CatalogResource.candidates CatalogResource.candidates
): ):
@ -148,14 +153,20 @@ func _validate_page() -> void:
) )
assert(silhouette_count == 53) assert(silhouette_count == 53)
page.call("_select_category", WaterType.Type.OTHER) page.call("_select_category", LogbookCatalog.Category.SHELLFISH)
await create_timer(0.25).timeout
assert((page.get("_entry_buttons") as Dictionary).size() == 1)
assert(
(page.get("_entry_buttons") as Dictionary).has(&"unknown_3906")
)
page.call("_select_category", LogbookCatalog.Category.OTHER)
await create_timer(0.25).timeout await create_timer(0.25).timeout
assert((page.get("_entry_buttons") as Dictionary).is_empty()) assert((page.get("_entry_buttons") as Dictionary).is_empty())
assert( assert(
(page.get("_empty_state") as Label).text (page.get("_empty_state") as Label).text
== "No entries available." == "No entries available."
) )
page.call("_select_category", WaterType.Type.FRESH_WATER) page.call("_select_category", LogbookCatalog.Category.FRESH_WATER)
await create_timer(0.25).timeout await create_timer(0.25).timeout
var bluegill = CatalogResource.get_fish_by_id(&"bluegill") var bluegill = CatalogResource.get_fish_by_id(&"bluegill")

View file

@ -0,0 +1,168 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18141
const EXPECTED_POPULATION: int = 2
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("World spawn multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var service: Node = main.get_node("%NetworkWorldSpawnService")
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)
var remote_peer_id: int = 0
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
remote_peer_id = peer_id
break
assert(remote_peer_id > 1)
assert(session.peer_supports_capability(
remote_peer_id,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
))
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
_validate_population(service)
_validate_respawn_budget(service)
print("World spawn multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var service: Node = main.get_node("%NetworkWorldSpawnService")
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
assert(session.supports_server_capability(
NetworkProtocol.WORLD_SPAWN_CAPABILITY
))
await _wait_for_population(service)
_validate_population(service)
print("World spawn multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _wait_for_population(service: Node) -> void:
var deadline: int = Time.get_ticks_msec() + 12000
while Time.get_ticks_msec() < deadline:
await process_frame
if (service.get("_entities") as Dictionary).size() == EXPECTED_POPULATION:
return
assert(false, "Timed out waiting for the world spawn population.")
func _validate_population(service: Node) -> void:
var entities: Dictionary = service.get("_entities")
var presentations: Dictionary = service.get("_presentations")
assert(entities.size() == EXPECTED_POPULATION)
assert(presentations.size() == EXPECTED_POPULATION)
for state: Dictionary in entities.values():
assert(state.get("type_id") == &"crab_brown")
var position: Variant = state.get("position")
assert(typeof(position) == TYPE_VECTOR3)
assert((position as Vector3).is_finite())
assert((position as Vector3).y > 0.08)
func _validate_respawn_budget(service: Node) -> void:
var entities: Dictionary = service.get("_entities")
var entity_ids: Array = entities.keys()
assert(entity_ids.size() == EXPECTED_POPULATION)
for entity_id: Variant in entity_ids:
service.call(
"_despawn_entity",
str(entity_id),
&"captured",
false,
true,
)
assert((service.get("_entities") as Dictionary).is_empty())
var now: float = Time.get_ticks_msec() / 1000.0
var respawns: Array = service.get("_respawns")
assert(respawns.size() == EXPECTED_POPULATION)
for index: int in respawns.size():
var respawn: Dictionary = respawns[index]
var delay: float = float(respawn.get("due", 0.0)) - now
assert(delay >= 479.0 and delay <= 721.0)
respawn["due"] = now - 1.0
respawns[index] = respawn
service.call("_update_respawns")
assert((service.get("_entities") as Dictionary).size() == 1)
assert((service.get("_respawns") as Array).size() == 1)
var next_by_type: Dictionary = service.get("_next_respawn_by_type")
var next_allowed: float = float(next_by_type.get(&"crab_brown", 0.0))
assert(next_allowed - now >= 179.0)
service.call("_update_respawns")
assert((service.get("_entities") as Dictionary).size() == 1)
assert((service.get("_respawns") as Array).size() == 1)
next_by_type[&"crab_brown"] = now - 1.0
service.call("_update_respawns")
assert((service.get("_entities") as Dictionary).size() == 2)
assert((service.get("_respawns") as Array).is_empty())
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main

View file

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

View file

@ -0,0 +1,102 @@
extends SceneTree
const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
func _initialize() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 5)
assert(
NetworkWorldSpawnProtocol.CAPABILITY
== NetworkProtocol.WORLD_SPAWN_CAPABILITY
)
assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4)
_validate_catalog_statuses()
_validate_envelopes()
print("World spawn protocol validation: PASS")
quit()
func _validate_catalog_statuses() -> void:
var brown: GatherableData = Gatherables.get_entry(&"crab_brown")
assert(brown != null and brown.is_available())
assert(brown.catch_data.active)
assert(brown.catch_data.collection_method == FishData.CollectionMethod.NET)
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.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))
assert(is_equal_approx(brown.scare_respawn_max_seconds, 90.0))
assert(is_equal_approx(brown.minimum_respawn_spacing_seconds, 180.0))
assert(brown.required_tool_id == &"crab_net")
assert(FishCatalog.get_fish_by_id(&"crab_brown") == brown.catch_data)
assert(not brown.catch_data.is_fishable())
for type_id: StringName in [
&"crab_ghost",
&"crab_blue",
&"crab_dungeness",
]:
var entry: GatherableData = Gatherables.get_entry(type_id)
assert(entry != null)
assert(entry.is_valid())
assert(not entry.catch_data.active)
assert(not entry.is_available())
assert(entry.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(
entry.catch_data.logbook_section
== FishData.LogbookSection.SHELLFISH
)
var available: Array[GatherableData] = Gatherables.get_available_entries()
assert(available.size() == 1)
assert(available.front() == brown)
var rng := RandomNumberGenerator.new()
rng.seed = 24680
var captured_delay: float = brown.get_respawn_delay(&"captured", rng)
var scared_delay: float = brown.get_respawn_delay(&"scared", rng)
assert(captured_delay >= 480.0 and captured_delay <= 720.0)
assert(scared_delay >= 45.0 and scared_delay <= 90.0)
func _validate_envelopes() -> void:
var entity: Dictionary = {
"entity_id": "world:sample",
"type_id": "future_shellfish",
"position": [1.0, 2.0, 3.0],
"yaw": 0.5,
"revision": 7,
}
assert(NetworkWorldSpawnProtocol.validate_entity_state(entity))
var future_entity: Dictionary = entity.duplicate(true)
future_entity["future_state"] = {"buried": false}
assert(NetworkWorldSpawnProtocol.validate_entity_state(future_entity))
var envelope: Dictionary = NetworkWorldSpawnProtocol.make_envelope(
"session-test",
12,
&"future_spawn_type",
{"entity": entity, "future_payload": [1, 2, 3]},
)
assert(NetworkWorldSpawnProtocol.validate_envelope(envelope))
var malformed_position: Dictionary = entity.duplicate(true)
malformed_position["position"] = [1.0, "not-a-number", 3.0]
assert(
not NetworkWorldSpawnProtocol.validate_entity_state(
malformed_position
)
)
assert(
not NetworkWorldSpawnProtocol.array_to_vector3(
malformed_position["position"]
).is_finite()
)
var invalid_envelope: Dictionary = envelope.duplicate(true)
invalid_envelope["envelope_version"] = 0
assert(not NetworkWorldSpawnProtocol.validate_envelope(invalid_envelope))

View file

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

View file

@ -3,23 +3,48 @@ extends RefCounted
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
enum Category {
FRESH_WATER,
SALT_WATER,
OTHER,
SHELLFISH,
}
static func category_for(fish: FishDataType) -> WaterType.Type:
static func category_for(fish: FishDataType) -> Category:
if fish == null: if fish == null:
return WaterType.Type.OTHER return Category.OTHER
return fish.get_primary_water_type() if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH:
return Category.SHELLFISH
match fish.get_primary_water_type():
static func category_label(category: WaterType.Type) -> String:
return WaterType.label(category)
static func empty_state(category: WaterType.Type) -> String:
match category:
WaterType.Type.FRESH_WATER: WaterType.Type.FRESH_WATER:
return "No freshwater catches cataloged yet." return Category.FRESH_WATER
WaterType.Type.SALT_WATER: WaterType.Type.SALT_WATER:
return Category.SALT_WATER
_:
return Category.OTHER
static func category_label(category: Category) -> String:
match category:
Category.FRESH_WATER:
return "Fresh Water"
Category.SALT_WATER:
return "Salt Water"
Category.SHELLFISH:
return "Shellfish"
_:
return "Misc"
static func empty_state(category: Category) -> String:
match category:
Category.FRESH_WATER:
return "No freshwater catches cataloged yet."
Category.SALT_WATER:
return "No saltwater catches cataloged yet." return "No saltwater catches cataloged yet."
Category.SHELLFISH:
return "No shellfish cataloged yet."
_: _:
return "No entries available." return "No entries available."

View file

@ -50,7 +50,9 @@ const DETAIL_BOTTOM_INSET: float = 35.0
var _collection_log: CollectionLogType var _collection_log: CollectionLogType
var _inventory: FishInventoryType var _inventory: FishInventoryType
var _catalog: FishPoolType var _catalog: FishPoolType
var _category: WaterType.Type = WaterType.Type.FRESH_WATER var _category: LogbookCatalog.Category = (
LogbookCatalog.Category.FRESH_WATER
)
var _selected_id: StringName var _selected_id: StringName
var _selected_entry_key: StringName var _selected_entry_key: StringName
var _active: bool = false var _active: bool = false
@ -186,13 +188,14 @@ func _build_interface() -> void:
tabs.mouse_filter = Control.MOUSE_FILTER_PASS tabs.mouse_filter = Control.MOUSE_FILTER_PASS
tabs.add_theme_constant_override("separation", 6) tabs.add_theme_constant_override("separation", 6)
stack.add_child(tabs) stack.add_child(tabs)
for category: WaterType.Type in [ for category: LogbookCatalog.Category in [
WaterType.Type.FRESH_WATER, LogbookCatalog.Category.FRESH_WATER,
WaterType.Type.SALT_WATER, LogbookCatalog.Category.SALT_WATER,
WaterType.Type.OTHER, LogbookCatalog.Category.OTHER,
LogbookCatalog.Category.SHELLFISH,
]: ]:
var tab := OrganizerTabType.new() var tab := OrganizerTabType.new()
tab.custom_minimum_size = Vector2(124, 38) tab.custom_minimum_size = Vector2(90, 38)
tab.toggle_mode = true tab.toggle_mode = true
tab.text = LogbookCatalog.category_label(category) tab.text = LogbookCatalog.category_label(category)
tab.palette_index = int(category) tab.palette_index = int(category)
@ -518,7 +521,7 @@ func _add_entry_content(
content.add_child(name_label) content.add_child(name_label)
func _select_category(category: WaterType.Type) -> void: func _select_category(category: LogbookCatalog.Category) -> void:
if category == _category: if category == _category:
return return
_category = category _category = category
@ -717,7 +720,12 @@ func _build_known_details(fish: FishDataType) -> void:
facts_column.size_flags_stretch_ratio = 1.0 facts_column.size_flags_stretch_ratio = 1.0
facts_column.add_theme_constant_override("separation", 6) facts_column.add_theme_constant_override("separation", 6)
summary_columns.add_child(facts_column) summary_columns.add_child(facts_column)
var facts_heading := _field_label("fish facts", 16) var facts_heading := _field_label(
"shellfish facts"
if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH
else "fish facts",
16,
)
facts_column.add_child(facts_heading) facts_column.add_child(facts_heading)
var facts := _label(LogbookCatalog.facts_for(fish), 16) var facts := _label(LogbookCatalog.facts_for(fish), 16)
facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@ -745,8 +753,12 @@ func _build_known_details(fish: FishDataType) -> void:
) )
_add_detail_row( _add_detail_row(
left_stats, left_stats,
"body of water", (
WaterType.label(fish.get_primary_water_type()).to_lower(), "habitat"
if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH
else "body of water"
),
fish.get_habitat_label(),
) )
_add_detail_row( _add_detail_row(
left_stats, left_stats,

View file

@ -60,6 +60,89 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
minimum_up_dot: float = 0.6,
) -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
if material_names.is_empty():
return triangles
var terrain_root: Node = get_node_or_null(terrain_visual_root_path)
if terrain_root == null:
return triangles
for mesh_instance: MeshInstance3D in _collect_terrain_mesh_instances(
terrain_root
):
var mesh: Mesh = mesh_instance.mesh
if mesh == null:
continue
for surface_index: int in mesh.get_surface_count():
var material: Material = mesh_instance.get_active_material(
surface_index
)
if material == null or not material_names.has(
StringName(material.resource_name)
):
continue
var arrays: Array = mesh.surface_get_arrays(surface_index)
if arrays.size() <= Mesh.ARRAY_INDEX:
continue
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if vertices.is_empty():
continue
if indices.is_empty():
for vertex_index: int in range(0, vertices.size() - 2, 3):
_append_spawn_triangle(
triangles,
mesh_instance,
vertices[vertex_index],
vertices[vertex_index + 1],
vertices[vertex_index + 2],
minimum_global_y,
minimum_up_dot,
)
else:
for index_offset: int in range(0, indices.size() - 2, 3):
_append_spawn_triangle(
triangles,
mesh_instance,
vertices[indices[index_offset]],
vertices[indices[index_offset + 1]],
vertices[indices[index_offset + 2]],
minimum_global_y,
minimum_up_dot,
)
return triangles
func _append_spawn_triangle(
result: Array[PackedVector3Array],
mesh_instance: MeshInstance3D,
local_a: Vector3,
local_b: Vector3,
local_c: Vector3,
minimum_global_y: float,
minimum_up_dot: float,
) -> void:
var a: Vector3 = mesh_instance.to_global(local_a)
var b: Vector3 = mesh_instance.to_global(local_b)
var c: Vector3 = mesh_instance.to_global(local_c)
if (
a.y <= minimum_global_y
or b.y <= minimum_global_y
or c.y <= minimum_global_y
):
return
var cross: Vector3 = (b - a).cross(c - a)
if cross.length_squared() <= 0.0000001:
return
if absf(cross.normalized().dot(Vector3.UP)) < minimum_up_dot:
return
result.append(PackedVector3Array([a, b, c]))
func has_terrain_collision() -> bool: func has_terrain_collision() -> bool:
var collision_shape: CollisionShape3D = ( var collision_shape: CollisionShape3D = (
get_node_or_null(terrain_collision_shape_path) as CollisionShape3D get_node_or_null(terrain_collision_shape_path) as CollisionShape3D

View file

@ -61,6 +61,16 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _starter_island.get_saltwater_shoreline_mesh() return _starter_island.get_saltwater_shoreline_mesh()
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
) -> Array[PackedVector3Array]:
return _starter_island.get_spawn_surface_triangles(
material_names,
minimum_global_y,
)
func _get_regions() -> Array[WorldRegion]: func _get_regions() -> Array[WorldRegion]:
var regions: Array[WorldRegion] = [] var regions: Array[WorldRegion] = []
for child: Node in _regions_root.get_children(): for child: Node in _regions_root.get_children():