Add gathering, unified inventory, and real-time world

This commit is contained in:
Alexander Sellite 2026-08-18 18:19:36 -04:00
parent 173371e6fc
commit fa57edca83
113 changed files with 6700 additions and 1307 deletions

View file

@ -11,6 +11,7 @@ const FISH_FINDER_ID: StringName = &"fish_finder"
const MAGNET_ID: StringName = &"magnet"
const BATTERIES_ID: StringName = &"batteries"
const CRAB_NET_ID: StringName = &"crab_net"
const STANDARD_SHOVEL_ID: StringName = &"standard_shovel"
const ITEM_PRICES: Dictionary[StringName, int] = {
&"worms": 1,
@ -28,6 +29,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = {
MAGNET_ID: 250,
BATTERIES_ID: 15,
CRAB_NET_ID: 50,
STANDARD_SHOVEL_ID: 75,
}
const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
&"snails": 400,
@ -49,6 +51,7 @@ const ITEM_ORDER: Array[StringName] = [
FISH_FINDER_ID,
MAGNET_ID,
CRAB_NET_ID,
STANDARD_SHOVEL_ID,
&"coffee",
&"energy_drink",
&"snack",
@ -116,6 +119,7 @@ static func is_permanent_unlock(
or item_id == FISH_FINDER_ID
or item_id == MAGNET_ID
or item_id == CRAB_NET_ID
or item_id == STANDARD_SHOVEL_ID
or item is FishingRodDataType
)
)

View file

@ -0,0 +1,21 @@
class_name ItemResalePolicy
extends RefCounted
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const ItemDataType = preload("res://items/item_data.gd")
static func is_sellable(item: ItemDataType) -> bool:
# Bait and lures live in their own tackle collection. Rods, tools, and
# utility gear are permanent equipment. The unified sell tray currently
# accepts ordinary consumable supplies only.
return item != null and item.is_available() and (
item.category == ItemDataType.Category.CONSUMABLE
)
static func get_unit_value(item: ItemDataType) -> int:
if not is_sellable(item):
return -1
var purchase_price := FishingShopStockType.get_price(item.item_id)
return purchase_price / 2 if purchase_price >= 0 else -1

View file

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://blw6lydoba8kd"
path="res://.godot/imported/beetle_stag_common.png-5eb7426d7dbda5083e39efc4cb980c8d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/beetle_stag_common/beetle_stag_common.png"
dest_files=["res://.godot/imported/beetle_stag_common.png-5eb7426d7dbda5083e39efc4cb980c8d.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,26 @@
[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/beetle_stag_common/beetle_stag_common.png" id="3_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"beetle_stag_common"
display_name = "common stag beetle"
active = true
catalog_number = 8001
collection_group = &"Beetles"
logbook_fact = "common stag beetles use broad jaws to wrestle rivals and cling to tree bark. adults feed on sap and other sweet plant juices."
collection_method = 1
habitat_label = "tree trunks"
allowed_water_types = 0
rarity = 0
base_catch_weight = 1.0
catch_profile = ExtResource("2_profile")
weight_min_lb = 0.02
weight_max_lb = 0.12
sell_value_min = 4
sell_value_max = 12
sell_value_curve = 1.0
display_texture = ExtResource("3_texture")

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://tmbkp1ohcavc"
path="res://.godot/imported/clam_manila.png-b04bc787291fc96749979a40380ba399.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://fish/species/clam_manila/clam_manila.png"
dest_files=["res://.godot/imported/clam_manila.png-b04bc787291fc96749979a40380ba399.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/clam_manila/clam_manila.png" id="3_texture"]
[resource]
script = ExtResource("1_fish_data")
id = &"clam_manila"
display_name = "Manila clam"
active = true
catalog_number = 6406
collection_group = &"Shellfish"
logbook_fact = "manila clams bury themselves in wet coastal sand and reveal their hiding places with small spurts of water."
collection_method = 2
logbook_section = 1
habitat_label = "wet beach sand"
allowed_water_types = 2
rarity = 0
base_catch_weight = 1.0
catch_profile = ExtResource("2_profile")
weight_min_lb = 0.05
weight_max_lb = 0.3
sell_value_min = 3
sell_value_max = 8
sell_value_curve = 1.0
display_texture = ExtResource("3_texture")

View file

@ -563,10 +563,10 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
return
if (
_is_cooler_full()
_is_inventory_full()
):
status_changed.emit(
"cooler full. sell fish before casting again."
"inventory full. store or sell something before casting."
)
get_viewport().set_input_as_handled()
return
@ -710,17 +710,15 @@ func _get_active_rod() -> FishingRodDataType:
return _get_active_item() as FishingRodDataType
func _is_cooler_full() -> bool:
func _is_inventory_full() -> bool:
return (
_cooler_capacity != null
and _local_inventory != null
and _local_inventory.get_all_catches().size()
>= _cooler_capacity.get_capacity()
_local_inventory != null
and not _local_inventory.can_accept_catch()
)
func _on_cooler_availability_changed() -> void:
if state == FishingState.READY and not _is_cooler_full():
if state == FishingState.READY and not _is_inventory_full():
refresh_active_item_status()
@ -1644,7 +1642,7 @@ func _build_network_evidence() -> Dictionary:
"bite_multiplier": 1.0,
"rarity_multipliers": rarity_multipliers,
"discovered_fish_ids": discovered_ids,
"capacity_available": not _is_cooler_full(),
"capacity_available": not _is_inventory_full(),
}

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/beetle_stag_common/beetle_stag_common.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"beetle_stag_common"
catch_data = ExtResource("2_catch")
required_tool_id = &"crab_net"
spawn_anchor_set_id = &"starter_reachable_tree_trunks"
population = 3
requires_sneaking = false
movement_speed = 0.0
roam_radius = 0.1
scare_radius = 0.1
capture_radius = 0.34
interaction_range = 2.8
charge_duration = 1.0
sprite_pixel_size = 0.005
sprite_tilt_degrees = 0.0
capture_respawn_min_seconds = 90.0
capture_respawn_max_seconds = 150.0
scare_respawn_min_seconds = 0.0
scare_respawn_max_seconds = 0.0
minimum_respawn_spacing_seconds = 30.0

View file

@ -0,0 +1,27 @@
[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/clam_manila/clam_manila.tres" id="2_catch"]
[resource]
script = ExtResource("1_data")
type_id = &"clam_manila"
catch_data = ExtResource("2_catch")
required_tool_id = &"standard_shovel"
diggable_area_id = &"starter_beach"
minimum_surface_y = -0.44
population = 3
presentation_mode = 1
requires_sneaking = false
active_lifetime_seconds = 10.0
movement_speed = 0.0
roam_radius = 0.1
scare_radius = 0.1
capture_radius = 0.38
interaction_range = 2.4
charge_duration = 0.55
capture_respawn_min_seconds = 8.0
capture_respawn_max_seconds = 14.0
scare_respawn_min_seconds = 0.0
scare_respawn_max_seconds = 0.0
minimum_respawn_spacing_seconds = 4.0

View file

@ -1,11 +1,13 @@
[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=6 format=3]
[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=8 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"]
[ext_resource type="Resource" path="res://gathering/catalog/clam_manila.tres" id="6_clam"]
[ext_resource type="Resource" path="res://gathering/catalog/beetle_stag_common.tres" id="7_beetle"]
[resource]
script = ExtResource("1_catalog")
entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness")]
entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness"), ExtResource("6_clam"), ExtResource("7_beetle")]

View file

@ -4,12 +4,22 @@ extends Resource
const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd")
enum PresentationMode {
VISIBLE_CREATURE,
WATER_SPURT,
}
@export var type_id: StringName
@export var catch_data: FishDataType
@export var required_tool_id: StringName
@export var surface_materials: Array[StringName] = []
@export var diggable_area_id: StringName
@export var spawn_anchor_set_id: 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 var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE
@export var requires_sneaking: bool = true
@export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.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
@ -41,17 +51,33 @@ const FishQualityType = preload("res://fish/fish_quality.gd")
func is_valid() -> bool:
var uses_diggable_area: bool = not diggable_area_id.is_empty()
var uses_spawn_anchors: bool = not spawn_anchor_set_id.is_empty()
var movement_parameters_valid: bool = (
movement_speed >= 0.0
and (
presentation_mode == PresentationMode.WATER_SPURT
or (roam_radius > 0.0 and scare_radius > 0.0)
)
)
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 (
uses_diggable_area
or uses_spawn_anchors
or not surface_materials.is_empty()
)
and (
not uses_diggable_area
or catch_data.collection_method
== FishDataType.CollectionMethod.DIGGING
)
and population > 0
and movement_speed >= 0.0
and roam_radius > 0.0
and scare_radius > 0.0
and movement_parameters_valid
and _quality_multipliers_are_valid(
quality_movement_speed_multipliers
)
@ -67,6 +93,18 @@ func is_valid() -> bool:
)
func is_stationary_hotspot() -> bool:
return presentation_mode == PresentationMode.WATER_SPURT
func is_stationary_spawn() -> bool:
return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty()
func can_be_scared() -> bool:
return not is_stationary_spawn() and scare_radius > 0.0
func get_movement_speed_for_quality(quality: int) -> float:
return movement_speed * _quality_multiplier(
quality_movement_speed_multipliers,

View file

@ -35,6 +35,7 @@ var _charging: bool = false
var _charge_elapsed: float = 0.0
var _charge_duration: float = 2.0
var _request_id: String = ""
var _active_tool_id: StringName
var _gameplay_input_enabled: bool = false
@ -76,15 +77,40 @@ func is_net_selected() -> bool:
)
func is_shovel_selected() -> bool:
return _is_owned_tool_selected(FishingShopStockType.STANDARD_SHOVEL_ID)
func _is_owned_tool_selected(tool_id: StringName) -> bool:
return (
_bag != null
and _hotbar != null
and _hotbar.get_selected_item_id() == tool_id
and _bag.owns_item(tool_id)
)
func _get_selected_gathering_tool_id() -> StringName:
if is_net_selected():
return FishingShopStockType.CRAB_NET_ID
if is_shovel_selected():
return FishingShopStockType.STANDARD_SHOVEL_ID
return StringName()
func _process(delta: float) -> void:
var selected_tool_id := _get_selected_gathering_tool_id()
var input_available: bool = (
_gameplay_input_enabled
and is_net_selected()
and not selected_tool_id.is_empty()
and _player != null
and _player.is_local_control_enabled()
and _fishing_spot != null
and _fishing_spot.can_change_hotbar_selection()
)
if _charging and selected_tool_id != _active_tool_id:
_cancel_charge()
input_available = false
if not input_available:
if _charging:
_cancel_charge()
@ -105,7 +131,11 @@ func _process(delta: float) -> void:
var valid_target: bool = (
fully_charged
and not _target_entity_id.is_empty()
and _player.is_sneaking()
and (
target_entry == null
or not target_entry.requires_sneaking
or _player.is_sneaking()
)
)
_marker.material_override = (
_marker_valid_material if valid_target else _marker_invalid_material
@ -127,7 +157,7 @@ func _unhandled_input(event: InputEvent) -> void:
if (
_charging
or not _gameplay_input_enabled
or not is_net_selected()
or _get_selected_gathering_tool_id().is_empty()
or _fishing_spot == null
or not _fishing_spot.can_change_hotbar_selection()
):
@ -136,10 +166,11 @@ func _unhandled_input(event: InputEvent) -> void:
if _request_id.is_empty():
return
_charging = true
_active_tool_id = _get_selected_gathering_tool_id()
_charge_elapsed = 0.0
_charge_duration = maxf(
_service.get_charge_duration_for_tool(
FishingShopStockType.CRAB_NET_ID
_active_tool_id
),
0.1,
)
@ -151,11 +182,18 @@ func _unhandled_input(event: InputEvent) -> void:
if not _charging:
return
var fully_charged: bool = _charge_elapsed >= _charge_duration
var target_entry: GatherableData = _service.get_entry_for_entity(
_target_entity_id
)
var valid_capture: bool = (
fully_charged
and _marker_has_surface
and not _target_entity_id.is_empty()
and _player.is_sneaking()
and (
target_entry == null
or not target_entry.requires_sneaking
or _player.is_sneaking()
)
)
_player.play_net_strike_visual()
if valid_capture:
@ -265,7 +303,7 @@ func _update_target_entity() -> void:
_target_entity_id = (
_service.find_capture_target(
_marker_position,
FishingShopStockType.CRAB_NET_ID,
_active_tool_id,
)
if _marker_has_surface and _service != null
else ""
@ -284,6 +322,7 @@ func _reset_charge_state() -> void:
_charging = false
_charge_elapsed = 0.0
_request_id = ""
_active_tool_id = StringName()
_target_entity_id = ""
_marker_has_surface = false
_set_marker_visible(false)

View file

@ -4,11 +4,16 @@ extends Node3D
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
const REFERENCE_SPRITE_PIXEL_SIZE: float = 0.001
const REFERENCE_SPRITE_HEIGHT: float = 0.22
const WATER_SPURT_INTERVAL_SECONDS: float = 1.15
const WATER_SPURT_COLOR := Color(0.48, 0.82, 0.84, 1.0)
const WATER_SPURT_HOLE_COLOR := Color(0.23, 0.27, 0.24, 1.0)
var entity_id: String = ""
var type_id: StringName
var data: GatherableDataType
var _sprite: Sprite3D
var _water_spurt_root: Node3D
var _water_spurt_elapsed: float = 0.0
var _target_position: Vector3
var _target_yaw: float = 0.0
var _has_state: bool = false
@ -24,14 +29,25 @@ func configure(
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:
if data != null and data.is_stationary_hotspot():
_ensure_water_spurt_visual()
_water_spurt_elapsed = (
float(abs(hash(entity_id)) % 1000) / 1000.0
* WATER_SPURT_INTERVAL_SECONDS
)
else:
_ensure_visual()
if data != null and data.catch_data != null and _sprite != null:
_sprite.texture = data.catch_data.display_texture
_sprite.pixel_size = data.sprite_pixel_size
_sprite.position.y = (
REFERENCE_SPRITE_HEIGHT
* data.sprite_pixel_size
/ REFERENCE_SPRITE_PIXEL_SIZE
0.0
if not data.spawn_anchor_set_id.is_empty()
else (
REFERENCE_SPRITE_HEIGHT
* data.sprite_pixel_size
/ REFERENCE_SPRITE_PIXEL_SIZE
)
)
_sprite.rotation_degrees.x = data.sprite_tilt_degrees
apply_network_state(position, yaw, true)
@ -58,6 +74,10 @@ func play_despawn(with_dust: bool) -> void:
_despawning = true
if with_dust:
_emit_dust()
if _water_spurt_root != null:
_water_spurt_root.visible = false
queue_free()
return
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.set_trans(Tween.TRANS_QUAD)
@ -80,6 +100,14 @@ func _process(delta: float) -> void:
_target_yaw,
1.0 - exp(-8.0 * delta),
)
if _water_spurt_root != null:
_water_spurt_elapsed += delta
if _water_spurt_elapsed >= WATER_SPURT_INTERVAL_SECONDS:
_water_spurt_elapsed = fmod(
_water_spurt_elapsed,
WATER_SPURT_INTERVAL_SECONDS,
)
_emit_water_spurt()
func _ensure_visual() -> void:
@ -95,6 +123,66 @@ func _ensure_visual() -> void:
add_child(_sprite)
func _ensure_water_spurt_visual() -> void:
if _water_spurt_root != null:
return
_water_spurt_root = Node3D.new()
_water_spurt_root.name = "WaterSpurt"
add_child(_water_spurt_root)
var hole_material := StandardMaterial3D.new()
hole_material.albedo_color = WATER_SPURT_HOLE_COLOR
hole_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
hole_material.roughness = 1.0
hole_material.metallic = 0.0
var hole_mesh := CylinderMesh.new()
hole_mesh.top_radius = 0.035
hole_mesh.bottom_radius = 0.035
hole_mesh.height = 0.004
hole_mesh.radial_segments = 12
hole_mesh.rings = 1
hole_mesh.material = hole_material
var hole := MeshInstance3D.new()
hole.name = "BurrowMark"
hole.position.y = 0.008
hole.mesh = hole_mesh
hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_water_spurt_root.add_child(hole)
_emit_water_spurt()
func _emit_water_spurt() -> void:
if _water_spurt_root == null or not _water_spurt_root.visible:
return
var particles := CPUParticles3D.new()
particles.name = "WaterDroplets"
particles.position.y = 0.035
particles.amount = 6
particles.lifetime = 0.42
particles.one_shot = true
particles.explosiveness = 1.0
particles.direction = Vector3.UP
particles.spread = 32.0
particles.gravity = Vector3(0.0, -3.4, 0.0)
particles.initial_velocity_min = 0.8
particles.initial_velocity_max = 1.2
particles.scale_amount_min = 0.75
particles.scale_amount_max = 1.0
var water_material := StandardMaterial3D.new()
water_material.albedo_color = WATER_SPURT_COLOR
water_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
water_material.roughness = 1.0
water_material.metallic = 0.0
var droplet_mesh := BoxMesh.new()
droplet_mesh.size = Vector3(0.022, 0.035, 0.022)
droplet_mesh.material = water_material
particles.mesh = droplet_mesh
_water_spurt_root.add_child(particles)
particles.emitting = true
var cleanup := create_tween()
cleanup.tween_interval(0.55)
cleanup.tween_callback(particles.queue_free)
func _emit_dust() -> void:
var particles := CPUParticles3D.new()
particles.name = "DustPoof"

View file

@ -9,6 +9,7 @@ signal catches_changed
var _catches: Array[FishCatchType] = []
var _next_catch_sequence: int = 1
var _reservation_service: PlayerAssetReservationService
var _inventory_layout: PlayerInventoryLayout
func set_reservation_service(
@ -17,11 +18,24 @@ func set_reservation_service(
_reservation_service = reservation_service
func add_catch(fish_catch: FishCatchType) -> void:
func set_inventory_layout(layout: PlayerInventoryLayout) -> void:
_inventory_layout = layout
func can_accept_catch(catch_id: StringName = StringName()) -> bool:
return (
_inventory_layout == null
or _inventory_layout.can_accept_catch(catch_id)
)
func add_catch(fish_catch: FishCatchType) -> bool:
if fish_catch == null or not fish_catch.is_valid():
return
return false
if get_catch_by_id(fish_catch.catch_id) != null:
return
return false
if not can_accept_catch(fish_catch.catch_id):
return false
if fish_catch.catch_sequence <= 0:
fish_catch.catch_sequence = _next_catch_sequence
_next_catch_sequence = maxi(
@ -34,6 +48,7 @@ func add_catch(fish_catch: FishCatchType) -> void:
get_count(fish_catch.fish_id)
)
catches_changed.emit()
return true
func get_count(fish_id: StringName) -> int:

View file

@ -13,11 +13,14 @@ enum StorageGroup {
NONE,
EQUIPMENT,
ITEMS,
BAIT,
LURES,
}
signal contents_changed
var _catalog: ItemCatalogType
var _inventory_layout: PlayerInventoryLayout
var _items: Array[OwnedItemType] = []
var _unlocked_bait_ids: Array[StringName] = (
DEFAULT_UNLOCKED_BAIT_IDS.duplicate()
@ -28,11 +31,21 @@ func setup(catalog: ItemCatalogType) -> void:
_catalog = catalog
func set_inventory_layout(layout: PlayerInventoryLayout) -> void:
_inventory_layout = layout
func add_item(item_id: StringName, quantity: int = 1) -> bool:
if not can_add_item(item_id, quantity):
return false
var item: ItemDataType = _resolve_valid_item(item_id)
var existing: OwnedItemType = get_owned_item(item_id)
if (
existing == null
and _inventory_layout != null
and not _inventory_layout.can_accept_item(item_id)
):
return false
if existing != null:
existing.quantity += quantity
else:
@ -57,6 +70,11 @@ func can_add_item(item_id: StringName, quantity: int = 1) -> bool:
item.stackable
and existing.quantity + quantity <= item.max_stack
)
if (
_inventory_layout != null
and not _inventory_layout.can_accept_item(item_id)
):
return false
return quantity <= (item.max_stack if item.stackable else 1)
@ -252,8 +270,12 @@ func _first_available_storage_slot(
func _storage_group_for_item(item: ItemDataType) -> StorageGroup:
if item == null or item.is_bait() or item.is_lure():
if item == null:
return StorageGroup.NONE
if item.is_bait():
return StorageGroup.BAIT
if item.is_lure():
return StorageGroup.LURES
return (
StorageGroup.ITEMS
if item.category == ItemDataType.Category.CONSUMABLE

View file

@ -24,6 +24,7 @@ signal selected_assignment_changed(
var _bag: PlayerBagType
var _catalog: ItemCatalogType
var _fish_inventory: FishInventoryType
var _inventory_layout: PlayerInventoryLayout
var _slots: Array[StringName] = []
var _fish_slots: Array[StringName] = []
var _selected_slot: int = 0
@ -40,10 +41,12 @@ func setup(
bag: PlayerBagType,
catalog: ItemCatalogType,
fish_inventory: FishInventoryType = null,
inventory_layout: PlayerInventoryLayout = null,
) -> void:
_bag = bag
_catalog = catalog
_fish_inventory = fish_inventory
_inventory_layout = inventory_layout
if _bag != null and not _bag.contents_changed.is_connected(
_on_bag_contents_changed
):
@ -55,6 +58,11 @@ func setup(
)
):
_fish_inventory.catches_changed.connect(_on_fish_inventory_changed)
if (
_inventory_layout != null
and not _inventory_layout.layout_changed.is_connected(_validate_assignments)
):
_inventory_layout.layout_changed.connect(_validate_assignments)
_validate_assignments()
@ -63,13 +71,26 @@ func assign_item(slot_index: int, item_id: StringName) -> bool:
return false
if _slots[slot_index] == item_id:
return true
var source_index: int = _slots.find(item_id)
var displaced_item: StringName = _slots[slot_index]
var displaced_fish: StringName = _fish_slots[slot_index]
if (
_inventory_layout != null
and not _inventory_layout.move_entry(
PlayerInventoryLayout.EntryKind.ITEM,
item_id,
PlayerInventoryLayout.InventoryContainer.HOTBAR,
slot_index,
)
):
return false
var selected_slot_was_affected: bool = slot_index == _selected_slot
for index: int in range(SLOT_COUNT):
if index != slot_index and _slots[index] == item_id:
_slots[index] = StringName()
selected_slot_was_affected = (
selected_slot_was_affected or index == _selected_slot
)
if source_index >= 0 and source_index != slot_index:
_slots[source_index] = displaced_item
_fish_slots[source_index] = displaced_fish
selected_slot_was_affected = (
selected_slot_was_affected or source_index == _selected_slot
)
_slots[slot_index] = item_id
_fish_slots[slot_index] = StringName()
slots_changed.emit()
@ -83,13 +104,26 @@ func assign_fish(slot_index: int, catch_id: StringName) -> bool:
return false
if _fish_slots[slot_index] == catch_id:
return true
var source_index: int = _fish_slots.find(catch_id)
var displaced_item: StringName = _slots[slot_index]
var displaced_fish: StringName = _fish_slots[slot_index]
if (
_inventory_layout != null
and not _inventory_layout.move_entry(
PlayerInventoryLayout.EntryKind.CATCH,
catch_id,
PlayerInventoryLayout.InventoryContainer.HOTBAR,
slot_index,
)
):
return false
var selected_slot_was_affected: bool = slot_index == _selected_slot
for index: int in range(SLOT_COUNT):
if index != slot_index and _fish_slots[index] == catch_id:
_fish_slots[index] = StringName()
selected_slot_was_affected = (
selected_slot_was_affected or index == _selected_slot
)
if source_index >= 0 and source_index != slot_index:
_slots[source_index] = displaced_item
_fish_slots[source_index] = displaced_fish
selected_slot_was_affected = (
selected_slot_was_affected or source_index == _selected_slot
)
_slots[slot_index] = StringName()
_fish_slots[slot_index] = catch_id
slots_changed.emit()
@ -107,6 +141,11 @@ func clear_slot(slot_index: int) -> bool:
)
):
return false
if (
_inventory_layout != null
and not _inventory_layout.return_hotbar_entry_to_inventory(slot_index)
):
return false
_slots[slot_index] = StringName()
_fish_slots[slot_index] = StringName()
slots_changed.emit()
@ -122,6 +161,39 @@ func swap_slots(from_index: int, to_index: int) -> bool:
or from_index == to_index
):
return false
if _inventory_layout != null:
var kind: PlayerInventoryLayout.EntryKind
var identity: StringName
if not _fish_slots[from_index].is_empty():
kind = PlayerInventoryLayout.EntryKind.CATCH
identity = _fish_slots[from_index]
elif not _slots[from_index].is_empty():
kind = PlayerInventoryLayout.EntryKind.ITEM
identity = _slots[from_index]
else:
kind = (
PlayerInventoryLayout.EntryKind.CATCH
if not _fish_slots[to_index].is_empty()
else PlayerInventoryLayout.EntryKind.ITEM
)
identity = (
_fish_slots[to_index]
if not _fish_slots[to_index].is_empty()
else _slots[to_index]
)
var temporary_index: int = from_index
from_index = to_index
to_index = temporary_index
if (
identity.is_empty()
or not _inventory_layout.move_entry(
kind,
identity,
PlayerInventoryLayout.InventoryContainer.HOTBAR,
to_index,
)
):
return false
var temporary: StringName = _slots[from_index]
_slots[from_index] = _slots[to_index]
_slots[to_index] = temporary
@ -134,6 +206,50 @@ func swap_slots(from_index: int, to_index: int) -> bool:
return true
func move_slot_to_container(
slot_index: int,
target_container: int,
target_slot: int,
) -> bool:
if not _is_slot_valid(slot_index) or _inventory_layout == null:
return false
var kind: PlayerInventoryLayout.EntryKind
var identity: StringName
if not _fish_slots[slot_index].is_empty():
kind = PlayerInventoryLayout.EntryKind.CATCH
identity = _fish_slots[slot_index]
elif not _slots[slot_index].is_empty():
kind = PlayerInventoryLayout.EntryKind.ITEM
identity = _slots[slot_index]
else:
return false
if not _inventory_layout.move_entry(
kind,
identity,
target_container,
target_slot,
):
return false
_slots[slot_index] = StringName()
_fish_slots[slot_index] = StringName()
var replacement := _inventory_layout.get_entry_at(
PlayerInventoryLayout.InventoryContainer.HOTBAR,
slot_index,
)
if not replacement.is_empty():
var replacement_identity := StringName(
str(replacement.get("identity", ""))
)
if int(replacement.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH:
_fish_slots[slot_index] = replacement_identity
else:
_slots[slot_index] = replacement_identity
slots_changed.emit()
if slot_index == _selected_slot:
_emit_selected_assignment()
return true
func select_slot(slot_index: int) -> bool:
if not _is_slot_valid(slot_index):
return false
@ -202,7 +318,13 @@ func replace_state(
slots: Array[StringName],
selected_slot: int,
fish_slots: Array[StringName] = [],
synchronize_layout: bool = true,
) -> bool:
if (
synchronize_layout and _inventory_layout != null
and not _inventory_layout.return_all_hotbar_entries_to_inventory()
):
return false
var normalized: Array[StringName] = []
normalized.resize(SLOT_COUNT)
normalized.fill(StringName())
@ -227,6 +349,22 @@ func replace_state(
seen_fish[catch_id] = true
_slots = normalized
_fish_slots = normalized_fish
if _inventory_layout != null and synchronize_layout:
for index: int in SLOT_COUNT:
if not _slots[index].is_empty():
_inventory_layout.move_entry(
PlayerInventoryLayout.EntryKind.ITEM,
_slots[index],
PlayerInventoryLayout.InventoryContainer.HOTBAR,
index,
)
elif not _fish_slots[index].is_empty():
_inventory_layout.move_entry(
PlayerInventoryLayout.EntryKind.CATCH,
_fish_slots[index],
PlayerInventoryLayout.InventoryContainer.HOTBAR,
index,
)
_selected_slot = clampi(selected_slot, 0, SLOT_COUNT - 1)
slots_changed.emit()
_emit_selected_assignment()
@ -249,7 +387,17 @@ func _can_assign(item_id: StringName) -> bool:
if _catalog == null:
return false
var item: ItemDataType = _catalog.get_item_by_id(item_id)
return item != null and item.is_available() and item.hotbar_allowed
return (
item != null
and item.is_available()
and item.hotbar_allowed
and (
_inventory_layout == null
or _inventory_layout.get_container(
PlayerInventoryLayout.EntryKind.ITEM, item_id
) >= 0
)
)
func _can_assign_fish(catch_id: StringName) -> bool:
@ -257,6 +405,12 @@ func _can_assign_fish(catch_id: StringName) -> bool:
not catch_id.is_empty()
and _fish_inventory != null
and _fish_inventory.contains_catch_id(catch_id)
and (
_inventory_layout == null
or _inventory_layout.get_container(
PlayerInventoryLayout.EntryKind.CATCH, catch_id
) >= 0
)
)

View file

@ -0,0 +1,519 @@
class_name PlayerInventoryLayout
extends Node
const ItemCatalogType = preload("res://items/item_catalog.gd")
const ItemDataType = preload("res://items/item_data.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const INVENTORY_COLUMNS: int = 9
const INVENTORY_CAPACITIES: Array[int] = [9, 18, 27, 36]
const BACKPACK_EXPANSION_COSTS: Array[int] = [1500, 4500, 9000]
const MAX_BACKPACK_LEVEL: int = 3
const MAX_INVENTORY_SLOT_COUNT: int = 36
const MAX_STORAGE_SLOT_COUNT: int = PlayerCoolerCapacityType.MAX_CAPACITY
const HOTBAR_SLOT_COUNT: int = 9
enum EntryKind {
ITEM,
CATCH,
}
enum InventoryContainer {
INVENTORY,
STORAGE,
HOTBAR,
}
signal layout_changed
signal backpack_capacity_changed(level: int, capacity: int)
var _bag: PlayerBagType
var _fish_inventory: FishInventoryType
var _item_catalog: ItemCatalogType
var _storage_capacity: PlayerCoolerCapacityType
var _placements: Dictionary[String, Dictionary] = {}
var _reconciling: bool = false
var _backpack_level: int = 0
func setup(
bag: PlayerBagType,
fish_inventory: FishInventoryType,
item_catalog: ItemCatalogType,
storage_capacity: PlayerCoolerCapacityType,
) -> void:
_bag = bag
_fish_inventory = fish_inventory
_item_catalog = item_catalog
_storage_capacity = storage_capacity
if _bag != null and not _bag.contents_changed.is_connected(_on_contents_changed):
_bag.contents_changed.connect(_on_contents_changed)
if (
_fish_inventory != null
and not _fish_inventory.catches_changed.is_connected(_on_contents_changed)
):
_fish_inventory.catches_changed.connect(_on_contents_changed)
if (
_storage_capacity != null
and not _storage_capacity.capacity_changed.is_connected(
_on_storage_capacity_changed
)
):
_storage_capacity.capacity_changed.connect(_on_storage_capacity_changed)
_reconcile(true)
func can_accept_item(item_id: StringName) -> bool:
if not is_managed_item(item_id):
return true
var key := item_key(item_id)
return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0
func can_accept_catch(catch_id: StringName = StringName()) -> bool:
if not catch_id.is_empty() and _placements.has(catch_key(catch_id)):
return true
return _first_free_slot(InventoryContainer.INVENTORY) >= 0
func is_managed_item(item_id: StringName) -> bool:
var item: ItemDataType = (
_item_catalog.get_item_by_id(item_id)
if _item_catalog != null else null
)
return item != null and not item.is_bait() and not item.is_lure()
func is_item_in_inventory(item_id: StringName) -> bool:
return _is_entry_in_container(item_key(item_id), InventoryContainer.INVENTORY)
func is_catch_in_inventory(catch_id: StringName) -> bool:
return _is_entry_in_container(catch_key(catch_id), InventoryContainer.INVENTORY)
func is_item_carried(item_id: StringName) -> bool:
return _is_entry_carried(item_key(item_id))
func is_catch_carried(catch_id: StringName) -> bool:
return _is_entry_carried(catch_key(catch_id))
func get_inventory_count() -> int:
return get_entries(InventoryContainer.INVENTORY).size()
func get_inventory_capacity() -> int:
return INVENTORY_CAPACITIES[_backpack_level]
func get_backpack_level() -> int:
return _backpack_level
func get_next_inventory_capacity() -> int:
if _backpack_level >= MAX_BACKPACK_LEVEL:
return -1
return INVENTORY_CAPACITIES[_backpack_level + 1]
func get_next_backpack_cost() -> int:
if _backpack_level >= MAX_BACKPACK_LEVEL:
return -1
return BACKPACK_EXPANSION_COSTS[_backpack_level]
func can_purchase_backpack(wallet: PlayerWalletType) -> bool:
var cost := get_next_backpack_cost()
return wallet != null and cost >= 0 and wallet.can_afford(cost)
func purchase_backpack(wallet: PlayerWalletType) -> bool:
if not can_purchase_backpack(wallet):
return false
var cost := get_next_backpack_cost()
if not wallet.debit(cost):
return false
_backpack_level += 1
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
layout_changed.emit()
return true
func restore_backpack_level(level: int) -> bool:
var validated := clampi(level, 0, MAX_BACKPACK_LEVEL)
var changed := _backpack_level != validated
_backpack_level = validated
if changed:
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
_reconcile(changed)
return true
func get_storage_count() -> int:
return get_entries(InventoryContainer.STORAGE).size()
func get_hotbar_count() -> int:
return get_entries(InventoryContainer.HOTBAR).size()
func get_storage_capacity() -> int:
return _storage_capacity.get_capacity() if _storage_capacity != null else 0
func get_entries(container: InventoryContainer) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for key: String in _placements:
var placement: Dictionary = _placements[key]
if int(placement.get("container", -1)) != int(container):
continue
var entry: Dictionary = placement.duplicate(true)
entry["key"] = key
entries.append(entry)
entries.sort_custom(
func(left: Dictionary, right: Dictionary) -> bool:
return int(left.get("slot", -1)) < int(right.get("slot", -1))
)
return entries
func get_entry(kind: EntryKind, identity: StringName) -> Dictionary:
var key := _entry_key(kind, identity)
return _placements.get(key, {}).duplicate(true)
func get_container(kind: EntryKind, identity: StringName) -> int:
return int(
get_entry(kind, identity).get("container", -1)
)
func get_key_at(container: InventoryContainer, slot: int) -> String:
return _key_at(container, slot)
func get_entry_at(container: InventoryContainer, slot: int) -> Dictionary:
var key := _key_at(container, slot)
if key.is_empty():
return {}
var entry: Dictionary = _placements[key].duplicate(true)
entry["key"] = key
return entry
func move_entry(
kind: EntryKind,
identity: StringName,
target_container: InventoryContainer,
target_slot: int,
) -> bool:
var key := _entry_key(kind, identity)
if not _placements.has(key) or not _is_slot_valid(target_container, target_slot):
return false
var source: Dictionary = _placements[key]
if (
int(source.get("container", -1)) == int(target_container)
and int(source.get("slot", -1)) == target_slot
):
return true
var displaced_key := _key_at(target_container, target_slot)
var source_container: int = int(source.get("container", InventoryContainer.INVENTORY))
var source_slot: int = int(source.get("slot", -1))
source["container"] = int(target_container)
source["slot"] = target_slot
_placements[key] = source
if not displaced_key.is_empty():
var displaced: Dictionary = _placements[displaced_key]
displaced["container"] = source_container
displaced["slot"] = source_slot
_placements[displaced_key] = displaced
layout_changed.emit()
return true
func move_entry_to_first_free(
kind: EntryKind,
identity: StringName,
target_container: InventoryContainer,
) -> bool:
var slot := _first_free_slot(target_container)
return slot >= 0 and move_entry(kind, identity, target_container, slot)
func return_hotbar_entry_to_inventory(slot: int) -> bool:
var key := _key_at(InventoryContainer.HOTBAR, slot)
if key.is_empty():
return true
var placement: Dictionary = _placements[key]
return move_entry_to_first_free(
int(placement.get("kind", -1)) as EntryKind,
StringName(str(placement.get("identity", ""))),
InventoryContainer.INVENTORY,
)
func return_all_hotbar_entries_to_inventory() -> bool:
var entries := get_entries(InventoryContainer.HOTBAR)
if get_inventory_count() + entries.size() > get_inventory_capacity():
return false
for entry: Dictionary in entries:
if not move_entry_to_first_free(
int(entry.get("kind", -1)) as EntryKind,
StringName(str(entry.get("identity", ""))),
InventoryContainer.INVENTORY,
):
return false
return true
func to_save_data() -> Dictionary:
var serialized: Array[Dictionary] = []
var keys: Array[String] = []
keys.assign(_placements.keys())
keys.sort()
for key: String in keys:
var placement: Dictionary = _placements[key]
serialized.append({
"kind": int(placement.get("kind", EntryKind.ITEM)),
"identity": str(placement.get("identity", "")),
"container": int(placement.get("container", InventoryContainer.INVENTORY)),
"slot": int(placement.get("slot", -1)),
})
return {
"backpack_level": _backpack_level,
"placements": serialized,
}
func restore_from_save_data(data: Dictionary) -> bool:
var level_value: Variant = data.get("backpack_level", 0)
if (
typeof(level_value) not in [TYPE_INT, TYPE_FLOAT]
or not is_equal_approx(float(level_value), floorf(float(level_value)))
):
return false
_backpack_level = clampi(int(level_value), 0, MAX_BACKPACK_LEVEL)
var restored: Dictionary[String, Dictionary] = {}
var values: Variant = data.get("placements", [])
if typeof(values) != TYPE_ARRAY:
return false
for value: Variant in values as Array:
if typeof(value) != TYPE_DICTIONARY:
continue
var record := value as Dictionary
var kind: int = int(record.get("kind", -1))
var identity := StringName(str(record.get("identity", "")))
var container: int = int(record.get("container", -1))
var slot: int = int(record.get("slot", -1))
if (
kind not in [EntryKind.ITEM, EntryKind.CATCH]
or identity.is_empty()
or container not in [
InventoryContainer.INVENTORY,
InventoryContainer.STORAGE,
InventoryContainer.HOTBAR,
]
or not _is_slot_valid(container as InventoryContainer, slot)
):
continue
var key := _entry_key(kind as EntryKind, identity)
if restored.has(key) or _placement_slot_occupied(restored, container, slot):
continue
restored[key] = {
"kind": kind,
"identity": identity,
"container": container,
"slot": slot,
}
_placements = restored
_reconcile(true)
return true
func reset_to_defaults() -> void:
var capacity_changed := _backpack_level != 0
_backpack_level = 0
_placements.clear()
_reconcile(true)
if capacity_changed:
backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity())
static func item_key(item_id: StringName) -> String:
return "item:%s" % String(item_id)
static func catch_key(catch_id: StringName) -> String:
return "catch:%s" % String(catch_id)
func _entry_key(kind: EntryKind, identity: StringName) -> String:
return item_key(identity) if kind == EntryKind.ITEM else catch_key(identity)
func _is_entry_in_container(key: String, container: InventoryContainer) -> bool:
var placement: Dictionary = _placements.get(key, {})
return int(placement.get("container", -1)) == int(container)
func _is_entry_carried(key: String) -> bool:
var placement: Dictionary = _placements.get(key, {})
var container: int = int(placement.get("container", -1))
return container in [InventoryContainer.INVENTORY, InventoryContainer.HOTBAR]
func _on_contents_changed() -> void:
_reconcile(false)
func _on_storage_capacity_changed(_level: int, _capacity: int) -> void:
_reconcile(true)
func _reconcile(force_signal: bool) -> void:
if _reconciling or _bag == null or _fish_inventory == null:
return
_reconciling = true
var expected: Dictionary[String, Dictionary] = {}
for owned in _bag.get_all_items():
if owned == null or not is_managed_item(owned.item_id):
continue
var key := item_key(owned.item_id)
expected[key] = {"kind": EntryKind.ITEM, "identity": owned.item_id}
for fish_catch in _fish_inventory.get_all_catches():
if fish_catch == null or not fish_catch.is_valid():
continue
var key := catch_key(fish_catch.catch_id)
expected[key] = {"kind": EntryKind.CATCH, "identity": fish_catch.catch_id}
var changed: bool = false
for key: String in _placements.keys():
if expected.has(key):
continue
_placements.erase(key)
changed = true
_normalize_existing_placements()
for key: String in expected:
if _placements.has(key):
continue
var target_container := InventoryContainer.INVENTORY
var target_slot := _first_free_slot(target_container)
if target_slot < 0:
target_container = InventoryContainer.STORAGE
target_slot = _first_free_slot(target_container)
if target_slot < 0:
push_error("Inventory and storage cannot fit owned entry '%s'." % key)
continue
var placement: Dictionary = expected[key].duplicate(true)
placement["container"] = int(target_container)
placement["slot"] = target_slot
_placements[key] = placement
changed = true
_reconciling = false
if changed or force_signal:
layout_changed.emit()
func _normalize_existing_placements() -> void:
var occupied_inventory: Dictionary[int, bool] = {}
var occupied_storage: Dictionary[int, bool] = {}
var occupied_hotbar: Dictionary[int, bool] = {}
var invalid_keys: Array[String] = []
var keys: Array[String] = []
keys.assign(_placements.keys())
keys.sort()
for key: String in keys:
var placement: Dictionary = _placements[key]
var container: int = int(placement.get("container", -1))
var slot: int = int(placement.get("slot", -1))
var occupied: Dictionary[int, bool]
match container:
InventoryContainer.INVENTORY:
occupied = occupied_inventory
InventoryContainer.STORAGE:
occupied = occupied_storage
InventoryContainer.HOTBAR:
occupied = occupied_hotbar
_:
occupied = {}
if (
container not in [
InventoryContainer.INVENTORY,
InventoryContainer.STORAGE,
InventoryContainer.HOTBAR,
]
or not _is_slot_valid(container as InventoryContainer, slot)
or occupied.has(slot)
):
invalid_keys.append(key)
continue
occupied[slot] = true
for key: String in invalid_keys:
# Invalid records must stop occupying their old slot before free-space
# lookup. Otherwise a duplicate can incorrectly reserve a valid slot.
var placement: Dictionary = _placements[key]
_placements.erase(key)
var target := InventoryContainer.INVENTORY
var slot := _first_free_slot(target)
if slot < 0:
target = InventoryContainer.STORAGE
slot = _first_free_slot(target)
if slot < 0:
_placements.erase(key)
continue
placement["container"] = int(target)
placement["slot"] = slot
_placements[key] = placement
func _first_free_slot(container: InventoryContainer) -> int:
var capacity := (
get_inventory_capacity() if container == InventoryContainer.INVENTORY
else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR
else get_storage_capacity()
)
for slot: int in capacity:
if _key_at(container, slot).is_empty():
return slot
return -1
func _key_at(container: InventoryContainer, slot: int) -> String:
for key: String in _placements:
var placement: Dictionary = _placements[key]
if (
int(placement.get("container", -1)) == int(container)
and int(placement.get("slot", -1)) == slot
):
return key
return ""
func _is_slot_valid(container: InventoryContainer, slot: int) -> bool:
if slot < 0:
return false
return slot < (
get_inventory_capacity() if container == InventoryContainer.INVENTORY
else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR
else get_storage_capacity()
)
func _placement_slot_occupied(
placements: Dictionary[String, Dictionary],
container: int,
slot: int,
) -> bool:
for placement: Dictionary in placements.values():
if (
int(placement.get("container", -1)) == container
and int(placement.get("slot", -1)) == slot
):
return true
return false

View file

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

View file

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=39 format=3]
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=40 format=3]
[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"]
@ -38,7 +38,8 @@
[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/crab_net.tres" id="39_crab_net"]
[ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"]
[resource]
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"), ExtResource("39_crab_net")]
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"), ExtResource("40_shovel")]

View file

@ -0,0 +1,18 @@
[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3]
[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="2_icon"]
[resource]
script = ExtResource("1_item")
item_id = &"standard_shovel"
display_name = "standard shovel"
description = "a sturdy shovel for digging up buried finds on the beach."
active = true
category = 1
icon = ExtResource("2_icon")
stackable = false
max_stack = 1
usable = false
equippable = true
hotbar_allowed = true

View file

@ -23,6 +23,8 @@ const WEATHER_SEGMENT_HOURS: float = (
const WEATHER_SEGMENT_COUNT: int = (
WorldWeatherService.DAILY_PLAN_SEGMENT_COUNT
)
const LEGACY_WEATHER_SEGMENT_HOURS: float = 2.0
const LEGACY_WEATHER_SEGMENT_COUNT: int = 12
const MAX_JOB_REWARD_COINS: int = 100000
const MAX_JOB_REWARD_EXPERIENCE: int = 10000
@ -341,6 +343,36 @@ static func is_valid_weather_schedule(value: Variant) -> bool:
return WorldWeatherService.is_valid_daily_plan_schedule(value)
static func is_valid_legacy_weather_schedule(value: Variant) -> bool:
if typeof(value) != TYPE_ARRAY:
return false
var schedule: Array = value
if schedule.size() != LEGACY_WEATHER_SEGMENT_COUNT:
return false
for index: int in schedule.size():
if typeof(schedule[index]) != TYPE_DICTIONARY:
return false
var entry: Dictionary = schedule[index]
var expected_hour: float = fposmod(
WorldTimeService.DAY_START_HOUR
+ float(index) * LEGACY_WEATHER_SEGMENT_HOURS,
WorldTimeService.HOURS_PER_DAY,
)
if (
typeof(entry.get("start_hour")) not in [TYPE_FLOAT, TYPE_INT]
or not is_equal_approx(
float(entry.get("start_hour", -1.0)), expected_hour
)
or not is_bounded_integer(
entry.get("weather"),
WorldWeatherService.Weather.SUNNY,
WorldWeatherService.Weather.FOGGY,
)
):
return false
return true
static func _job(
id: String,
title: String,

View file

@ -245,16 +245,30 @@ func restore_from_save_data(data: Dictionary) -> bool:
if not validate_save_data(data):
return false
var received_board: Dictionary = _active_board.duplicate(true)
_host_board = (data.get("host_board", {}) as Dictionary).duplicate(true)
_active_plan_id = str(data.get("active_plan_id", ""))
var saved_host_board: Dictionary = data.get("host_board", {})
var legacy_weather_board: bool = (
not saved_host_board.is_empty()
and not validate_board(saved_host_board)
and _validate_legacy_board(saved_host_board)
)
_host_board = (
{}
if legacy_weather_board
else saved_host_board.duplicate(true)
)
_active_plan_id = (
"" if legacy_weather_board else str(data.get("active_plan_id", ""))
)
_daily_progress.clear()
var progress: Dictionary = data.get("daily_progress", {})
for key: Variant in progress:
_daily_progress[str(key)] = int(progress[key])
if not legacy_weather_board:
for key: Variant in progress:
_daily_progress[str(key)] = int(progress[key])
_daily_completions.clear()
var completions: Dictionary = data.get("daily_completions", {})
for key: Variant in completions:
_daily_completions[str(key)] = mini(int(completions[key]), 1)
if not legacy_weather_board:
for key: Variant in completions:
_daily_completions[str(key)] = mini(int(completions[key]), 1)
_daily_clock_hour = float(data.get(
"daily_clock_hour",
_world_time.get_time_hours()
@ -345,6 +359,14 @@ static func validate_board(value: Variant) -> bool:
)
):
return false
if (
board.has("calendar_cycle_id")
and (
typeof(board["calendar_cycle_id"]) != TYPE_STRING
or str(board["calendar_cycle_id"]).length() != 10
)
):
return false
var jobs: Array = board.get("jobs", [])
if jobs.is_empty() or jobs.size() > 8:
return false
@ -378,7 +400,11 @@ static func validate_save_data(value: Variant) -> bool:
):
return false
var host_board: Dictionary = data.get("host_board", {})
if not host_board.is_empty() and not validate_board(host_board):
if (
not host_board.is_empty()
and not validate_board(host_board)
and not _validate_legacy_board(host_board)
):
return false
if (
data.has("daily_clock_hour")
@ -436,9 +462,43 @@ static func validate_save_data(value: Variant) -> bool:
)
static func _validate_legacy_board(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var board: Dictionary = value
if (
typeof(board.get("plan_id")) != TYPE_STRING
or str(board.get("plan_id", "")).is_empty()
or str(board.get("plan_id", "")).length() > 96
or not JobCatalog.is_bounded_integer(
board.get("cycle"), 0, MAX_PROGRESS_VALUE
)
or not JobCatalog.is_bounded_integer(
board.get("schedule_anchor_index"),
0,
JobCatalog.LEGACY_WEATHER_SEGMENT_COUNT - 1,
)
or typeof(board.get("jobs")) != TYPE_ARRAY
or not JobCatalog.is_valid_legacy_weather_schedule(
board.get("weather_schedule", [])
)
):
return false
var jobs: Array = board.get("jobs", [])
if jobs.is_empty() or jobs.size() > 8:
return false
for job: Variant in jobs:
if not JobCatalog.is_valid_job(job):
return false
return true
func _activate_host_board() -> void:
if _host_board.is_empty():
_generate_host_board(0)
if _world_time != null:
_daily_clock_hour = _world_time.get_time_hours()
if _host_board.is_empty() or _host_board_is_stale():
var next_cycle: int = int(_host_board.get("cycle", -1)) + 1
_generate_host_board(maxi(next_cycle, 0))
else:
_active_board = _host_board.duplicate(true)
_switch_plan(str(_host_board.get("plan_id", "")))
@ -472,6 +532,11 @@ func _generate_host_board(cycle: int) -> void:
plan_id, jobs, schedule_anchor_index
),
}
var calendar_cycle_id: String = _world_time.get_calendar_cycle_id(
DAILY_REFRESH_HOUR
)
if not calendar_cycle_id.is_empty():
_host_board["calendar_cycle_id"] = calendar_cycle_id
_active_board = _host_board.duplicate(true)
_switch_plan(plan_id)
_apply_host_weather_plan()
@ -514,7 +579,10 @@ func _on_natural_time_advanced(hours: float) -> void:
_progression_ready
and _session != null
and _session.is_host()
and _crossed_daily_refresh(previous_hour, _daily_clock_hour)
and (
hours >= WorldTimeService.HOURS_PER_DAY
or _crossed_daily_refresh(previous_hour, _daily_clock_hour)
)
):
var cycle: int = int(_host_board.get("cycle", -1)) + 1
_generate_host_board(maxi(cycle, 0))
@ -759,6 +827,19 @@ func _current_weather_segment() -> int:
)
func _host_board_is_stale() -> bool:
if _world_time == null:
return false
var current_cycle_id: String = _world_time.get_calendar_cycle_id(
DAILY_REFRESH_HOUR
)
return (
not current_cycle_id.is_empty()
and str(_host_board.get("calendar_cycle_id", ""))
!= current_cycle_id
)
static func _daily_claim_id(
plan_id: String,
job_id: String,

View file

@ -33,6 +33,9 @@ const FishingShopType = preload("res://ui/fishing_shop.gd")
const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const UIPixelationPresenterType = preload(
"res://ui/ui_pixelation_presenter.gd"
)
@ -228,6 +231,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
var _gameplay_started: bool = false
var _shop_interaction: FishingShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
var _title_music_requested: bool = false
@ -432,13 +436,14 @@ func _start_dedicated_server() -> void:
_fail_dedicated_server(_discovery.get_host_status_message())
return
print(
"NETfishing dedicated server ready: %s on %s:%d (%d players, %s)"
"NETfishing dedicated server ready: %s on %s:%d (%d players, %s, timezone %s)"
% [
config.server_name,
config.bind_address,
config.port,
config.max_players,
"public" if config.public_listing else "unlisted",
str(Time.get_time_zone_from_system().get("name", "local")),
]
)
print(
@ -581,8 +586,22 @@ func _initialize_application(dedicated: bool) -> void:
_asset_reservations
)
_player.bag.setup(item_catalog)
_player.hotbar.setup(_player.bag, item_catalog, _player.inventory)
_player.inventory_layout.setup(
_player.bag,
_player.inventory,
item_catalog,
_player.cooler_capacity,
)
_player.bag.set_inventory_layout(_player.inventory_layout)
_player.inventory.set_inventory_layout(_player.inventory_layout)
_player.hotbar.setup(
_player.bag,
item_catalog,
_player.inventory,
_player.inventory_layout,
)
_shop_interaction = _test_world.get_fishing_shop()
_storage_interaction = _test_world.get_player_storage()
if not dedicated:
_shop_interaction.setup_local_player(_player)
_shop_interaction.local_player_range_changed.connect(
@ -591,6 +610,10 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.set_shop_npc_player_in_range(
_shop_interaction.is_local_player_in_range()
)
_storage_interaction.setup_local_player(_player)
_storage_interaction.local_player_range_changed.connect(
_on_storage_range_changed
)
_save_manager.setup(
_player.inventory,
_player.collection_log,
@ -598,6 +621,7 @@ func _initialize_application(dedicated: bool) -> void:
fish_catalog,
_player.bag,
_player.hotbar,
_player.inventory_layout,
item_catalog,
_player.fishing_upgrades,
_player.cooler_capacity,
@ -681,7 +705,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_player_list.set_surface_drawing_service(
_network_surface_drawing
)
_network_chat.setup(_network_session, _world_time, _world_weather)
_network_chat.setup(_network_session)
_network_fishing.setup(
_network_session,
_player_spawn_service,
@ -706,12 +730,15 @@ func _initialize_application(dedicated: bool) -> void:
_network_fishing,
_shop_interaction,
_player.inventory,
_player.bag,
item_catalog,
_player.wallet,
_player.fish_sale_service,
_save_manager,
fish_catalog,
sale_buyers,
_asset_reservations
_asset_reservations,
_player.inventory_layout,
)
_player_jobs.bind_authoritative_services(_network_fishing, _network_sale)
_network_shop.setup(
@ -726,7 +753,8 @@ func _initialize_application(dedicated: bool) -> void:
_player.cooler_capacity,
_player.art_unlocks,
_save_manager,
_asset_reservations
_asset_reservations,
_player.inventory_layout,
)
_fishing_spot.setup(
_player,
@ -759,10 +787,12 @@ func _initialize_application(dedicated: bool) -> void:
_fishing_spot,
_player.bag,
_player.hotbar,
_player.inventory_layout,
item_catalog,
main_shop_buyer_profile,
_player.fishing_upgrades,
_shop_interaction,
_storage_interaction,
_player.item_effects,
_player.cooler_capacity,
_network_session,
@ -1264,6 +1294,9 @@ func _input(event: InputEvent) -> void:
return
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
var fishing_shop: FishingShopType = _game_ui.get_fishing_shop()
if _game_ui.get_player_storage().consume_escape():
get_viewport().set_input_as_handled()
return
if fishing_shop.consume_escape():
get_viewport().set_input_as_handled()
return
@ -1368,6 +1401,15 @@ func _unhandled_input(event: InputEvent) -> void:
or (event is InputEventKey and event.echo)
):
return
if (
_storage_interaction != null
and _storage_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_player_storage().open_storage()
):
_game_ui.set_storage_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
@ -1391,6 +1433,16 @@ func _process(_delta: float) -> void:
show_shop_prompt,
shop_prompt_anchor,
)
var show_storage_prompt := _can_show_storage_prompt()
var storage_prompt_anchor := (
_storage_interaction.get_prompt_anchor_position()
if _storage_interaction != null
else Vector3.ZERO
)
_game_ui.set_storage_prompt_visible(
show_storage_prompt,
storage_prompt_anchor,
)
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
@ -1879,6 +1931,7 @@ func _on_water_recovery_starting() -> void:
_game_ui.close_player_menu_for_water_recovery()
_game_ui.get_pause_menu().close_for_water_recovery()
_game_ui.get_fishing_shop().close_for_water_recovery()
_game_ui.get_player_storage().close_for_water_recovery()
func _on_water_recovery_finished() -> void:
@ -1933,6 +1986,12 @@ func _on_active_hotbar_item_changed(
and item.is_available()
and _player.bag.owns_item(item_id)
)
var active_is_shovel: bool = (
item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and item != null
and item.is_available()
and _player.bag.owns_item(item_id)
)
_player.set_active_fishing_rod(
item as FishingRodDataType if active_is_rod else null,
true,
@ -1942,6 +2001,7 @@ func _on_active_hotbar_item_changed(
active_is_art_kit,
)
_player.set_active_catching_net(active_is_catching_net)
_player.set_active_shovel(active_is_shovel)
_game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit)
_network_item_use.submit_local_equipped(item_id, active_is_rod or (
item != null and _player.bag.owns_item(item_id)
@ -2067,6 +2127,12 @@ func _on_shop_range_changed(in_range: bool) -> void:
_game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
func _on_storage_range_changed(in_range: bool) -> void:
if not in_range:
_game_ui.get_player_storage().close_for_range_exit()
_game_ui.set_storage_prompt_visible(_can_show_storage_prompt())
func _can_show_shop_prompt() -> bool:
return (
_gameplay_started
@ -2076,3 +2142,15 @@ func _can_show_shop_prompt() -> bool:
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)
func _can_show_storage_prompt() -> bool:
return (
_gameplay_started
and _storage_interaction != null
and _storage_interaction.is_local_player_in_range()
and not _game_ui.get_player_storage().visible
and not _game_ui.get_fishing_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)

View file

@ -5,7 +5,6 @@ const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
@ -23,7 +22,6 @@ signal message_received(message: Dictionary)
signal local_message_confirmed(message: Dictionary)
signal send_rejected(message: String)
signal history_replaced(messages: Array[Dictionary])
signal world_command_finished(success: bool, message: String)
signal character_call_received(
peer_id: int,
call_id: String,
@ -37,22 +35,13 @@ var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {}
var _last_call_msec: Dictionary[int, int] = {}
var _call_variant_indices: Dictionary[int, int] = {}
var _last_world_command_msec: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
var _world_time: WorldTimeService
var _world_weather: WorldWeatherService
func setup(
session: NetworkSession,
world_time: WorldTimeService,
world_weather: WorldWeatherService,
) -> void:
func setup(session: NetworkSession) -> void:
_session = session
_world_time = world_time
_world_weather = world_weather
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed)
@ -261,188 +250,6 @@ func broadcast_system_message(body: String) -> bool:
return true
func request_world_time_change(phase_name: String) -> bool:
var normalized: String = phase_name.strip_edges().to_lower()
if not _valid_time_phase(normalized) or _session == null:
return false
if _session.is_host():
return _apply_world_time_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
)
):
return false
submit_world_time_command.rpc_id(1, normalized)
return true
func request_world_weather_change(weather_name: String) -> bool:
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty() or _session == null:
return false
if _session.is_host():
return _apply_world_weather_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
)
):
return false
submit_world_weather_command.rpc_id(1, normalized)
return true
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_time_command(phase_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id, false, "Only the host or an operator can change world time."
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_time_change(
phase_name.strip_edges().to_lower()
)
_send_world_command_result(
sender_id,
success,
"" if success else "World time could not be changed.",
)
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_weather_command(weather_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id,
false,
"Only the host or an operator can change world weather.",
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_weather_change(
_normalized_weather_name(weather_name)
)
_send_world_command_result(
sender_id,
success,
"" if success else "World weather could not be changed.",
)
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func receive_world_command_result(success: bool, message: String) -> void:
if _session == null or not _session.is_joined_client():
return
world_command_finished.emit(success, message.left(120))
func _valid_world_command_sender(peer_id: int) -> bool:
return (
_session != null
and _session.is_host()
and peer_id > 1
and _session.is_authenticated_peer(peer_id)
and _session.is_peer_operator(peer_id)
)
func _apply_world_time_change(phase_name: String) -> bool:
if _world_time == null or not _valid_time_phase(phase_name):
return false
var target_hour: float = _time_phase_hour(phase_name)
if not _world_time.set_authoritative_time(target_hour):
return false
broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
return true
func _apply_world_weather_change(weather_name: String) -> bool:
if _world_weather == null:
return false
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty():
return false
var target_weather: WorldWeatherService.Weather = (
_weather_for_name(normalized)
)
if not _world_weather.set_authoritative_weather(target_weather):
return false
broadcast_system_message("World weather set to %s." % normalized)
return true
func _send_world_command_result(
peer_id: int,
success: bool,
message: String,
) -> void:
if peer_id > 1 and _session.is_authenticated_peer(peer_id):
receive_world_command_result.rpc_id(peer_id, success, message.left(120))
func _consume_world_command_rate(peer_id: int) -> bool:
var now_msec: int = Time.get_ticks_msec()
var last_msec: int = _last_world_command_msec.get(
peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS
)
if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS:
return false
_last_world_command_msec[peer_id] = now_msec
return true
static func _valid_time_phase(phase_name: String) -> bool:
return phase_name in ["dawn", "day", "dusk", "night"]
static func _time_phase_hour(phase_name: String) -> float:
match phase_name:
"dawn":
return WorldTimeService.DAWN_START_HOUR
"day":
return WorldTimeService.DAWN_END_HOUR
"dusk":
return WorldTimeService.DUSK_START_HOUR
"night":
return WorldTimeService.DUSK_END_HOUR
return -1.0
static func _normalized_weather_name(weather_name: String) -> String:
var normalized: String = weather_name.strip_edges().to_lower()
return "clear" if normalized == "sunny" else normalized if normalized in [
"clear", "cloudy", "rainy", "foggy"
] else ""
static func _weather_for_name(
weather_name: String,
) -> WorldWeatherService.Weather:
match weather_name:
"cloudy":
return WorldWeatherService.Weather.CLOUDY
"rainy":
return WorldWeatherService.Weather.RAINY
"foggy":
return WorldWeatherService.Weather.FOGGY
return WorldWeatherService.Weather.SUNNY
func get_history() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for message: Dictionary in _history:
@ -638,7 +445,6 @@ func _on_peer_removed(peer_id: int) -> void:
_rate_times.erase(peer_id)
_last_call_msec.erase(peer_id)
_call_variant_indices.erase(peer_id)
_last_world_command_msec.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
@ -701,7 +507,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_rate_times.clear()
_last_call_msec.clear()
_call_variant_indices.clear()
_last_world_command_msec.clear()
_peer_names.clear()
_sequence = 0
history_replaced.emit([])

View file

@ -253,7 +253,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
_record_and_reject(peer_id, request_id, "Already fishing.")
return
if not bool(data["capacity_available"]):
_record_and_reject(peer_id, request_id, "Cooler is full.")
_record_and_reject(peer_id, request_id, "Inventory is full.")
return
var rod := _item_catalog.get_item_by_id(
StringName(str(data["rod_id"]))
@ -812,11 +812,8 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
return
var can_accept: bool = (
_local_inventory != null
and _local_capacity != null
and (
_local_inventory.contains_catch_id(StringName(data["catch_id"]))
or _local_inventory.get_all_catches().size()
< _local_capacity.get_capacity()
and _local_inventory.can_accept_catch(
StringName(data["catch_id"])
)
)
if _session.is_host():
@ -864,7 +861,7 @@ func _handle_capacity_response(
):
return
if not can_accept:
_cancel_attempt(peer_id, "Cooler is full.")
_cancel_attempt(peer_id, "Inventory is full.")
return
_finalize_catch(attempt)
@ -915,11 +912,7 @@ func _apply_target_outcome(data: Dictionary) -> void:
if fish_catch == null:
return
var already_owned: bool = _local_inventory.contains_catch_id(catch_id)
if (
not already_owned
and _local_inventory.get_all_catches().size()
>= _local_capacity.get_capacity()
):
if not already_owned and not _local_inventory.can_accept_catch(catch_id):
return
if not already_owned:
var experience_award: int = (

View file

@ -389,6 +389,10 @@ func _apply_equipped(data: Dictionary) -> void:
item_id == FishingShopStockType.CRAB_NET_ID
and bool(data["owns_item"]),
)
avatar.set_active_shovel(
item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and bool(data["owns_item"]),
)
equipped_state_changed.emit(
peer_id, StringName(str(data["item_id"])), int(data["category"])
)

View file

@ -928,7 +928,9 @@ func _can_receive(attachment: Dictionary) -> bool:
return _wallet.can_credit(int(attachment["amount"]))
PlayerAssetReservationService.AttachmentType.FISH:
return (
_inventory.get_all_catches().size() < _cooler_capacity.get_capacity()
_inventory.can_accept_catch(
StringName(str(attachment["catch_id"]))
)
and not _inventory.contains_catch_id(
StringName(str(attachment["catch_id"]))
)

View file

@ -21,6 +21,7 @@ const MAIL_RELIABLE_CHANNEL: int = 9
const ENET_CHANNEL_COUNT: int = 10
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1"
const WORLD_TIME_CAPABILITY: String = "world_time_v1"
const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
@ -176,6 +177,7 @@ static func make_client_hello(
JOBS_CAPABILITY,
WORLD_SPAWN_CAPABILITY,
APPEARANCE_PREVIEW_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -296,6 +298,7 @@ static func make_server_hello(
"sale_v1",
"shop_v1",
ART_SHOP_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
"item_use_v1",
"equipment_v1",
"fish_showcase_v1",

View file

@ -6,6 +6,8 @@ const RELIABLE_CHANNEL: int = NetworkProtocol.SALE_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_CATCH_ID_LENGTH: int = 160
const MAX_CATCHES_PER_REQUEST: int = 64
const MAX_ITEM_STACKS_PER_REQUEST: int = 20
const MAX_ITEM_QUANTITY: int = 999
const MAX_MESSAGE_LENGTH: int = 160
enum Rejection {
@ -40,6 +42,7 @@ static func validate_request(data: Variant) -> String:
var session_id: String = payload["session_id"]
var buyer_id: String = str(payload["buyer_id"])
var catches: Array = payload["catches"]
var items: Array = payload.get("items", [])
if (
request_id.is_empty()
or request_id.length() > MAX_ID_LENGTH
@ -47,8 +50,10 @@ static func validate_request(data: Variant) -> String:
or session_id.length() > MAX_ID_LENGTH
or buyer_id.is_empty()
or buyer_id.length() > MAX_ID_LENGTH
or catches.is_empty()
or typeof(items) != TYPE_ARRAY
or (catches.is_empty() and items.is_empty())
or catches.size() > MAX_CATCHES_PER_REQUEST
or items.size() > MAX_ITEM_STACKS_PER_REQUEST
):
return "Sale could not be completed."
var seen: Dictionary[String, bool] = {}
@ -74,6 +79,23 @@ static func validate_request(data: Variant) -> String:
):
return "Sale could not be completed."
seen[catch_id] = true
var seen_items: Dictionary[String, bool] = {}
for value: Variant in items:
if typeof(value) != TYPE_DICTIONARY:
return "Sale could not be completed."
var item: Dictionary = value
var item_id := str(item.get("item_id", ""))
var quantity := int(item.get("quantity", 0))
if (
item_id.is_empty()
or item_id.length() > MAX_ID_LENGTH
or seen_items.has(item_id)
or typeof(item.get("quantity")) != TYPE_INT
or quantity < 1
or quantity > MAX_ITEM_QUANTITY
):
return "Sale could not be completed."
seen_items[item_id] = true
return ""
@ -93,6 +115,8 @@ static func validate_result(data: Variant) -> bool:
and typeof(payload.get("accepted")) == TYPE_BOOL
and typeof(payload.get("catch_ids")) == TYPE_ARRAY
and payload["catch_ids"].size() <= MAX_CATCHES_PER_REQUEST
and typeof(payload.get("items", [])) == TYPE_ARRAY
and payload.get("items", []).size() <= MAX_ITEM_STACKS_PER_REQUEST
and typeof(payload.get("payout")) == TYPE_INT
and int(payload["payout"]) >= 0
and typeof(payload.get("base_value")) == TYPE_INT
@ -119,4 +143,20 @@ static func validate_result(data: Variant) -> bool:
):
return false
seen[catch_id] = true
var seen_items: Dictionary[String, bool] = {}
for value: Variant in payload.get("items", []):
if typeof(value) != TYPE_DICTIONARY:
return false
var item := value as Dictionary
var item_id := str(item.get("item_id", ""))
if (
item_id.is_empty()
or item_id.length() > MAX_ID_LENGTH
or seen_items.has(item_id)
or typeof(item.get("quantity")) != TYPE_INT
or int(item["quantity"]) < 1
or int(item["quantity"]) > MAX_ITEM_QUANTITY
):
return false
seen_items[item_id] = true
return true

View file

@ -8,6 +8,10 @@ const FishPoolType = preload("res://fish/fish_pool.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const ItemDataType = preload("res://items/item_data.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const ItemResalePolicyType = preload("res://economy/item_resale_policy.gd")
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const PELICAN_BUYER_ID: StringName = &"pelicans"
@ -27,6 +31,8 @@ var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _shop_interaction: FishingShopInteraction
var _inventory: FishInventory
var _bag: PlayerBagType
var _item_catalog: ItemCatalogType
var _wallet: PlayerWallet
var _sale_service: FishSaleServiceType
var _save_manager: PlayerSaveManager
@ -40,8 +46,10 @@ var _applied_results: Dictionary[String, bool] = {}
var _received_results: Dictionary[String, bool] = {}
var _pending_local_request_id: String = ""
var _pending_local_catch_ids: Array[StringName] = []
var _pending_local_items: Array[Dictionary] = []
var _pending_local_buyer_id: StringName
var _reservations: PlayerAssetReservationService
var _inventory_layout: PlayerInventoryLayout
func setup(
@ -50,18 +58,23 @@ func setup(
network_fishing: NetworkFishingService,
shop_interaction: FishingShopInteraction,
inventory: FishInventory,
bag: PlayerBagType,
item_catalog: ItemCatalogType,
wallet: PlayerWallet,
sale_service: FishSaleServiceType,
save_manager: PlayerSaveManager,
fish_catalog: FishPoolType,
buyers: Array[FishBuyerProfileType],
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
) -> void:
_session = session
_spawn_service = spawn_service
_network_fishing = network_fishing
_shop_interaction = shop_interaction
_inventory = inventory
_bag = bag
_item_catalog = item_catalog
_wallet = wallet
_sale_service = sale_service
_save_manager = save_manager
@ -71,6 +84,7 @@ func setup(
if buyer != null and buyer.is_valid():
_buyers[buyer.id] = buyer
_reservations = reservations
_inventory_layout = inventory_layout
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -102,6 +116,15 @@ func can_request_sale(
func request_local_sale(
catch_ids: Array[StringName],
buyer_id: StringName = PELICAN_BUYER_ID,
) -> String:
var no_items: Dictionary[StringName, int] = {}
return request_local_mixed_sale(catch_ids, no_items, buyer_id)
func request_local_mixed_sale(
catch_ids: Array[StringName],
item_quantities: Dictionary[StringName, int],
buyer_id: StringName = MAIN_SHOP_BUYER_ID,
) -> String:
for catch_id: StringName in catch_ids:
if _reservations != null and _reservations.is_fish_reserved(catch_id):
@ -123,7 +146,12 @@ func request_local_sale(
0
)
return ""
if catch_ids.is_empty() or _inventory == null:
if (
(catch_ids.is_empty() and item_quantities.is_empty())
or _inventory == null
or _bag == null
or _item_catalog == null
):
local_sale_finished.emit(
"", false, "Sale could not be completed.", _empty_catch_ids(), 0
)
@ -143,15 +171,41 @@ func request_local_sale(
)
return ""
evidence.append(fish_catch.to_network_dict())
var item_evidence: Array[Dictionary] = []
var item_ids: Array[StringName] = []
item_ids.assign(item_quantities.keys())
item_ids.sort_custom(
func(left: StringName, right: StringName) -> bool:
return str(left) < str(right)
)
for item_id: StringName in item_ids:
var quantity := int(item_quantities.get(item_id, 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
if (
quantity < 1
or not ItemResalePolicyType.is_sellable(item)
or quantity > _bag.get_quantity(item_id)
or (
_reservations != null
and quantity > _reservations.get_available_item_quantity(item_id)
)
):
local_sale_finished.emit(
"", false, "That item is no longer available.", _empty_catch_ids(), 0
)
return ""
item_evidence.append({"item_id": str(item_id), "quantity": quantity})
var request_id: String = _new_id("sale")
var request: Dictionary = {
"request_id": request_id,
"session_id": _session.get_session_id(),
"buyer_id": str(buyer.id),
"catches": evidence,
"items": item_evidence,
}
_pending_local_request_id = request_id
_pending_local_catch_ids = catch_ids.duplicate()
_pending_local_items = item_evidence.duplicate(true)
_pending_local_buyer_id = buyer.id
local_sale_pending.emit(request_id)
if _session.is_host():
@ -219,7 +273,11 @@ func _handle_sale_request(peer_id: int, data: Dictionary) -> void:
))
return
var result: Dictionary = _build_authoritative_result(
peer_id, request_id, data["catches"], buyer
peer_id,
request_id,
data["catches"],
data.get("items", []),
buyer,
)
_pending_by_peer[peer_id] = request_id
_record_and_send(peer_id, result)
@ -229,9 +287,14 @@ func _build_authoritative_result(
peer_id: int,
request_id: String,
evidence_values: Array,
item_values: Array,
buyer: FishBuyerProfileType,
) -> Dictionary:
if _fish_catalog == null or buyer == null:
if (
buyer == null
or (not evidence_values.is_empty() and _fish_catalog == null)
or (not item_values.is_empty() and _item_catalog == null)
):
return _rejected_result(
request_id, "The buyer is unavailable."
)
@ -283,6 +346,27 @@ func _build_authoritative_result(
base_value += decoded.sale_value
payout += offer
catch_ids.append(str(decoded.catch_id))
var items: Array[Dictionary] = []
if not item_values.is_empty() and buyer.id != MAIN_SHOP_BUYER_ID:
return _rejected_result(request_id, "This buyer only accepts catches.")
for value: Variant in item_values:
var evidence := value as Dictionary
var item_id := StringName(str(evidence.get("item_id", "")))
var quantity := int(evidence.get("quantity", 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
var unit_value := ItemResalePolicyType.get_unit_value(item)
if quantity < 1 or unit_value < 0:
return _rejected_result(request_id, "That item cannot be sold.")
var item_value := unit_value * quantity
if (
item_value < 0
or base_value > 9223372036854775807 - item_value
or payout > 9223372036854775807 - item_value
):
return _rejected_result(request_id, "Sale could not be completed.")
base_value += item_value
payout += item_value
items.append({"item_id": str(item_id), "quantity": quantity})
return {
"result_id": _new_id("sale_result"),
"request_id": request_id,
@ -291,6 +375,7 @@ func _build_authoritative_result(
"buyer_id": str(buyer.id),
"accepted": true,
"catch_ids": catch_ids,
"items": items,
"payout": payout,
"base_value": base_value,
"message": "Sale complete.",
@ -313,6 +398,7 @@ func _rejected_result(request_id: String, message: String) -> Dictionary:
"target_peer_id": 0,
"accepted": false,
"catch_ids": [],
"items": [],
"payout": 0,
"base_value": 0,
"message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH),
@ -388,6 +474,12 @@ func _apply_sale_result(data: Dictionary) -> void:
if catch_ids != _pending_local_catch_ids:
_fail_local_apply(data, "Sale could not be completed.")
return
var items: Array[Dictionary] = []
for value: Variant in data.get("items", []):
items.append((value as Dictionary).duplicate(true))
if items != _pending_local_items:
_fail_local_apply(data, "Sale could not be completed.")
return
for catch_id: StringName in catch_ids:
if _reservations != null and _reservations.is_fish_reserved(catch_id):
_fail_local_apply(data, "Reserved in a letter.")
@ -396,10 +488,12 @@ func _apply_sale_result(data: Dictionary) -> void:
if buyer == null or not buyer.is_valid():
_fail_local_apply(data, "The buyer is unavailable.")
return
var preview: FishSaleResultType = _sale_service.preview_batch(
catch_ids, buyer
)
if not preview.is_success():
var catch_payout: int = 0
var catch_base_value: int = 0
var preview: FishSaleResultType
if not catch_ids.is_empty():
preview = _sale_service.preview_batch(catch_ids, buyer)
if preview != null and not preview.is_success():
var message: String = (
"Favorite catches cannot be sold."
if preview.status == FishSaleResultType.Status.FAVORITED
@ -407,22 +501,63 @@ func _apply_sale_result(data: Dictionary) -> void:
)
_fail_local_apply(data, message)
return
if preview != null:
catch_payout = preview.payout
catch_base_value = preview.base_value
var item_payout: int = 0
for record: Dictionary in items:
var item_id := StringName(str(record.get("item_id", "")))
var quantity := int(record.get("quantity", 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
var unit_value := ItemResalePolicyType.get_unit_value(item)
if (
quantity < 1
or unit_value < 0
or quantity > _bag.get_quantity(item_id)
or (
_reservations != null
and quantity > _reservations.get_available_item_quantity(item_id)
)
):
_fail_local_apply(data, "That item is no longer available.")
return
item_payout += unit_value * quantity
if (
preview.payout != int(data["payout"])
or preview.base_value != int(data["base_value"])
catch_payout + item_payout != int(data["payout"])
or catch_base_value + item_payout != int(data["base_value"])
):
_fail_local_apply(data, "Sale could not be completed.")
return
var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches()
var sequence_snapshot: int = _inventory.get_next_catch_sequence()
var wallet_snapshot: int = _wallet.get_balance()
var local_result: FishSaleResultType = _sale_service.sell_batch(
catch_ids, buyer
var bag_snapshot := _bag.get_all_items()
var layout_snapshot: Dictionary = (
_inventory_layout.to_save_data()
if _inventory_layout != null else {}
)
if not local_result.is_success() or not _save_manager.save_if_dirty():
var wallet_snapshot: int = _wallet.get_balance()
var applied: bool = true
if not catch_ids.is_empty():
applied = (
_inventory.remove_catches_by_ids(catch_ids).size()
== catch_ids.size()
)
if applied:
for record: Dictionary in items:
if not _bag.remove_item(
StringName(str(record["item_id"])), int(record["quantity"])
):
applied = false
break
if applied:
applied = _wallet.credit(int(data["payout"]))
if not applied or not _save_manager.save_if_dirty():
_inventory.replace_all_catches(
inventory_snapshot, sequence_snapshot
)
_bag.replace_all_items(bag_snapshot)
if _inventory_layout != null:
_inventory_layout.restore_from_save_data(layout_snapshot)
_wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty()
_fail_local_apply(data, "Sale could not be completed.")
@ -457,6 +592,7 @@ func _finish_local_sale(
) -> void:
_pending_local_request_id = ""
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()
local_sale_finished.emit(
request_id, accepted, message, catch_ids, payout
@ -591,6 +727,7 @@ func _clear_session_state() -> void:
_received_results.clear()
_pending_local_request_id = ""
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()

View file

@ -271,6 +271,7 @@ func _register_player_host() -> void:
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.JOBS_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -554,6 +555,7 @@ func supports_server_capability(capability: StringName) -> bool:
return str(capability) in PackedStringArray([
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
NetworkProtocol.ART_SHOP_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
"item_use_v1", "equipment_v1", "fish_showcase_v1",
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
@ -1365,6 +1367,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)

View file

@ -3,6 +3,7 @@ extends RefCounted
const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_shop_v1"
const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
@ -16,6 +17,7 @@ enum ProductCategory {
COOLER_CAPACITY_UPGRADE,
ART_KIT,
ART_UPGRADE,
BACKPACK_CAPACITY_UPGRADE,
}

View file

@ -13,6 +13,7 @@ const SHOP_ID: StringName = &"main_fishing_shop"
const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade"
const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
const BACKPACK_PRODUCT_ID: StringName = &"backpack_capacity_upgrade"
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
signal local_purchase_pending(request_id: String)
@ -35,6 +36,7 @@ var _bag: PlayerBag
var _item_catalog: ItemCatalog
var _upgrades: PlayerFishingUpgrades
var _cooler_capacity: PlayerCoolerCapacity
var _inventory_layout: PlayerInventoryLayout
var _art_unlocks: PlayerArtUnlocks
var _save_manager: PlayerSaveManager
var _request_ledgers: Dictionary[int, Dictionary] = {}
@ -60,6 +62,7 @@ func setup(
art_unlocks: PlayerArtUnlocks,
save_manager: PlayerSaveManager,
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
) -> void:
_session = session
_spawn_service = spawn_service
@ -73,6 +76,7 @@ func setup(
_art_unlocks = art_unlocks
_save_manager = save_manager
_reservations = reservations
_inventory_layout = inventory_layout
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -104,6 +108,18 @@ func can_request_art_purchase() -> bool:
)
func can_request_backpack_purchase() -> bool:
return (
can_request_purchase()
and (
_session.is_host()
or _session.supports_server_capability(
NetworkShopProtocol.BACKPACK_CAPABILITY
)
)
)
func is_local_purchase_pending() -> bool:
return not _pending_local_request.is_empty()
@ -163,6 +179,16 @@ func request_cooler_capacity_upgrade() -> String:
)
func request_backpack_capacity_upgrade() -> String:
return _request_purchase(
BACKPACK_PRODUCT_ID,
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE,
1,
_inventory_layout.get_backpack_level()
if _inventory_layout != null else 0,
)
func request_art_kit() -> String:
return _request_purchase(
ArtShopStockType.ART_KIT_ITEM_ID,
@ -230,6 +256,15 @@ func _request_purchase(
product_id, category, 0, 0,
)
return ""
if (
category == NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE
and not can_request_backpack_purchase()
):
local_purchase_finished.emit(
"", false, "Backpack upgrades require a newer server.",
product_id, category, 0, 0,
)
return ""
var request_id: String = _new_id("shop")
var request: Dictionary = {
"request_id": request_id,
@ -372,7 +407,7 @@ func _build_authoritative_result(
or current_state + quantity > item.max_stack
):
rejection = (
"Your Bag is full."
"Your inventory is full."
if item != null and current_state >= item.max_stack
else "Purchase could not be completed."
)
@ -458,6 +493,17 @@ func _build_authoritative_result(
]
)
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
product_id != BACKPACK_PRODUCT_ID
or current_state >= PlayerInventoryLayout.MAX_BACKPACK_LEVEL
):
rejection = "Upgrade is already at maximum."
else:
cost = PlayerInventoryLayout.BACKPACK_EXPANSION_COSTS[
current_state
]
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.ART_KIT:
var art_item: ItemDataType = _item_catalog.get_item_by_id(
product_id
@ -612,6 +658,10 @@ func _apply_purchase_result(data: Dictionary) -> void:
var reel_snapshot: int = _upgrades.get_reel_speed_level()
var barrier_snapshot: int = _upgrades.get_barrier_power_level()
var cooler_snapshot: int = _cooler_capacity.get_level()
var backpack_snapshot: int = (
_inventory_layout.get_backpack_level()
if _inventory_layout != null else 0
)
var art_snapshot: int = _art_unlocks.get_unlock_mask()
var applied: bool = _apply_local_product(data)
if not applied or not _save_manager.save_if_dirty():
@ -619,6 +669,8 @@ func _apply_purchase_result(data: Dictionary) -> void:
_bag.replace_unlocked_bait_ids(bait_unlock_snapshot)
_upgrades.restore_levels(reel_snapshot, barrier_snapshot)
_cooler_capacity.restore_level(cooler_snapshot)
if _inventory_layout != null:
_inventory_layout.restore_backpack_level(backpack_snapshot)
_art_unlocks.restore_mask(art_snapshot)
_wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty()
@ -687,7 +739,7 @@ func _validate_local_result(data: Dictionary) -> String:
):
return "Purchase could not be completed."
if not _bag.can_add_item(product_id, data["quantity"]):
return "Your Bag is full."
return "Your inventory is full."
NetworkShopProtocol.ProductCategory.ROD:
var rod := (
_item_catalog.get_item_by_id(product_id)
@ -719,6 +771,13 @@ func _validate_local_result(data: Dictionary) -> String:
return "Purchase could not be completed."
if _cooler_capacity.get_next_cost() != cost:
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
_inventory_layout == null
or _inventory_layout.get_backpack_level() != expected_state
or _inventory_layout.get_next_backpack_cost() != cost
):
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.ART_KIT:
var art_item: ItemDataType = (
_item_catalog.get_item_by_id(product_id)
@ -768,6 +827,11 @@ func _apply_local_product(data: Dictionary) -> bool:
return _upgrades.purchase_barrier_power(_wallet)
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
return _cooler_capacity.purchase(_wallet)
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
return (
_inventory_layout != null
and _inventory_layout.purchase_backpack(_wallet)
)
NetworkShopProtocol.ProductCategory.ART_KIT:
return (
_wallet.debit(int(data["total_cost"]))

View file

@ -39,6 +39,7 @@ var _entity_revisions: Dictionary = {}
var _surface_triangles: Dictionary = {}
var _surface_areas: Dictionary = {}
var _surface_total_areas: Dictionary = {}
var _spawn_anchor_positions: Dictionary = {}
var _respawns: Array[Dictionary] = []
var _next_respawn_by_type: Dictionary[StringName, float] = {}
var _charge_requests: Dictionary = {}
@ -151,10 +152,9 @@ func find_entity_near(
if bool(state.get("locked", false)):
continue
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
var distance_squared: float = Vector2(
entity_position.x - position.x,
entity_position.z - position.z,
).length_squared()
var distance_squared: float = entity_position.distance_squared_to(
position
)
if distance_squared <= best_distance_squared:
best_distance_squared = distance_squared
best_id = entity_id
@ -177,10 +177,9 @@ func find_capture_target(
if entry == null or entry.required_tool_id != tool_id:
continue
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
var distance_squared: float = Vector2(
entity_position.x - position.x,
entity_position.z - position.z,
).length_squared()
var distance_squared: float = entity_position.distance_squared_to(
position
)
if (
distance_squared <= entry.capture_radius * entry.capture_radius
and distance_squared <= best_distance_squared
@ -248,14 +247,30 @@ func _begin_population_if_ready() -> void:
func _cache_spawn_surface(entry: GatherableDataType) -> void:
if entry == null or _surface_triangles.has(entry.type_id):
if (
entry == null
or _surface_triangles.has(entry.type_id)
or _spawn_anchor_positions.has(entry.type_id)
):
return
var triangles: Array[PackedVector3Array] = (
_world.get_spawn_surface_triangles(
if not entry.spawn_anchor_set_id.is_empty():
var anchor_positions: PackedVector3Array = (
_world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id)
)
_spawn_anchor_positions[entry.type_id] = anchor_positions
if anchor_positions.is_empty():
push_warning(
"No gatherable spawn anchors were found for %s." % entry.type_id
)
return
var triangles: Array[PackedVector3Array]
if not entry.diggable_area_id.is_empty():
triangles = _world.get_diggable_area_triangles(entry.diggable_area_id)
else:
triangles = _world.get_spawn_surface_triangles(
entry.surface_materials,
entry.minimum_surface_y,
)
)
var areas := PackedFloat32Array()
var total_area: float = 0.0
for triangle: PackedVector3Array in triangles:
@ -279,6 +294,11 @@ func _spawn_entity(entry: GatherableDataType) -> void:
var position: Vector3 = _sample_surface_position(entry)
if not position.is_finite():
return
var target: Vector3 = (
position
if entry.is_stationary_spawn()
else _sample_surface_position(entry, position, entry.roam_radius)
)
var quality: int = FishQualityType.roll(_rng)
var entity_id: String = _new_id("world")
var state: Dictionary = {
@ -286,11 +306,16 @@ func _spawn_entity(entry: GatherableDataType) -> void:
"type_id": entry.type_id,
"data": entry,
"position": position,
"target": _sample_surface_position(entry, position, entry.roam_radius),
"target": target,
"yaw": _rng.randf_range(-PI, PI),
"quality": quality,
"revision": 1,
"locked": false,
"expires_at": (
_now() + entry.active_lifetime_seconds
if entry.active_lifetime_seconds > 0.0
else INF
),
}
if not (state["target"] as Vector3).is_finite():
state["target"] = position
@ -311,6 +336,11 @@ func _update_host_entities(delta: float) -> void:
var entry := state.get("data") as GatherableDataType
if entry == null:
continue
if _now() >= float(state.get("expires_at", INF)):
_despawn_entity(entity_id, &"expired", false, true)
continue
if entry.is_stationary_spawn():
continue
var quality: int = _get_state_quality(state)
var position: Vector3 = state["position"]
var target: Vector3 = state["target"]
@ -349,7 +379,7 @@ func _update_host_entities(delta: float) -> void:
state["yaw"] = atan2(-direction.x, -direction.z)
state["position"] = position
_entities[entity_id] = state
if _should_scare(entry, position, quality):
if entry.can_be_scared() and _should_scare(entry, position, quality):
_despawn_entity(entity_id, &"scared", true, true)
@ -383,6 +413,8 @@ func _sample_surface_position(
maximum_distance: float = INF,
) -> Vector3:
_cache_spawn_surface(entry)
if not entry.spawn_anchor_set_id.is_empty():
return _sample_anchor_position(entry, origin, maximum_distance)
var triangles: Array = _surface_triangles.get(entry.type_id, [])
var cumulative_areas: PackedFloat32Array = _surface_areas.get(
entry.type_id,
@ -420,6 +452,44 @@ func _sample_surface_position(
return fallback
func _sample_anchor_position(
entry: GatherableDataType,
origin: Vector3,
maximum_distance: float,
) -> Vector3:
var anchors: PackedVector3Array = _spawn_anchor_positions.get(
entry.type_id,
PackedVector3Array(),
)
var candidates := PackedVector3Array()
for anchor: Vector3 in anchors:
if not anchor.is_finite() or _anchor_is_occupied(entry.type_id, anchor):
continue
if (
origin.is_finite()
and is_finite(maximum_distance)
and anchor.distance_to(origin) > maximum_distance
):
continue
candidates.append(anchor)
if candidates.is_empty():
return Vector3(INF, INF, INF)
return candidates[_rng.randi_range(0, candidates.size() - 1)]
func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool:
for state: Dictionary in _entities.values():
if StringName(state.get("type_id", StringName())) != type_id:
continue
var position: Variant = state.get("position")
if (
typeof(position) == TYPE_VECTOR3
and (position as Vector3).distance_squared_to(anchor) <= 0.0001
):
return true
return false
func _broadcast_entity_snapshots() -> void:
if _entities.is_empty():
return
@ -515,7 +585,9 @@ func _handle_interaction_begin(peer_id: int, data: Dictionary) -> void:
or request_id.length() > MAX_REQUEST_ID_LENGTH
or _pending_captures.has(peer_id)
):
_send_interaction_result(peer_id, request_id, false, "Cannot use the net now.")
_send_interaction_result(
peer_id, request_id, false, "Cannot use that gathering tool now."
)
return
_charge_requests[peer_id] = {
"request_id": request_id,
@ -556,27 +628,26 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
):
error = "The catch attempt was invalid."
elif state.is_empty() or entry == null or bool(state.get("locked", false)):
error = "That animal is no longer there."
error = "That gathering spot is no longer there."
elif _now() - float(charge.get("started", _now())) + 0.05 < entry.charge_duration:
error = "Pull the net all the way back first."
error = "Finish readying the tool first."
elif _item_use.get_equipped_item_id(peer_id) != entry.required_tool_id:
error = "Equip the correct gathering tool."
elif avatar == null or not avatar.is_sneaking():
error = "Sneak closer before swinging the net."
elif avatar == null:
error = "The player is unavailable."
elif entry.requires_sneaking and not avatar.is_sneaking():
error = "Sneak closer before using the tool."
else:
var entity_position: Vector3 = state["position"]
var target_distance: float = Vector2(
entity_position.x - target_position.x,
entity_position.z - target_position.z,
).length()
var target_distance: float = entity_position.distance_to(target_position)
var player_distance: float = Vector2(
avatar.global_position.x - entity_position.x,
avatar.global_position.z - entity_position.z,
).length()
if target_distance > entry.capture_radius:
error = "The net missed."
error = "The gathering tool missed."
elif player_distance > entry.interaction_range:
error = "Move closer before swinging the net."
error = "Move closer before using the tool."
if not error.is_empty():
_send_interaction_result(peer_id, request_id, false, error)
return
@ -670,12 +741,7 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
var catch_id := StringName(str(data.get("catch_id", "")))
var can_accept: bool = (
_local_inventory != null
and _local_capacity != null
and (
_local_inventory.contains_catch_id(catch_id)
or _local_inventory.get_all_catches().size()
< _local_capacity.get_capacity()
)
and _local_inventory.can_accept_catch(catch_id)
)
if _session.is_host():
_handle_capacity_response(
@ -728,7 +794,7 @@ func _handle_capacity_response(
):
return
if not can_accept:
_reject_pending_capture(peer_id, "Cooler is full.")
_reject_pending_capture(peer_id, "Inventory is full.")
return
_pending_captures.erase(peer_id)
var entity_id: String = str(pending["entity_id"])
@ -813,8 +879,7 @@ func _apply_capture_result(data: Dictionary) -> void:
)
if (
not already_owned
and _local_inventory.get_all_catches().size()
>= _local_capacity.get_capacity()
and not _local_inventory.can_accept_catch(fish_catch.catch_id)
):
return
if not already_owned:
@ -1138,6 +1203,7 @@ func _clear_world() -> void:
_surface_triangles.clear()
_surface_areas.clear()
_surface_total_areas.clear()
_spawn_anchor_positions.clear()
_respawns.clear()
_next_respawn_by_type.clear()
_charge_requests.clear()

View file

@ -45,20 +45,16 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(true)
_world_time.begin_session(
_world_time.get_persistent_time_hours()
)
_world_time.begin_authoritative_session()
return
if state == NetworkSession.State.JOINED_CLIENT:
_active_session_id = _session.get_session_id()
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(false)
if _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
):
_world_time.begin_session()
_world_time.begin_remote_session()
else:
_world_time.end_session()
return
@ -72,7 +68,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(false)
_world_time.end_session()

View file

@ -3,7 +3,7 @@ extends Node
const SYNC_INTERVAL_SECONDS: float = 15.0
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_REMAINING_SECONDS: float = 1800.0
const MAX_REMAINING_SECONDS: float = WorldWeatherService.WEATHER_PERIOD_SECONDS
var _session: NetworkSession
var _world_weather: WorldWeatherService
@ -44,7 +44,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(true)
_world_weather.begin_authoritative_session(
_active_session_id.hash()
)
@ -53,7 +52,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_active_session_id = _session.get_session_id()
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(false)
if _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
):
@ -71,7 +69,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(false)
_world_weather.end_session()

View file

@ -19,6 +19,9 @@ const PlayerItemEffectsType = preload(
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const PlayerInventoryLayoutType = preload(
"res://inventory/player_inventory_layout.gd"
)
const PlayerArtUnlocksType = preload(
"res://progression/player_art_unlocks.gd"
)
@ -33,6 +36,7 @@ const FishingRodAttachmentScene = preload(
)
const NetAttachmentScene = preload("res://player/net_attachment.tscn")
const NetAttachmentType = preload("res://player/net_attachment.gd")
const ShovelAttachmentScene = preload("res://player/shovel_attachment.tscn")
const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const HeldItemAttachmentScene = preload(
"res://player/held_item_attachment.tscn"
@ -347,6 +351,7 @@ class ShowcaseCameraSnapshot:
@export_range(0.05, 1.0, 0.01) var showcase_restore_duration: float = 0.24
@export_range(0.05, 1.5, 0.01) var showcase_camera_transition_duration: float = 0.42
@export_range(-180.0, 180.0, 1.0) var showcase_camera_yaw_offset: float = 180.0
@export_range(-180.0, 180.0, 1.0) var net_showcase_camera_yaw_offset: float = 0.0
@export_range(-80.0, 80.0, 1.0) var showcase_camera_pitch: float = -8.0
@export_range(1.0, 10.0, 0.1) var showcase_camera_zoom_distance: float = 3.6
@export_range(0.5, 3.0, 0.05) var showcase_camera_target_height: float = 1.45
@ -394,6 +399,7 @@ class ShowcaseCameraSnapshot:
@onready var fishing_upgrades: PlayerFishingUpgradesType = %FishingUpgrades
@onready var item_effects: PlayerItemEffectsType = %ItemEffects
@onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity
@onready var inventory_layout: PlayerInventoryLayoutType = %InventoryLayout
@onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks
@onready var experience: PlayerExperienceType = %Experience
@onready var _cast_origin: Marker3D = %CastOrigin
@ -428,6 +434,7 @@ var _remote_recovery_elapsed: float = 0.0
var _target_zoom: float = 5.0
var _showcase_rod_visibility: bool = true
var _showcase_net_visibility: bool = false
var _showcase_shovel_visibility: bool = false
var _remote_presentation_visible := true
var _showcase_rod_state_stored: bool = false
var _showcase_visual_rotation: Vector3
@ -502,6 +509,7 @@ var _retract_animation_completed: bool = true
var _fishing_rod: Node3D
var _catching_net: Node3D
var _catching_net_attachment: NetAttachmentType
var _shovel: Node3D
var _net_strike_collision_armed: bool = false
var _net_strike_has_previous_sample: bool = false
var _net_strike_previous_position: Vector3
@ -520,6 +528,7 @@ var _custom_fishing_rod_visual: Node3D
var _active_fishing_rod_id: StringName
var _active_item_is_rod: bool = false
var _active_item_is_net: bool = false
var _active_item_is_shovel: bool = false
var _controller_mapping_manager: ControllerMappingManagerType
var _sprint_dust_landing_ready: bool = false
var _sprint_dust_airborne: bool = false
@ -545,6 +554,7 @@ func _ready() -> void:
_apply_presented_appearance()
_initialize_fishing_rod()
_initialize_catching_net()
_initialize_shovel()
_target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom)
_spring_arm.spring_length = _target_zoom
_camera.current = local_control_enabled
@ -861,6 +871,23 @@ func _initialize_catching_net() -> void:
_catching_net.visible = false
func _initialize_shovel() -> 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 shovel.")
return
var attachment := ShovelAttachmentScene.instantiate() as BoneAttachment3D
if attachment == null:
push_error("Shovel attachment could not be instantiated.")
return
skeleton.add_child(attachment)
_shovel = attachment.get_node("Shovel") as Node3D
if _shovel != null:
_shovel.visible = false
func _initialize_held_item_attachment() -> void:
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
@ -2557,6 +2584,20 @@ func set_active_catching_net(should_show: bool) -> void:
)
func set_active_shovel(should_show: bool) -> void:
_active_item_is_shovel = should_show
if not should_show:
cancel_net_action_visual()
if _shovel == null:
return
if _showcase_rod_state_stored:
_showcase_shovel_visibility = should_show
return
_shovel.visible = (
should_show and not _has_held_show_item() and not _showcase_animation_active
)
func _apply_fishing_rod_model(rod: FishingRodDataType) -> void:
var next_id: StringName = rod.item_id if rod != null else StringName()
if next_id == _active_fishing_rod_id:
@ -2625,6 +2666,12 @@ func _apply_active_rod_visibility() -> void:
and not _has_held_show_item()
and not _showcase_animation_active
)
if _shovel != null:
_shovel.visible = (
_active_item_is_shovel
and not _has_held_show_item()
and not _showcase_animation_active
)
func set_active_art_kit(icon: Texture2D, should_show: bool) -> void:
@ -2655,6 +2702,8 @@ func set_active_art_kit(icon: Texture2D, should_show: bool) -> void:
_fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if _shovel != null:
_shovel.visible = false
if item_changed:
_begin_pocket_visual(
PocketVisualTarget.ART_KIT,
@ -2705,6 +2754,8 @@ func set_held_fish(
_fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if _shovel != null:
_shovel.visible = false
if item_changed:
_begin_pocket_visual(
PocketVisualTarget.HELD_FISH,
@ -2919,10 +2970,13 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void:
_pending_net_showcase_catch = fish_catch
_pending_net_showcase_remote = false
return
_begin_catch_showcase_now(fish_catch)
_begin_catch_showcase_now(fish_catch, showcase_camera_yaw_offset)
func _begin_catch_showcase_now(fish_catch: FishCatchType) -> void:
func _begin_catch_showcase_now(
fish_catch: FishCatchType,
camera_yaw_offset_degrees: float,
) -> void:
if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE:
_cancel_pocket_visual()
_showcase_animation_active = true
@ -2933,17 +2987,24 @@ func _begin_catch_showcase_now(fish_catch: FishCatchType) -> void:
if not _showcase_visual_rotation_stored:
_showcase_visual_rotation = _visuals.rotation
_showcase_visual_rotation_stored = true
var showcase_camera_position: Vector3 = _begin_showcase_camera_transition()
var showcase_camera_position: Vector3 = _begin_showcase_camera_transition(
camera_yaw_offset_degrees
)
_turn_showcase_toward_position(showcase_camera_position)
if not _showcase_rod_state_stored:
_showcase_rod_visibility = _fishing_rod.visible
_showcase_net_visibility = (
_catching_net.visible if _catching_net != null else false
)
_showcase_shovel_visibility = (
_shovel.visible if _shovel != null else false
)
_showcase_rod_state_stored = true
_fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if _shovel != null:
_shovel.visible = false
_catch_sprite.texture = fish_catch.fish.display_texture
_catch_display.scale = (
Vector3.ONE
@ -2972,10 +3033,15 @@ func _begin_remote_catch_showcase_now(fish_catch: FishCatchType) -> void:
_showcase_net_visibility = (
_catching_net.visible if _catching_net != null else false
)
_showcase_shovel_visibility = (
_shovel.visible if _shovel != null else false
)
_showcase_rod_state_stored = true
_fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if _shovel != null:
_shovel.visible = false
_catch_sprite.texture = fish_catch.fish.display_texture
_catch_display.scale = (
Vector3.ONE
@ -3009,7 +3075,10 @@ func _begin_pending_net_showcase() -> void:
if remote:
_begin_remote_catch_showcase_now(fish_catch)
else:
_begin_catch_showcase_now(fish_catch)
_begin_catch_showcase_now(
fish_catch,
net_showcase_camera_yaw_offset,
)
func end_catch_showcase(
@ -3043,8 +3112,11 @@ func end_catch_showcase(
_fishing_rod.visible = _showcase_rod_visibility
if _catching_net != null:
_catching_net.visible = _showcase_net_visibility
if _shovel != null:
_shovel.visible = _showcase_shovel_visibility
_showcase_rod_visibility = true
_showcase_net_visibility = false
_showcase_shovel_visibility = false
_showcase_rod_state_stored = false
if (
not _showcase_visual_rotation_stored
@ -3230,10 +3302,12 @@ func _capture_showcase_camera_snapshot() -> void:
_set_camera_dragging(false)
func _begin_showcase_camera_transition() -> Vector3:
func _begin_showcase_camera_transition(
camera_yaw_offset_degrees: float,
) -> Vector3:
var target_world_yaw: float = (
_visuals.global_rotation.y
+ deg_to_rad(showcase_camera_yaw_offset)
+ deg_to_rad(camera_yaw_offset_degrees)
)
var target_local_yaw: float = target_world_yaw - global_rotation.y
var shortest_target_yaw: float = (

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=22 format=3]
[gd_scene load_steps=23 format=3]
[ext_resource type="Script" path="res://player/player.gd" id="1_script"]
[ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"]
@ -16,6 +16,7 @@
[ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"]
[ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"]
[ext_resource type="Script" path="res://player/sprint_dust_trail.gd" id="16_sprint_dust"]
[ext_resource type="Script" path="res://inventory/player_inventory_layout.gd" id="17_inventory_layout"]
[sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45
@ -119,6 +120,10 @@ script = ExtResource("9_effects")
unique_name_in_owner = true
script = ExtResource("10_capacity")
[node name="InventoryLayout" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("17_inventory_layout")
[node name="ArtUnlocks" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("14_art_unlocks")

View file

@ -0,0 +1,68 @@
[gd_scene load_steps=8 format=3]
[ext_resource type="Script" path="res://player/tool_socket_attachment.gd" id="1_socket"]
[sub_resource type="StandardMaterial3D" id="HandleMaterial"]
shading_mode = 0
albedo_color = Color(0.36, 0.2, 0.1, 1)
metallic = 0.0
roughness = 1.0
[sub_resource type="StandardMaterial3D" id="BladeMaterial"]
shading_mode = 0
albedo_color = Color(0.34, 0.57, 0.58, 1)
metallic = 0.0
roughness = 1.0
[sub_resource type="CylinderMesh" id="ShaftMesh"]
top_radius = 0.018
bottom_radius = 0.018
height = 0.7
radial_segments = 8
rings = 1
material = SubResource("HandleMaterial")
[sub_resource type="BoxMesh" id="BladeMesh"]
size = Vector3(0.19, 0.25, 0.025)
material = SubResource("BladeMaterial")
[sub_resource type="BoxMesh" id="GripCrossMesh"]
size = Vector3(0.18, 0.035, 0.035)
material = SubResource("HandleMaterial")
[sub_resource type="BoxMesh" id="GripSideMesh"]
size = Vector3(0.035, 0.13, 0.035)
material = SubResource("HandleMaterial")
[node name="ShovelAttachment" type="BoneAttachment3D"]
bone_name = "rod_socket"
script = ExtResource("1_socket")
aligned_mount_path = NodePath("Shovel")
[node name="Shovel" type="Node3D" parent="."]
visible = false
[node name="Shaft" type="MeshInstance3D" parent="Shovel"]
position = Vector3(0, 0.35, 0)
cast_shadow = 0
mesh = SubResource("ShaftMesh")
[node name="Blade" type="MeshInstance3D" parent="Shovel"]
position = Vector3(0, 0.78, 0)
cast_shadow = 0
mesh = SubResource("BladeMesh")
[node name="GripCross" type="MeshInstance3D" parent="Shovel"]
position = Vector3(0, -0.09, 0)
cast_shadow = 0
mesh = SubResource("GripCrossMesh")
[node name="GripLeft" type="MeshInstance3D" parent="Shovel"]
position = Vector3(-0.0725, -0.035, 0)
cast_shadow = 0
mesh = SubResource("GripSideMesh")
[node name="GripRight" type="MeshInstance3D" parent="Shovel"]
position = Vector3(0.0725, -0.035, 0)
cast_shadow = 0
mesh = SubResource("GripSideMesh")

View file

@ -5,9 +5,13 @@ const PlayerWalletType = preload("res://economy/player_wallet.gd")
signal capacity_changed(level: int, capacity: int)
const CAPACITIES: Array[int] = [12, 18, 24, 32, 40]
const EXPANSION_COSTS: Array[int] = [75, 175, 400, 850]
const MAX_LEVEL: int = 4
## Kept under the historical class name so existing saves and network product
## identifiers migrate without losing their purchased level. This progression
## now controls the player's private storage box rather than carried catches.
const CAPACITIES: Array[int] = [9, 18, 27, 36, 45, 54, 63, 72]
const EXPANSION_COSTS: Array[int] = [75, 175, 400, 850, 1500, 2500, 4000]
const MAX_LEVEL: int = 7
const MAX_CAPACITY: int = 72
var _capacity_level: int = 0

View file

@ -12,6 +12,9 @@ const ItemCatalogType = preload("res://items/item_catalog.gd")
const OwnedItemType = preload("res://items/owned_item.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const PlayerInventoryLayoutType = preload(
"res://inventory/player_inventory_layout.gd"
)
const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
)
@ -30,7 +33,7 @@ const WorldWeatherServiceType = preload(
)
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const SAVE_VERSION: int = 7
const SAVE_VERSION: int = 8
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000
@ -47,6 +50,7 @@ class LoadSnapshot:
var hotbar_slots: Array[StringName] = []
var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0
var inventory_layout_data: Dictionary = {}
var reel_speed_level: int = 0
var barrier_power_level: int = 0
var cooler_capacity_level: int = 0
@ -71,6 +75,7 @@ var _wallet: PlayerWalletType
var _catalog: FishPoolType
var _bag: PlayerBagType
var _hotbar: PlayerHotbarType
var _inventory_layout: PlayerInventoryLayoutType
var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType
var _cooler_capacity: PlayerCoolerCapacityType
@ -117,6 +122,7 @@ func setup(
catalog: FishPoolType,
bag: PlayerBagType,
hotbar: PlayerHotbarType,
inventory_layout: PlayerInventoryLayoutType,
item_catalog: ItemCatalogType,
fishing_upgrades: PlayerFishingUpgradesType,
cooler_capacity: PlayerCoolerCapacityType,
@ -132,6 +138,7 @@ func setup(
_catalog = catalog
_bag = bag
_hotbar = hotbar
_inventory_layout = inventory_layout
_item_catalog = item_catalog
_fishing_upgrades = fishing_upgrades
_cooler_capacity = cooler_capacity
@ -147,6 +154,7 @@ func setup(
and _catalog != null
and _bag != null
and _hotbar != null
and _inventory_layout != null
and _item_catalog != null
and _fishing_upgrades != null
and _cooler_capacity != null
@ -171,6 +179,8 @@ func setup(
_bag.contents_changed.connect(_mark_dirty)
if not _hotbar.slots_changed.is_connected(_mark_dirty):
_hotbar.slots_changed.connect(_mark_dirty)
if not _inventory_layout.layout_changed.is_connected(_mark_dirty):
_inventory_layout.layout_changed.connect(_mark_dirty)
if not _hotbar.selected_slot_changed.is_connected(
_on_selected_hotbar_slot_changed
):
@ -263,11 +273,6 @@ func load_player_data() -> bool:
_bag.replace_all_items(snapshot.bag_items)
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
)
var hotbar_restored: bool = _hotbar.replace_state(
snapshot.hotbar_slots,
snapshot.selected_hotbar_slot,
snapshot.fish_hotbar_slots,
)
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level,
snapshot.barrier_power_level
@ -275,6 +280,15 @@ func load_player_data() -> bool:
var cooler_restored: bool = _cooler_capacity.restore_level(
snapshot.cooler_capacity_level
)
var layout_restored: bool = _inventory_layout.restore_from_save_data(
snapshot.inventory_layout_data
)
var hotbar_restored: bool = _hotbar.replace_state(
snapshot.hotbar_slots,
snapshot.selected_hotbar_slot,
snapshot.fish_hotbar_slots,
false,
)
var art_restored: bool = _art_unlocks.restore_mask(
snapshot.art_unlock_mask
)
@ -300,13 +314,36 @@ func load_player_data() -> bool:
or not hotbar_restored
or not upgrades_restored
or not cooler_restored
or not layout_restored
or not art_restored
or not experience_restored
or not world_time_restored
or not world_weather_restored
or not jobs_restored
):
push_error("Validated player save could not be restored.")
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s"
)
% [
inventory_restored,
collection_restored,
wallet_restored,
bag_restored,
hotbar_restored,
upgrades_restored,
cooler_restored,
layout_restored,
art_restored,
experience_restored,
world_time_restored,
world_weather_restored,
jobs_restored,
]
)
return false
_is_dirty = false
@ -541,6 +578,7 @@ func _build_save_dictionary() -> Dictionary:
"slots": serialized_slots,
"fish_slots": serialized_fish_slots,
},
"inventory_layout": _inventory_layout.to_save_data(),
"upgrades": _fishing_upgrades.to_save_data(),
"cooler": _cooler_capacity.to_save_data(),
"art": _art_unlocks.to_save_data(),
@ -563,6 +601,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
):
@ -572,6 +611,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"]
var hotbar_data: Dictionary = save_data["hotbar"]
var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"]
var jobs_data: Dictionary = save_data["jobs"]
var world_data: Dictionary = {}
@ -624,6 +664,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
return null
var snapshot := LoadSnapshot.new()
snapshot.inventory_layout_data = inventory_layout_data.duplicate(true)
snapshot.wallet_balance = balance
snapshot.total_experience = _read_integer(
experience_data["total_experience"],
@ -922,6 +963,8 @@ func _migrate_save(
migrated = _migrate_version_5_to_6(migrated)
6:
migrated = _migrate_version_6_to_7(migrated)
7:
migrated = _migrate_version_7_to_8(migrated)
_:
return {}
if migrated.is_empty():
@ -1040,6 +1083,124 @@ func _migrate_version_6_to_7(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_7_to_8(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
if (
typeof(migrated.get("bag")) != TYPE_DICTIONARY
or typeof(migrated.get("inventory")) != TYPE_DICTIONARY
or typeof(migrated.get("hotbar")) != TYPE_DICTIONARY
):
return {}
var bag_data: Dictionary = migrated["bag"]
var inventory_data: Dictionary = migrated["inventory"]
var hotbar_data: Dictionary = migrated["hotbar"]
if (
typeof(bag_data.get("items")) != TYPE_ARRAY
or typeof(inventory_data.get("catches")) != TYPE_ARRAY
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
):
return {}
var placements: Array[Dictionary] = []
var assigned: Dictionary[String, bool] = {}
var item_slots: Array = hotbar_data["slots"]
var fish_slots: Array = []
if typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY:
fish_slots = hotbar_data["fish_slots"]
for slot: int in PlayerHotbarType.SLOT_COUNT:
var item_id := StringName(
str(item_slots[slot]) if slot < item_slots.size() else ""
)
if not item_id.is_empty():
var item = _item_catalog.get_item_by_id(item_id)
if item != null and not item.is_bait() and not item.is_lure():
var key := PlayerInventoryLayoutType.item_key(item_id)
if not assigned.has(key):
placements.append({
"kind": PlayerInventoryLayoutType.EntryKind.ITEM,
"identity": String(item_id),
"container": PlayerInventoryLayoutType.InventoryContainer.HOTBAR,
"slot": slot,
})
assigned[key] = true
continue
var catch_id := StringName(
str(fish_slots[slot]) if slot < fish_slots.size() else ""
)
if catch_id.is_empty():
continue
var catch_key := PlayerInventoryLayoutType.catch_key(catch_id)
if assigned.has(catch_key):
continue
placements.append({
"kind": PlayerInventoryLayoutType.EntryKind.CATCH,
"identity": String(catch_id),
"container": PlayerInventoryLayoutType.InventoryContainer.HOTBAR,
"slot": slot,
})
assigned[catch_key] = true
var next_inventory_slot: int = 0
var next_storage_slot: int = 0
for value: Variant in bag_data["items"] as Array:
if typeof(value) != TYPE_DICTIONARY:
continue
var item_id := StringName(str((value as Dictionary).get("item_id", "")))
var item = _item_catalog.get_item_by_id(item_id)
if item == null or item.is_bait() or item.is_lure():
continue
var key := PlayerInventoryLayoutType.item_key(item_id)
if assigned.has(key):
continue
var target := PlayerInventoryLayoutType.InventoryContainer.INVENTORY
var target_slot := next_inventory_slot
if next_inventory_slot < PlayerInventoryLayoutType.INVENTORY_CAPACITIES[0]:
next_inventory_slot += 1
else:
target = PlayerInventoryLayoutType.InventoryContainer.STORAGE
target_slot = next_storage_slot
next_storage_slot += 1
placements.append({
"kind": PlayerInventoryLayoutType.EntryKind.ITEM,
"identity": String(item_id),
"container": target,
"slot": target_slot,
})
assigned[key] = true
for value: Variant in inventory_data["catches"] as Array:
if typeof(value) != TYPE_DICTIONARY:
continue
var catch_id := StringName(
str((value as Dictionary).get("catch_id", ""))
)
if catch_id.is_empty():
continue
var key := PlayerInventoryLayoutType.catch_key(catch_id)
if assigned.has(key):
continue
var target := PlayerInventoryLayoutType.InventoryContainer.INVENTORY
var target_slot := next_inventory_slot
if next_inventory_slot < PlayerInventoryLayoutType.INVENTORY_CAPACITIES[0]:
next_inventory_slot += 1
else:
target = PlayerInventoryLayoutType.InventoryContainer.STORAGE
target_slot = next_storage_slot
next_storage_slot += 1
placements.append({
"kind": PlayerInventoryLayoutType.EntryKind.CATCH,
"identity": String(catch_id),
"container": target,
"slot": target_slot,
})
assigned[key] = true
migrated["inventory_layout"] = {
"backpack_level": 0,
"placements": placements,
}
migrated["save_version"] = 8
return migrated
func _mark_dirty() -> void:
if (
_is_restoring
@ -1148,9 +1309,10 @@ func _restore_defaults() -> void:
_bag.replace_all_items(default_items)
var default_unlocked_baits: Array[StringName] = []
_bag.replace_unlocked_bait_ids(default_unlocked_baits)
_hotbar.replace_state(default_slots, 0)
_fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults()
_inventory_layout.reset_to_defaults()
_hotbar.replace_state(default_slots, 0)
_art_unlocks.reset_to_defaults()
_experience.reset_to_defaults()
_world_time.restore_persistent_time_hours(

View file

@ -21,6 +21,7 @@ readonly -a QUICK_TESTS=(
"tests/controller_world_interaction_validation.gd"
"tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/digging_prototype_validation.gd"
"tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd"
@ -43,6 +44,8 @@ readonly -a QUICK_TESTS=(
"tests/tackle_order_validation.gd"
"tests/terrain_blender_material_validation.gd"
"tests/texture_sampling_validation.gd"
"tests/tree_gathering_prototype_validation.gd"
"tests/unified_inventory_validation.gd"
"tests/world_time_validation.gd"
"tests/world_spawn_protocol_validation.gd"
"tests/world_weather_validation.gd"

View file

@ -18,11 +18,10 @@ const VoiceProfilesType = preload(
)
const PlayerMenuScene = preload("res://ui/player_menu.tscn")
const PlayerMenuType = preload("res://ui/player_menu.gd")
const BagItemSpriteScene = preload(
"res://ui/components/bubble_menu/bag_item_sprite.tscn"
)
const OwnedItemType = preload("res://items/owned_item.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
const PlayersPageType = preload("res://ui/players_page.gd")
const TheNetPageType = preload("res://ui/the_net_page.gd")
const ControllerMappingManagerType = preload(
@ -1077,21 +1076,63 @@ func _validate_inventory_tab_zone_transitions() -> void:
var menu := PlayerMenuScene.instantiate() as Control
root.add_child(menu)
await process_frame
var inventory_state := Node.new()
root.add_child(inventory_state)
var bag := PlayerBag.new()
var catches := FishInventory.new()
var storage_capacity := PlayerCoolerCapacity.new()
var layout := PlayerInventoryLayout.new()
var hotbar := PlayerHotbarType.new()
for node: Node in [bag, catches, storage_capacity, layout, hotbar]:
inventory_state.add_child(node)
bag.setup(ItemCatalogResource)
layout.setup(bag, catches, ItemCatalogResource, storage_capacity)
bag.set_inventory_layout(layout)
catches.set_inventory_layout(layout)
hotbar.setup(bag, ItemCatalogResource, catches, layout)
assert(bag.add_item(&"basic_fishing_rod", 1))
assert(bag.add_item(&"coffee", 1))
assert(layout.move_entry(
PlayerInventoryLayout.EntryKind.ITEM,
&"basic_fishing_rod",
PlayerInventoryLayout.InventoryContainer.INVENTORY,
9,
))
assert(hotbar.assign_item(0, &"coffee"))
menu.set("_bag", bag)
menu.set("_inventory", catches)
menu.set("_hotbar", hotbar)
menu.set("_inventory_layout", layout)
menu.set("_item_catalog", ItemCatalogResource)
var inventory_grid := menu.get("_general_inventory_grid") as GeneralInventoryGrid
inventory_grid.setup(
layout,
bag,
catches,
hotbar,
ItemCatalogResource,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
menu.visible = true
menu.call(
"_show_section_immediate",
PlayerMenuType.Section.COOLER,
PlayerMenuType.Section.BAG,
)
menu.call("_set_content_interactive", true)
menu.call("_enter_inventory_tabs_zone")
for _frame: int in 2:
await process_frame
var cooler_tab := menu.get_node("%CoolerSubTab") as Button
var equipment_tab := menu.get_node("%BagSubTab") as Button
var items_tab := menu.get_node("%ItemsSubTab") as Button
var inventory_tab := menu.get_node("%BagSubTab") as Button
var tackle_tab := menu.get_node("%TackleSubTab") as Button
_expect(
root.gui_get_focus_owner() == cooler_tab,
"Inventory tab zone did not begin on the active Cooler tab.",
root.gui_get_focus_owner() == inventory_tab,
"Inventory tab zone did not begin on the unified Inventory tab.",
)
_expect(inventory_tab.text == "Inventory", "Unified tab is not labelled Inventory.")
_expect(
not (menu.get_node("%CoolerSubTab") as Button).visible
and not (menu.get_node("%ItemsSubTab") as Button).visible,
"Legacy Cooler or Items tabs remain visible.",
)
var right := InputEventAction.new()
@ -1103,8 +1144,8 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
await _wait_for_player_menu_page_transition(menu)
_expect(
menu.get("_current_section") == PlayerMenuType.Section.BAG,
"Controller Right did not switch from Cooler to Equipment.",
menu.get("_current_section") == PlayerMenuType.Section.TACKLE_BOX,
"Controller Right did not switch from Inventory to Tackle.",
)
_expect(
menu.get("_controller_ownership")
@ -1112,7 +1153,7 @@ func _validate_inventory_tab_zone_transitions() -> void:
"Changing Inventory tabs entered the item-content zone without A.",
)
_expect(
root.gui_get_focus_owner() == equipment_tab,
root.gui_get_focus_owner() == tackle_tab,
"Changing Inventory tabs did not keep focus on the selected tab.",
)
var down := InputEventAction.new()
@ -1124,59 +1165,33 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
await process_frame
_expect(
root.gui_get_focus_owner() == equipment_tab,
root.gui_get_focus_owner() == tackle_tab,
"Controller Down escaped the Inventory tab zone before A.",
)
_expect(
bool(menu.call("_handle_controller_ownership_input", right)),
"Equipment-to-Items navigation did not consume controller Right.",
)
await _wait_for_player_menu_page_transition(menu)
_expect(
root.gui_get_focus_owner() == items_tab,
"Items tab selection did not retain tab focus before A (focus=%s)."
% root.gui_get_focus_owner(),
)
_expect(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.INVENTORY_TABS,
"Items tab selection changed controller zones before A.",
)
var left := InputEventAction.new()
left.action = &"ui_left"
left.pressed = true
_expect(
bool(menu.call("_handle_controller_ownership_input", left)),
"Items-to-Equipment navigation did not consume controller Left.",
"Tackle-to-Inventory navigation did not consume controller Left.",
)
_expect(
menu.get("_current_section") == PlayerMenuType.Section.BAG
or bool(menu.get("_page_transitioning")),
"Tackle-to-Inventory input did not start the Inventory transition.",
)
await _wait_for_player_menu_page_transition(menu)
_expect(
root.gui_get_focus_owner() == equipment_tab,
"Returning to Equipment did not retain tab focus before A.",
root.gui_get_focus_owner() == inventory_tab,
"Returning to Inventory did not retain tab focus before A.",
)
# Add three representative equipment entries so entering the content zone
# exercises the real five-column directional layout.
var item_field := menu.get_node("%BagItemField") as Control
var bag_nodes: Dictionary = menu.get("_bag_item_nodes")
var owned_items: Array[OwnedItemType] = []
for index: int in 3:
var owned := OwnedItemType.new()
owned.item_id = StringName("controller_test_item_%d" % index)
owned_items.append(owned)
var item_node := BagItemSpriteScene.instantiate() as Button
item_node.set("item_id", owned.item_id)
item_node.position = Vector2(60.0 + float(index) * 180.0, 40.0)
item_field.add_child(item_node)
bag_nodes[owned.item_id] = item_node
menu.set("_sorted_bag_items", owned_items)
menu.call("_set_content_interactive", true)
menu.call("_configure_bag_item_focus")
for item_node: Control in bag_nodes.values():
var active_slots := inventory_grid.get_slots()
for slot: GeneralInventorySlot in active_slots:
_expect(
item_node.focus_mode == Control.FOCUS_NONE,
"Equipment content remained focusable while tabs owned the controller.",
slot.focus_mode == Control.FOCUS_NONE,
"Inventory slots remained focusable while tabs owned the controller.",
)
var accept := InputEventJoypadButton.new()
@ -1193,34 +1208,45 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
for _frame: int in 2:
await process_frame
var first_item := bag_nodes[owned_items[0].item_id] as Control
var second_item := bag_nodes[owned_items[1].item_id] as Control
for item_node: Control in bag_nodes.values():
_expect(
menu.get("_current_section") == PlayerMenuType.Section.BAG,
"Entering Inventory contents changed section to %s."
% menu.get("_current_section"),
)
var first_item := active_slots[0] as Control
var second_item := active_slots[1] as Control
for slot: GeneralInventorySlot in active_slots:
_expect(
item_node.focus_mode == Control.FOCUS_ALL,
"Accepting Equipment did not enable its content focus zone.",
slot.focus_mode == Control.FOCUS_ALL,
"Accepting Inventory did not enable its content focus zone.",
)
_expect(
root.gui_get_focus_owner() == first_item,
"Entering Equipment did not focus its first item.",
"Entering Inventory did not focus its first slot.",
)
_expect(
first_item.focus_neighbor_right == first_item.get_path_to(second_item),
"Equipment items do not provide horizontal controller navigation.",
"Inventory slots do not provide horizontal controller navigation.",
)
Input.parse_input_event(right)
for _frame: int in 2:
await process_frame
_expect(
root.gui_get_focus_owner() == second_item,
"Controller Right did not move between Equipment items.",
menu.get("_current_section") == PlayerMenuType.Section.BAG,
"Inventory slot navigation changed section to %s."
% menu.get("_current_section"),
)
right.pressed = false
Input.parse_input_event(right)
right.pressed = true
_expect(
root.gui_get_focus_owner() == second_item,
"Controller Right did not move between Inventory slots.",
)
var right_release := InputEventAction.new()
right_release.action = &"ui_right"
right_release.pressed = false
Input.parse_input_event(right_release)
_expect(
bool(menu.call("consume_escape")),
"Global player-menu Back did not consume Equipment contents.",
"Global player-menu Back did not consume Inventory contents.",
)
_expect(
menu.visible,
@ -1233,90 +1259,102 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
for _frame: int in 2:
await process_frame
_expect(
menu.get("_current_section") == PlayerMenuType.Section.BAG,
"Returning from Inventory contents changed section to %s."
% menu.get("_current_section"),
)
_expect(
bool(menu.call("_handle_controller_ownership_input", accept)),
"Inventory tab zone did not re-enter Equipment contents.",
"Inventory tab zone did not re-enter Inventory contents.",
)
for _frame: int in 2:
await process_frame
var favorite := menu.get_node("%FavoriteBubble") as BaseButton
var sell := menu.get_node("%SellBubble") as BaseButton
var sell_all := menu.get_node("%SellAllBubble") as BaseButton
for action: BaseButton in [favorite, sell, sell_all]:
action.disabled = false
action.focus_mode = Control.FOCUS_ALL
var notepad_actions: Array[BaseButton] = [favorite, sell, sell_all]
menu.call(
"_configure_controller_notepad_action_focus",
notepad_actions,
)
var notepad_source := active_slots[9]
notepad_source.grab_focus()
var context_press := InputEventJoypadButton.new()
context_press.button_index = JOY_BUTTON_Y
context_press.pressed = true
_expect(
sell.get_node(sell.focus_neighbor_bottom) == sell_all,
"Sell All is not reachable below Sell Fish in the notepad zone.",
)
_expect(
sell_all.get_node(sell_all.focus_neighbor_top) == sell,
"Sell All does not return to the upper notepad actions.",
)
menu.set(
"_controller_ownership",
PlayerMenuType.ControllerOwnership.NOTEPAD_ACTIONS,
)
menu.set("_controller_source_section", PlayerMenuType.Section.BAG)
menu.set("_controller_source_identity", owned_items[1].item_id)
menu.call("_apply_inventory_controller_zone_focus_modes")
_expect(
bool(menu.call("consume_escape")),
"Global player-menu Back did not consume the Inventory notepad zone.",
)
_expect(
menu.visible,
"Global player-menu Back closed Inventory from its notepad zone.",
bool(menu.call(
"_handle_controller_ownership_input", context_press
)),
"Controller Y did not open the selected Inventory notepad.",
)
_expect(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.NOTEPAD_ACTIONS,
"Inventory notepad did not become the active controller zone.",
)
_expect(
(menu.get_node("%BagDetailConstellation") as Control).visible,
"Inventory notepad remained hidden after controller Y.",
)
_expect(
bool(menu.call("consume_escape")),
"Controller B did not close the Inventory notepad.",
)
_expect(
not (menu.get_node("%BagDetailConstellation") as Control).visible
and menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.ITEM_LIST,
"Global player-menu Back did not return the notepad to Inventory contents.",
"Closing the Inventory notepad did not restore the item zone.",
)
for _frame: int in 2:
await process_frame
var hotbar := PlayerHotbarType.new()
menu.set("_hotbar", hotbar)
var hotbar_slots: Array[StringName] = []
hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
hotbar_slots.fill(StringName())
hotbar.set("_slots", hotbar_slots)
var hotbar_fish_slots: Array[StringName] = []
hotbar_fish_slots.resize(PlayerHotbarType.SLOT_COUNT)
hotbar_fish_slots.fill(StringName())
hotbar_fish_slots[0] = &"controller_test_fish"
hotbar.set("_fish_slots", hotbar_fish_slots)
var accept_release := InputEventJoypadButton.new()
accept_release.button_index = JOY_BUTTON_A
accept_release.pressed = false
notepad_source.grab_focus()
menu.call("_handle_controller_ownership_input", accept)
menu.call("_handle_controller_ownership_input", accept_release)
_expect(
str(menu.get("_inventory_move_identity")) == "basic_fishing_rod",
"Controller A did not pick up the focused Inventory item.",
)
var move_target := active_slots[10]
move_target.grab_focus()
menu.call("_handle_controller_ownership_input", accept)
menu.call("_handle_controller_ownership_input", accept_release)
_expect(
layout.get_key_at(
PlayerInventoryLayout.InventoryContainer.INVENTORY, 10
) == PlayerInventoryLayout.item_key(&"basic_fishing_rod"),
"Controller A did not place the picked-up item in its target slot.",
)
var management_requests: Array[int] = [0]
menu.controller_hotbar_management_requested.connect(
func(_initial_slot: int) -> void:
management_requests[0] += 1
)
var hotbar_owned := OwnedItemType.new()
hotbar_owned.item_id = &"controller_hotbar_source"
var hotbar_source := BagItemSpriteScene.instantiate() as Button
hotbar_source.set("item_id", hotbar_owned.item_id)
hotbar_source.position = Vector2(60.0, 40.0)
item_field.add_child(hotbar_source)
bag_nodes[hotbar_owned.item_id] = hotbar_source
var hotbar_items: Array[OwnedItemType] = [hotbar_owned]
menu.set("_sorted_bag_items", hotbar_items)
menu.set(
"_controller_ownership",
PlayerMenuType.ControllerOwnership.ITEM_LIST,
)
menu.call("_apply_inventory_controller_zone_focus_modes")
menu.call("_configure_bag_item_focus")
var hotbar_source := active_slots[10]
_expect(
menu.get("_current_section") == PlayerMenuType.Section.BAG,
"Hotbar navigation test left the unified Inventory section (%s)."
% menu.get("_current_section"),
)
_expect(
hotbar_source.focus_mode == Control.FOCUS_ALL,
"Final active Inventory row was not controller-focusable.",
)
hotbar_source.grab_focus()
_expect(
root.gui_get_focus_owner() == hotbar_source,
"Final Inventory row did not accept controller focus before Hotbar entry.",
)
_expect(
bool(menu.call("_controller_focus_is_on_last_inventory_row")),
"Focused Inventory slot was not recognized as part of the final row.",
)
_expect(
bool(menu.call("_handle_controller_ownership_input", down)),
"Down from the final Equipment row did not enter the hotbar zone.",
"Down from the final Inventory row did not enter the hotbar zone.",
)
_expect(
menu.get("_controller_ownership")
@ -1358,9 +1396,10 @@ func _validate_inventory_tab_zone_transitions() -> void:
"Hotbar management did not consume controller A.",
)
_expect(
hotbar.get_fish_catch_id(0).is_empty(),
"Controller A did not remove the selected fish hotbar assignment.",
hotbar.get_item_id(0).is_empty(),
"Controller A did not return the selected hotbar item to Inventory.",
)
_expect(layout.is_item_in_inventory(&"coffee"), "Cleared hotbar item was lost.")
_expect(
bool(menu.call("consume_escape")),
"Global player-menu Back did not consume hotbar management.",
@ -1376,8 +1415,8 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
for _frame: int in 2:
await process_frame
hotbar.free()
menu.queue_free()
inventory_state.queue_free()
await process_frame

View file

@ -0,0 +1,91 @@
extends SceneTree
const StarterIslandScene = preload(
"res://world/regions/starter_island_region.tscn"
)
const ShovelAttachmentScene = preload("res://player/shovel_attachment.tscn")
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_catalog_content()
_validate_flat_shovel()
await _validate_beach_authoring()
print("Digging prototype validation: PASS")
quit()
func _validate_catalog_content() -> void:
var shovel: ItemData = ItemCatalogResource.get_available_item_by_id(
&"standard_shovel"
)
assert(shovel != null)
assert(shovel.category == ItemData.Category.TOOL)
assert(shovel.equippable and shovel.hotbar_allowed)
var clam: GatherableData = Gatherables.get_entry(&"clam_manila")
assert(clam != null and clam.is_available())
assert(clam.required_tool_id == shovel.item_id)
assert(clam.diggable_area_id == &"starter_beach")
assert(clam.presentation_mode == GatherableData.PresentationMode.WATER_SPURT)
assert(not clam.requires_sneaking)
assert(is_equal_approx(clam.active_lifetime_seconds, 10.0))
func _validate_flat_shovel() -> void:
var attachment := ShovelAttachmentScene.instantiate() as BoneAttachment3D
assert(attachment != null)
var shovel := attachment.get_node("Shovel") as Node3D
assert(shovel != null)
var meshes: Array[MeshInstance3D] = []
_collect_meshes(shovel, meshes)
assert(meshes.size() == 5)
for mesh_instance: MeshInstance3D in meshes:
assert(
mesh_instance.cast_shadow
== GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
)
var material := mesh_instance.mesh.surface_get_material(0) as StandardMaterial3D
assert(material != null)
assert(material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
assert(material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED)
assert(material.albedo_texture == null)
assert(is_equal_approx(material.albedo_color.a, 1.0))
attachment.free()
func _validate_beach_authoring() -> void:
var region := StarterIslandScene.instantiate() as WorldRegion
root.add_child(region)
await process_frame
var area: DiggableArea3D = region.get_diggable_area(&"starter_beach")
assert(area != null)
assert(area.terrain_source == NodePath("../../Terrain/Visual"))
assert(area.surface_materials.size() == 1)
assert(area.surface_materials[0] == &"sand")
var triangles: Array[PackedVector3Array] = area.get_surface_triangles()
assert(not triangles.is_empty())
for triangle: PackedVector3Array in triangles:
assert(triangle.size() == 3)
var center := (triangle[0] + triangle[1] + triangle[2]) / 3.0
assert(area.generation_bounds.has_point(Vector2(center.x, center.z)))
assert(center.y <= area.maximum_global_y + 0.001)
region.queue_free()
func _collect_meshes(
root_node: Node,
result: Array[MeshInstance3D],
) -> void:
for child: Node in root_node.get_children():
if child is MeshInstance3D:
result.append(child as MeshInstance3D)
_collect_meshes(child, result)

View file

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

View file

@ -48,8 +48,8 @@ func _run() -> void:
as PlayerAssetReservationService
)
assert(player != null)
assert(catalog != null and catalog.candidates.size() == 314)
assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 54)
assert(catalog != null and catalog.candidates.size() == 316)
assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 56)
assert(sale_service != null)
assert(shop_service != null)
assert(session != null and session.is_host())
@ -62,7 +62,6 @@ func _run() -> void:
_test_multi_sale(player, catalog, sale_service)
_test_reservations(player, catalog, sale_service, reservations)
_test_anywhere_sale(player, catalog, sale_service)
await _test_player_menu_sale(main, player, catalog, sale_service)
await _test_host_shop_sale(main, player, catalog, sale_service)
assert(session.set_host_open(true))
@ -78,6 +77,8 @@ func _run() -> void:
await _test_fishing_shop_sale_ui(
main, player, catalog, sale_service, reservations
)
_test_host_backpack_purchase(player, shop_service)
_test_host_storage_purchase(player, shop_service)
assert(not sale_service.is_local_sale_pending())
assert(not shop_service.is_local_purchase_pending())
@ -217,6 +218,17 @@ func _run_multiplayer_client() -> void:
print("Client shop result: ", _shop_result)
assert(not _shop_result.is_empty() and bool(_shop_result[1]))
assert(not shop_service.is_local_purchase_pending())
var backpack_cost := player.inventory_layout.get_next_backpack_cost()
if player.wallet.get_balance() < backpack_cost:
assert(player.wallet.credit(
backpack_cost - player.wallet.get_balance()
))
_shop_result.clear()
assert(not shop_service.request_backpack_capacity_upgrade().is_empty())
while _shop_result.is_empty():
await process_frame
assert(bool(_shop_result[1]))
assert(player.inventory_layout.get_inventory_capacity() == 18)
var required_art_balance: int = (
ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE
)
@ -377,80 +389,39 @@ func _test_host_shop_sale(
)
var species_ids: Array[StringName] = []
var expected_payout: int = 0
var species_balance_before: int = player.wallet.get_balance()
for fish: FishData in catalog.candidates:
if not fish.active:
continue
var fish_catch := _make_catch(fish)
player.inventory.add_catch(fish_catch)
assert(player.inventory.add_catch(fish_catch))
species_ids.append(fish_catch.catch_id)
expected_payout += fish_catch.sale_value
var species_balance_before: int = player.wallet.get_balance()
_assert_sale(
player,
sale_service,
species_ids,
true,
true,
NetworkSaleService.MAIN_SHOP_BUYER_ID,
)
if species_ids.size() >= 5:
_assert_sale(
player,
sale_service,
species_ids,
true,
true,
NetworkSaleService.MAIN_SHOP_BUYER_ID,
)
species_ids.clear()
if not species_ids.is_empty():
_assert_sale(
player,
sale_service,
species_ids,
true,
true,
NetworkSaleService.MAIN_SHOP_BUYER_ID,
)
assert(
player.wallet.get_balance()
== species_balance_before + expected_payout
)
func _test_player_menu_sale(
main: Node,
player: Player,
catalog: FishPool,
sale_service: NetworkSaleService,
) -> void:
var game_ui := main.get_node("%GameUI") as GameUI
var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu
var fish_catch := _make_catch(catalog.candidates[6])
player.inventory.add_catch(fish_catch)
player.global_position = Vector3(0.0, 3.95, 13.0)
player_menu.open_menu()
await create_timer(2.2).timeout
player_menu.call("_on_catch_card_pressed", fish_catch.catch_id)
await process_frame
var sell_action := player_menu.get_node("%SellBubble") as Button
var confirmation := player_menu.get_node("%SaleConfirmation") as Control
var confirm_button := player_menu.get_node("%ConfirmSaleButton") as Button
var ui_viewport := main.get_node(
"UIPresentation/UIViewport"
) as SubViewport
assert(sell_action.visible and not sell_action.disabled)
assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(ui_viewport != null)
await _activate_pointer_control(sell_action, ui_viewport)
await process_frame
assert(confirmation.visible)
assert(confirm_button.visible and not confirm_button.disabled)
assert(
confirmation.z_index
> (player_menu.get_node("%CoolerOuterWall") as Control).z_index
)
_sale_result.clear()
await _activate_pointer_control(confirm_button, ui_viewport)
await process_frame
assert(not _sale_result.is_empty() and bool(_sale_result[1]))
assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
assert(not sale_service.is_local_sale_pending())
player_menu.close_menu()
await create_timer(2.2).timeout
assert(not player_menu.visible)
player_menu.open_menu()
await create_timer(2.2).timeout
assert(player_menu.visible)
assert(
not sell_action.disabled
or player.inventory.get_all_catches().is_empty()
)
player_menu.close_menu()
await create_timer(2.2).timeout
func _activate_pointer_control(
control: Control,
ui_viewport: SubViewport,
@ -547,6 +518,48 @@ func _test_host_rod_purchase(
assert(not shop_service.is_local_purchase_pending())
func _test_host_backpack_purchase(
player: Player,
shop_service: NetworkShopService,
) -> void:
var layout := player.inventory_layout
assert(layout.get_inventory_capacity() == 9)
var total_cost: int = 0
for cost: int in PlayerInventoryLayout.BACKPACK_EXPANSION_COSTS:
total_cost += cost
if player.wallet.get_balance() < total_cost:
assert(player.wallet.credit(total_cost - player.wallet.get_balance()))
var balance_before := player.wallet.get_balance()
for expected_capacity: int in [18, 27, 36]:
_shop_result.clear()
assert(not shop_service.request_backpack_capacity_upgrade().is_empty())
assert(not _shop_result.is_empty() and bool(_shop_result[1]))
assert(layout.get_inventory_capacity() == expected_capacity)
assert(player.wallet.get_balance() == balance_before - total_cost)
assert(layout.get_next_backpack_cost() == -1)
func _test_host_storage_purchase(
player: Player,
shop_service: NetworkShopService,
) -> void:
var capacity := player.cooler_capacity
assert(capacity.get_capacity() == 9)
var total_cost: int = 0
for cost: int in PlayerCoolerCapacity.EXPANSION_COSTS:
total_cost += cost
if player.wallet.get_balance() < total_cost:
assert(player.wallet.credit(total_cost - player.wallet.get_balance()))
var balance_before := player.wallet.get_balance()
for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]:
_shop_result.clear()
assert(not shop_service.request_cooler_capacity_upgrade().is_empty())
assert(not _shop_result.is_empty() and bool(_shop_result[1]))
assert(capacity.get_capacity() == expected_capacity)
assert(player.wallet.get_balance() == balance_before - total_cost)
assert(capacity.get_next_cost() == -1)
func _test_fishing_shop_sale_ui(
main: Node,
player: Player,
@ -608,7 +621,11 @@ func _test_fishing_shop_sale_ui(
"QuantityBadge/Quantity"
) as Label
assert(tackle_quantity.text == expected_bait_supply)
assert(tackle_bait_button.tooltip_text.contains(expected_bait_supply))
assert(
str(tackle_bait_button.get_meta(
&"inventory_context_text", ""
)).contains(expected_bait_supply)
)
var tackle_badge := tackle_quantity.get_parent() as Panel
assert(
is_equal_approx(
@ -654,7 +671,10 @@ func _test_fishing_shop_sale_ui(
assert((shop.get_node("%Upgrades") as Control).visible)
assert(not (shop.get_node("%Supplies") as Control).visible)
for upgrade_name: String in [
"ReelPurchase", "BarrierPurchase", "CoolerPurchase"
"ReelPurchase",
"BarrierPurchase",
"CoolerPurchase",
"BackpackPurchase",
]:
var upgrade_button := shop.get_node("%%%s" % upgrade_name) as Button
assert(upgrade_button != null)
@ -662,6 +682,15 @@ func _test_fishing_shop_sale_ui(
assert(upgrade_button.text.is_empty())
assert(upgrade_button.icon != null)
assert(upgrade_button.tooltip_text.contains("level"))
for placeholder_name: String in ["CoolerPurchase", "BackpackPurchase"]:
var placeholder_button := shop.get_node(
"%%%s" % placeholder_name
) as Button
assert(
placeholder_button.icon.resource_path.ends_with(
"/pictograms/x_light.png"
)
)
assert(not shop.has_node("%ReelLevel"))
assert(not shop.has_node("%BarrierEffect"))
assert(not shop.has_node("%CoolerLevel"))
@ -674,7 +703,9 @@ func _test_fishing_shop_sale_ui(
"/shop/32_currency.png"
)
)
for cost_name: String in ["ReelCost", "BarrierCost", "CoolerCost"]:
for cost_name: String in [
"ReelCost", "BarrierCost", "CoolerCost", "BackpackCost"
]:
var cost_display := shop.get_node("%%%s" % cost_name) as CurrencyAmount
assert(cost_display != null)
var cost_icon := cost_display.get_node("Icon") as TextureRect
@ -690,7 +721,7 @@ func _test_fishing_shop_sale_ui(
var art_supplies_tab := shop_tabs[4] as Button
assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies")
var sell_mode := shop_tabs[5] as Button
assert(sell_mode != null and sell_mode.text == "Sell Fish")
assert(sell_mode != null and sell_mode.text == "Sell")
var equipment_tab := shop_tabs[3] as Button
assert(equipment_tab != null and equipment_tab.text == "Equipment")
var supplies_list := shop.get_node("%SuppliesList") as VBoxContainer
@ -881,44 +912,47 @@ func _test_fishing_shop_sale_ui(
assert((shop.get_node("%ShopPanel") as Control).visible)
assert(not (shop.get_node("ShopPanel/Margin/Layout/Body") as Control).visible)
assert(not (shop.get_node("%Feedback") as Control).visible)
var mounted_cooler := player_menu.get("_cooler_page") as Control
assert(mounted_cooler != null and mounted_cooler.visible)
var sell_inventory := shop.get("_sell_inventory") as ShopSellInventory
assert(sell_inventory != null and sell_inventory.visible)
var sell_tray_grid := sell_inventory.get("_tray_grid") as GridContainer
assert(
mounted_cooler.get_parent() == shop.get_node("%ShopCoolerMount")
sell_tray_grid.columns == PlayerInventoryLayout.INVENTORY_COLUMNS
)
var cooler_outer_wall := player_menu.get("_cooler_outer_wall") as Control
var water_surface := player_menu.get("_cooler_water_surface") as ColorRect
assert(cooler_outer_wall != null and cooler_outer_wall.visible)
assert(water_surface.visible and water_surface.material is ShaderMaterial)
var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control
await _activate_pointer_control(cooler_sort_option, ui_viewport)
var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control
assert(cooler_choice_panel.visible)
cooler_sort_option.call("close_choices")
assert(
StringName(
(player_menu.get("_sale_buyer_override") as FishBuyerProfile).id
) == NetworkSaleService.MAIN_SHOP_BUYER_ID
sell_tray_grid.get_theme_constant("h_separation")
== GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION
)
var fish_nodes: Dictionary = player_menu.get("_fish_nodes")
var fish_button := fish_nodes.get(fish_catch.catch_id) as Button
var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button
assert(fish_button != null and fish_button.visible)
assert(reserved_button != null and reserved_button.visible)
await _activate_pointer_control(fish_button, ui_viewport)
var sell_button := player_menu.get("_sell_bubble") as Button
assert(sell_button.visible and not sell_button.disabled)
await _activate_pointer_control(sell_button, ui_viewport)
await process_frame
var confirmation := player_menu.get("_sale_confirmation") as Control
var confirm_button := player_menu.get("_confirm_sale_button") as Button
assert(confirmation.visible)
assert(
confirmation.z_index
> cooler_outer_wall.z_index
sell_inventory.get_parent() == shop.get_node("%ShopCoolerMount")
)
var staged: Dictionary = sell_inventory.get("_staged")
sell_inventory.call(
"_stage",
PlayerInventoryLayout.EntryKind.CATCH,
reserved_catch.catch_id,
)
assert(staged.is_empty())
sell_inventory.call(
"_stage",
PlayerInventoryLayout.EntryKind.CATCH,
fish_catch.catch_id,
)
assert(staged.has(PlayerInventoryLayout.catch_key(fish_catch.catch_id)))
assert((sell_inventory.get("_feedback") as Label).text.is_empty())
var source_grid := sell_inventory.get("_inventory_grid") as GeneralInventoryGrid
var staged_source_found := false
for source_slot: GeneralInventorySlot in source_grid.get_slots():
if source_slot.entry_identity == fish_catch.catch_id:
staged_source_found = bool(source_slot.get("_staged"))
break
assert(staged_source_found)
var feedback := sell_inventory.get("_feedback") as Label
var total_label := sell_inventory.get("_total_label") as Label
var sell_button := sell_inventory.get("_sell_button") as Button
assert(feedback.get_index() < total_label.get_parent().get_index())
assert(total_label.get_parent().get_index() < sell_button.get_index())
_sale_result.clear()
await _activate_pointer_control(confirm_button, ui_viewport)
sell_inventory.call("_submit_sale")
await process_frame
assert(not _sale_result.is_empty() and bool(_sale_result[1]))
assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
@ -929,7 +963,7 @@ func _test_fishing_shop_sale_ui(
await process_frame
assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible)
assert(not (shop.get_node("%ShopCoolerPage") as Control).visible)
assert(not player_menu.is_shop_cooler_mounted())
assert(not sell_inventory.is_visible_in_tree())
shop.close_shop()
await shop.menu_visibility_changed
assert(not shop.visible)

View file

@ -55,6 +55,17 @@ func _initialize() -> void:
assert(crab_net != null)
assert(crab_net.icon != null)
assert(crab_net.icon.resource_path.ends_with("/equipment/temp_net.png"))
var shovel: ItemDataType = ItemCatalogResource.get_available_item_by_id(
&"standard_shovel"
)
assert(shovel != null)
assert(shovel.category == ItemDataType.Category.TOOL)
assert(shovel.icon != null)
assert(shovel.equippable)
assert(shovel.hotbar_allowed)
assert(FishingShopStockType.get_price(&"standard_shovel") == 75)
assert(FishingShopStockType.get_stock_item_ids().has(&"standard_shovel"))
assert(FishingShopStockType.is_permanent_unlock(&"standard_shovel", shovel))
var wallet := PlayerWalletType.new()
wallet.current_balance = 250

View file

@ -121,7 +121,7 @@ func _validate_weight_based_display_scale() -> void:
func _validate_catalog_and_pools() -> void:
assert(Catalog.candidates.size() == 314)
assert(Catalog.candidates.size() == 316)
assert(PondPool.candidates.size() == 19)
assert(OceanPool.candidates.size() == 34)
var active_count: int = 0
@ -142,7 +142,7 @@ func _validate_catalog_and_pools() -> void:
inactive_count += 1
assert(not fish.is_selectable())
assert(fish.display_texture == null)
assert(active_count == 54)
assert(active_count == 56)
assert(inactive_count == 260)
var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"bowfin")
assert(inactive_fish != null and not inactive_fish.active)
@ -455,6 +455,7 @@ func _validate_catches_and_authoritative_sale() -> void:
1,
"catalog_sale_%d" % catch_sequence,
[loaded.to_network_dict()],
[],
PelicanBuyer,
)
assert(bool(sale_result.get("accepted", false)))

View file

@ -104,7 +104,7 @@ func _run() -> void:
var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int((parsed as Dictionary)["save_version"]) == 7)
assert(int((parsed as Dictionary)["save_version"]) == 8)
assert(
int((parsed as Dictionary)["experience"]["total_experience"])
== 125

View file

@ -305,6 +305,7 @@ func _validate_catch_round_trip_and_sale() -> void:
1,
"quality_sale",
[network_data],
[],
PelicanBuyer,
)
assert(bool(accepted.get("accepted", false)))
@ -336,6 +337,7 @@ func _validate_catch_round_trip_and_sale() -> void:
1,
"quality_sale_forged",
[forged],
[],
PelicanBuyer,
)
assert(not bool(rejected.get("accepted", false)))
@ -431,7 +433,7 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 7)
assert(int(migrated.get("save_version", -1)) == 8)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(

View file

@ -288,9 +288,9 @@ func _validate_fur_color_ui(snapshot: Dictionary) -> void:
var picker_popup := picker.get_popup()
var picker_control := picker.get_picker()
assert(color_panel != null and color_panel.size.x > 360.0)
assert(channel_grid != null and channel_grid.columns == 4)
assert(channel_grid != null and channel_grid.columns == 2)
assert(channel_grid.get_child_count() == 4)
assert(palette_grid != null and palette_grid.columns == 10)
assert(palette_grid != null and palette_grid.columns == 6)
assert(palette_grid.get_child_count() == 30)
for option_button: Button in palette_grid.get_children():
assert(

View file

@ -1,14 +1,17 @@
extends SceneTree
const PlayerMenuScene = preload("res://ui/player_menu.tscn")
const HotbarScene = preload("res://ui/hotbar.tscn")
const UIReferencePresentationType = preload(
"res://ui/ui_reference_presentation.gd"
)
const EXPECTED_HOST_SIZE := Vector2(278.0, 484.0)
const EXPECTED_ART_SIZE := Vector2(306.28125, 580.8)
const EXPECTED_ART_POSITION := Vector2(-4.140625, -28.4)
const INVENTORY_PANEL_RECT := Rect2(54.0, 166.0, 882.0, 484.0)
const NOTEPAD_HOST_RECT := Rect2(952.0, 166.0, 278.0, 484.0)
const LEGACY_COOLER_PANEL_RECT := Rect2(54.0, 166.0, 882.0, 484.0)
const LEGACY_COOLER_NOTEPAD_RECT := Rect2(952.0, 166.0, 278.0, 484.0)
const INVENTORY_PANEL_RECT := Rect2(199.0, 166.0, 882.0, 484.0)
const NOTEPAD_HOST_RECT := Rect2(501.0, 166.0, 278.0, 484.0)
const NOTEPAD_ART_RECT := Rect2(
NOTEPAD_HOST_RECT.position + EXPECTED_ART_POSITION,
EXPECTED_ART_SIZE,
@ -57,6 +60,11 @@ func _run() -> void:
+ InventoryNotepad.NOTEPAD_ART_OFFSET
))
_validate_shared_inventory_geometry(player_menu)
_validate_first_open_inventory(player_menu)
await _validate_inventory_grid_centering(player_menu, presentation_stage)
_validate_utility_page_geometry(player_menu)
await _validate_profile_content_bounds(player_menu)
await _validate_hotbar_centering(presentation_stage)
_validate_inventory_layering(player_menu)
_validate_tackle_to_items_transition(player_menu)
_validate_cooler_notepad_typography(player_menu)
@ -103,23 +111,151 @@ func _apply_stage_layout(stage: Control, display_size: Vector2) -> void:
func _validate_shared_inventory_geometry(player_menu: PlayerMenu) -> void:
for node_name: StringName in [
&"CoolerOuterWall",
&"BagOuterWall",
&"TackleMainPanel",
]:
var main_panel := player_menu.get_node("%%%s" % node_name) as Control
assert(main_panel != null)
assert(main_panel.position.is_equal_approx(INVENTORY_PANEL_RECT.position))
assert(main_panel.size.is_equal_approx(INVENTORY_PANEL_RECT.size))
assert(
main_panel.size.is_equal_approx(INVENTORY_PANEL_RECT.size),
"%s was %s, expected %s" % [
node_name,
main_panel.size,
INVENTORY_PANEL_RECT.size,
],
)
var cooler_panel := player_menu.get_node("%CoolerOuterWall") as Control
var cooler_notepad := player_menu.get_node("%DetailConstellation") as Control
assert(cooler_panel.position.is_equal_approx(
LEGACY_COOLER_PANEL_RECT.position
))
assert(cooler_panel.size.is_equal_approx(LEGACY_COOLER_PANEL_RECT.size))
assert(cooler_notepad.position.is_equal_approx(
LEGACY_COOLER_NOTEPAD_RECT.position
))
assert(cooler_notepad.size.is_equal_approx(
LEGACY_COOLER_NOTEPAD_RECT.size
))
for node_name: StringName in [
&"DetailConstellation",
&"BagDetailConstellation",
&"TackleDetailPanel",
]:
var host := player_menu.get_node("%%%s" % node_name) as Control
assert(host != null)
assert(host.position.is_equal_approx(Vector2(952.0, 166.0)))
assert(host.position.is_equal_approx(NOTEPAD_HOST_RECT.position))
assert(host.size.is_equal_approx(EXPECTED_HOST_SIZE))
assert(is_equal_approx(INVENTORY_PANEL_RECT.get_center().x, 640.0))
func _validate_first_open_inventory(player_menu: PlayerMenu) -> void:
var legacy_slots: Array = player_menu.get("_bag_slot_nodes") as Array
assert(legacy_slots.is_empty())
var old_slot_nodes: Array[Node] = player_menu.find_children(
"*", "BagStorageSlot", true, false
)
assert(old_slot_nodes.is_empty())
func _validate_inventory_grid_centering(
player_menu: PlayerMenu,
stage: Control,
) -> void:
var grid := player_menu.get("_general_inventory_grid") as Control
assert(grid != null)
grid.custom_minimum_size = Vector2(782.0, 342.0)
grid.size = grid.custom_minimum_size
player_menu.call("_layout_general_inventory_grid")
await process_frame
assert(is_equal_approx(grid.position.y, 39.0))
assert(
is_equal_approx(
grid.get_global_rect().get_center().x,
stage.get_global_rect().get_center().x,
),
"inventory center %s did not match stage center %s" % [
grid.get_global_rect().get_center().x,
stage.get_global_rect().get_center().x,
],
)
func _validate_utility_page_geometry(player_menu: PlayerMenu) -> void:
for page_name: StringName in [
&"TheNetPage",
&"MailPage",
&"ProfilePage",
&"PlayersPage",
]:
var page := player_menu.get_node("%%%s" % page_name) as Control
assert(page != null)
var shell := page.find_child(
"UtilityMainBox", true, false
) as Control
assert(shell != null)
assert(shell.position.is_equal_approx(UtilityPageStyle.LAPTOP_RECT.position))
assert(shell.size.is_equal_approx(UtilityPageStyle.LAPTOP_RECT.size))
assert(is_equal_approx(shell.get_rect().get_center().x, 640.0))
func _validate_profile_content_bounds(player_menu: PlayerMenu) -> void:
var profile := player_menu.get_node("%ProfilePage") as ProfilePage
assert(profile != null)
var menu_was_visible: bool = player_menu.visible
var profile_was_visible: bool = profile.visible
player_menu.visible = true
profile.visible = true
await process_frame
await process_frame
var option_list := profile.get("_option_list") as Control
var body := option_list.get_parent() as Control
var preview := profile.get("_preview") as Control
var shell := profile.find_child("UtilityMainBox", true, false) as Control
assert(option_list != null and body != null and preview != null and shell != null)
for category_id: String in ["fur_pattern", "voice"]:
profile.call("_select_category", category_id)
await process_frame
await process_frame
assert(
body.get_combined_minimum_size().x <= body.size.x,
"%s customization content overflowed its body: %s > %s" % [
category_id,
body.get_combined_minimum_size().x,
body.size.x,
],
)
assert(
preview.get_global_rect().end.x
<= shell.get_global_rect().end.x,
"%s preview overflowed the utility content area" % category_id,
)
profile.visible = profile_was_visible
player_menu.visible = menu_was_visible
func _validate_hotbar_centering(parent: Control) -> void:
var hotbar := HotbarScene.instantiate() as HotbarUI
parent.add_child(hotbar)
await process_frame
var presentation := hotbar.get_node(
"%HotbarPresentationScaleRoot"
) as Control
var field := hotbar.get_node("%BubbleField") as Control
hotbar.set_player_menu_context(true)
var displayed_center_x: float = (
presentation.position.x
+ field.get_rect().get_center().x * presentation.scale.x
)
assert(is_equal_approx(displayed_center_x, 640.0))
var displayed_top: float = (
presentation.position.y + field.position.y * presentation.scale.y
)
var displayed_bottom: float = (
displayed_top + field.size.y * presentation.scale.y
)
assert(displayed_top < INVENTORY_PANEL_RECT.end.y)
assert(displayed_bottom > INVENTORY_PANEL_RECT.end.y)
hotbar.queue_free()
func _validate_inventory_layering(player_menu: PlayerMenu) -> void:
@ -136,13 +272,31 @@ func _validate_inventory_layering(player_menu: PlayerMenu) -> void:
assert(cooler_panel != null)
assert(tackle_panel != null)
assert(inventory_tabs != null)
assert(items_tab != null and items_tab.text == "Items")
assert(bait_list != null and bait_list.columns == 3)
assert(lure_list != null and lure_list.columns == 3)
assert(items_tab != null and not items_tab.visible)
assert(not (player_menu.get_node("%CoolerSubTab") as Button).visible)
assert((player_menu.get_node("%BagSubTab") as Button).text == "Inventory")
assert(bait_list != null and bait_list.columns == 4)
assert(lure_list != null and lure_list.columns == 4)
assert(bait_list.get_theme_constant("h_separation") == 28)
assert(lure_list.get_theme_constant("h_separation") == 28)
assert(sale_confirmation.z_index > cooler_panel.z_index)
assert(sale_confirmation.z_index > tackle_panel.z_index)
assert(
(player_menu.get_node("%BagDetailConstellation") as Control).z_index
> bag_panel.z_index
)
assert(
(player_menu.get_node("%BagModalBlocker") as Control).z_index
> bag_panel.z_index
)
assert(
(player_menu.get_node("%BagDetailConstellation") as Control).z_index
> (player_menu.get_node("%BagModalBlocker") as Control).z_index
)
assert(
(player_menu.get_node("%TackleDetailPanel") as Control).z_index
> tackle_panel.z_index
)
# The contextual Hotbar uses z=90 while the Player Menu is open.
assert(sale_confirmation.z_index > 90)
assert(sale_confirmation.position.is_equal_approx(Vector2(380.0, 265.0)))
@ -159,25 +313,55 @@ func _validate_inventory_layering(player_menu: PlayerMenu) -> void:
func _validate_tackle_to_items_transition(player_menu: PlayerMenu) -> void:
var empty_state := player_menu.get_node("%BagEmptyState") as Label
player_menu.set("_bag_view", PlayerMenu.BagView.EQUIPMENT)
player_menu.call("_refresh_bag")
assert(empty_state.text == "No equipment in your Bag.")
var inventory_notepad := player_menu.get_node(
"%BagDetailBubble"
) as InventoryNotepad
assert(inventory_notepad != null)
assert(inventory_notepad.title_text == "inventory notes")
player_menu.call(
"_show_section_immediate", PlayerMenu.Section.TACKLE_BOX
)
player_menu.call("_show_bag_view", PlayerMenu.BagView.CONSUMABLES)
assert(
player_menu.get("_current_section") == PlayerMenu.Section.BAG
)
assert(
player_menu.get("_bag_view") == PlayerMenu.BagView.CONSUMABLES
)
assert(empty_state.text == "No items in your Bag.")
player_menu.call("_show_inventory_tab", 0)
player_menu.call("_cancel_page_tween")
player_menu.call(
"_show_section_immediate", PlayerMenu.Section.COOLER
player_menu.call("_show_section_immediate", PlayerMenu.Section.BAG)
player_menu.call("_update_bag_detail")
assert(player_menu.get("_current_section") == PlayerMenu.Section.BAG)
assert(not (player_menu.get_node("%BagDetailConstellation") as Control).visible)
assert(
(player_menu.get_node("%BagSpriteDetailData") as Label).text
== "select an item for details."
)
player_menu.visible = true
player_menu.call("_set_content_interactive", true)
var no_actions: Array[BaseButton] = []
player_menu.call(
"_open_inventory_notepad",
PlayerMenu.Section.BAG,
StringName("modal-test"),
no_actions,
)
assert((player_menu.get_node("%BagDetailConstellation") as Control).visible)
assert((player_menu.get_node("%BagModalBlocker") as Control).visible)
assert(
(player_menu.get_node("%BagModalBlocker") as Control).mouse_filter
== Control.MOUSE_FILTER_STOP
)
assert(
(player_menu.get_node("%InventoryTab") as Button).focus_mode
== Control.FOCUS_NONE
)
assert(
(player_menu.get_node("%BagSpriteDetailData") as Label).get_theme_font(
"font"
) == InventoryNotepad.NOTEPAD_FONT
)
player_menu.call(
"_release_controller_ownership", false, false
)
assert(not (player_menu.get_node("%BagModalBlocker") as Control).visible)
player_menu.call("_set_content_interactive", false)
player_menu.visible = false
player_menu.call("_cancel_page_tween")
func _validate_cooler_notepad_typography(player_menu: PlayerMenu) -> void:
@ -314,17 +498,16 @@ func _capture_inventory_pages(player_menu: PlayerMenu) -> void:
return
player_menu.visible = true
var sections: Array[PlayerMenu.Section] = [
PlayerMenu.Section.COOLER,
PlayerMenu.Section.BAG,
PlayerMenu.Section.TACKLE_BOX,
]
var suffixes: Array[String] = ["cooler", "equipment", "tackle"]
var suffixes: Array[String] = ["inventory", "tackle"]
for index: int in sections.size():
player_menu.call("_show_section_immediate", sections[index])
await process_frame
await process_frame
await _save_capture(suffixes[index])
player_menu.call("_show_section_immediate", PlayerMenu.Section.COOLER)
player_menu.call("_show_section_immediate", PlayerMenu.Section.BAG)
var sale_confirmation := player_menu.get_node("%SaleConfirmation") as Control
var confirmation_message := player_menu.get_node(
"%ConfirmationMessage"

View file

@ -1,31 +1,7 @@
extends SceneTree
const BagItemSpriteType = preload(
"res://ui/components/bubble_menu/bag_item_sprite.gd"
)
const BagStorageSlotType = preload(
"res://ui/components/bubble_menu/bag_storage_slot.gd"
)
const Catalog: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
const OwnedItemType = preload("res://items/owned_item.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const PlayerMenuScene = preload("res://ui/player_menu.tscn")
const PlayerMenuType = preload("res://ui/player_menu.gd")
const EQUIPMENT_IDS: Array[StringName] = [
&"basic_fishing_rod",
&"art_kit",
&"crab_net",
&"magnet",
]
const ITEM_IDS: Array[StringName] = [
&"coffee",
&"energy_drink",
&"snack",
]
const Catalog: ItemCatalog = preload("res://items/catalog/item_catalog.tres")
const Bluegill: FishData = preload("res://fish/species/bluegill/bluegill.tres")
func _initialize() -> void:
@ -33,140 +9,119 @@ func _initialize() -> void:
func _run() -> void:
root.size = Vector2i(1280, 720)
var bag := PlayerBagType.new()
var state := Node.new()
root.add_child(state)
var bag := PlayerBag.new()
var catches := FishInventory.new()
var storage_capacity := PlayerCoolerCapacity.new()
var layout := PlayerInventoryLayout.new()
var hotbar := PlayerHotbar.new()
for node: Node in [bag, catches, storage_capacity, layout, hotbar]:
state.add_child(node)
bag.setup(Catalog)
for item_id: StringName in EQUIPMENT_IDS + ITEM_IDS:
assert(bag.add_item(item_id))
for index: int in EQUIPMENT_IDS.size():
assert(bag.get_storage_slot(EQUIPMENT_IDS[index]) == index)
for index: int in ITEM_IDS.size():
assert(bag.get_storage_slot(ITEM_IDS[index]) == index)
layout.setup(bag, catches, Catalog, storage_capacity)
bag.set_inventory_layout(layout)
catches.set_inventory_layout(layout)
hotbar.setup(bag, Catalog, catches, layout)
assert(bag.move_item_to_storage_slot(&"basic_fishing_rod", 14))
assert(bag.get_storage_slot(&"basic_fishing_rod") == 14)
assert(bag.move_item_to_storage_slot(&"crab_net", 14))
assert(bag.get_storage_slot(&"crab_net") == 14)
assert(bag.get_storage_slot(&"basic_fishing_rod") == 2)
var saved_record: Dictionary = bag.get_owned_item(
&"crab_net"
).to_save_dict()
assert(int(saved_record.get("storage_slot", -1)) == 14)
assert(bag.add_item(&"basic_fishing_rod", 1))
assert(bag.add_item(&"coffee", 3))
var fish_catch := _make_bluegill()
assert(catches.add_catch(fish_catch))
var legacy_equipment := OwnedItemType.new()
legacy_equipment.item_id = &"basic_fishing_rod"
var legacy_item := OwnedItemType.new()
legacy_item.item_id = &"coffee"
var legacy_records: Array[OwnedItemType] = [
legacy_equipment,
legacy_item,
]
var legacy_bag := PlayerBagType.new()
legacy_bag.setup(Catalog)
assert(legacy_bag.replace_all_items(legacy_records))
assert(legacy_bag.get_storage_slot(&"basic_fishing_rod") == 0)
assert(legacy_bag.get_storage_slot(&"coffee") == 0)
legacy_bag.free()
var menu := PlayerMenuScene.instantiate() as PlayerMenu
root.add_child(menu)
await process_frame
menu.set("_bag", bag)
var hotbar := PlayerHotbarType.new()
hotbar.setup(bag, Catalog)
menu.set("_hotbar", hotbar)
menu.set("_item_catalog", Catalog)
menu.set("_bag_view", PlayerMenuType.BagView.EQUIPMENT)
menu.visible = true
menu.call("_show_section_immediate", PlayerMenuType.Section.BAG)
menu.call("_set_content_interactive", true)
menu.call("_refresh_bag")
await process_frame
_validate_grid(menu, EQUIPMENT_IDS.size())
var item_nodes: Dictionary = menu.get("_bag_item_nodes")
var crab_node := item_nodes.get(&"crab_net") as BagItemSpriteType
assert(crab_node != null)
menu.set(
"_controller_ownership",
PlayerMenuType.ControllerOwnership.ITEM_LIST,
var inventory_grid := GeneralInventoryGrid.new()
inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10)
root.add_child(inventory_grid)
inventory_grid.setup(
layout,
bag,
catches,
hotbar,
Catalog,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
menu.call("_apply_inventory_controller_zone_focus_modes")
crab_node.grab_focus()
assert(bool(menu.call("_try_begin_controller_storage_placement")))
await process_frame
assert(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT
)
var slots: Array = menu.get("_bag_slot_nodes")
var bottom_slot := slots[12] as BagStorageSlotType
bottom_slot.grab_focus()
var down := InputEventAction.new()
down.action = &"ui_down"
down.pressed = true
assert(bool(menu.call("_handle_controller_ownership_input", down)))
assert(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.HOTBAR_PLACEMENT
)
var up := InputEventAction.new()
up.action = &"ui_up"
up.pressed = true
assert(bool(menu.call("_handle_controller_ownership_input", up)))
assert(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT
var storage_grid := GeneralInventoryGrid.new()
root.add_child(storage_grid)
storage_grid.setup(
layout,
bag,
catches,
hotbar,
Catalog,
PlayerInventoryLayout.InventoryContainer.STORAGE,
)
await process_frame
assert(root.gui_get_focus_owner() == bottom_slot)
assert(StringName(menu.get("_controller_storage_identity")) == &"crab_net")
var target_slot := slots[7] as BagStorageSlotType
target_slot.grab_focus()
menu.call("_confirm_controller_storage_placement")
await process_frame
assert(bag.get_storage_slot(&"crab_net") == 7)
assert(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.ITEM_LIST
var inventory_slots: Array = inventory_grid.get("_slots")
var storage_slots: Array = storage_grid.get("_slots")
assert(inventory_slots.size() == 36)
assert(inventory_grid.get_slots().size() == 9)
assert(inventory_grid.custom_minimum_size == Vector2(782.0, 342.0))
assert(storage_capacity.get_capacity() == 9)
assert(storage_slots.size() == 72)
assert(storage_grid.get_slots().size() == 9)
assert((storage_slots[0] as Control).custom_minimum_size == Vector2(52.0, 52.0))
assert((storage_slots[9] as GeneralInventorySlot).disabled)
var coffee_slot := _find_slot(inventory_slots, &"coffee")
assert(coffee_slot != null)
(inventory_slots[7] as GeneralInventorySlot).call(
"_drop_data",
Vector2.ZERO,
{"kind": "bag_item", "item_id": "coffee"},
)
assert(int(layout.get_entry(
PlayerInventoryLayout.EntryKind.ITEM, &"coffee"
).get("slot", -1)) == 7)
assert(coffee_slot.entry_identity.is_empty())
menu.call("_show_bag_view", PlayerMenuType.BagView.CONSUMABLES)
await process_frame
_validate_grid(menu, ITEM_IDS.size())
menu.call("_on_bag_item_dropped", &"coffee", 12)
await process_frame
assert(bag.get_storage_slot(&"coffee") == 12)
(storage_slots[0] as GeneralInventorySlot).call(
"_drop_data",
Vector2.ZERO,
{"kind": "cooler_fish", "catch_id": String(fish_catch.catch_id)},
)
assert(layout.get_container(
PlayerInventoryLayout.EntryKind.CATCH, fish_catch.catch_id
) == PlayerInventoryLayout.InventoryContainer.STORAGE)
assert(hotbar.assign_item(0, &"basic_fishing_rod"))
assert(not layout.is_item_in_inventory(&"basic_fishing_rod"))
(inventory_slots[8] as GeneralInventorySlot).call(
"_drop_data",
Vector2.ZERO,
{"kind": "hotbar_slot", "slot_index": 0},
)
assert(hotbar.get_item_id(0).is_empty())
assert(layout.is_item_in_inventory(&"basic_fishing_rod"))
assert(int(layout.get_entry(
PlayerInventoryLayout.EntryKind.ITEM, &"basic_fishing_rod"
).get("slot", -1)) == 8)
menu.queue_free()
hotbar.free()
bag.free()
await process_frame
print("Inventory storage validation: PASS")
state.free()
inventory_grid.free()
storage_grid.free()
quit()
func _validate_grid(menu: PlayerMenu, expected_items: int) -> void:
var slots: Array = menu.get("_bag_slot_nodes")
assert(slots.size() == PlayerBagType.MIN_STORAGE_SLOT_COUNT)
var seen_positions: Dictionary[Vector2, bool] = {}
for index: int in slots.size():
var slot := slots[index] as BagStorageSlotType
assert(slot != null)
assert(slot.storage_slot_index == index)
assert(not seen_positions.has(slot.position))
seen_positions[slot.position] = true
var normal := slot.get_theme_stylebox("normal") as StyleBoxFlat
assert(normal != null)
assert(normal.bg_color.a >= 0.7)
assert((slots[1] as Control).position.x > (slots[0] as Control).position.x)
assert((slots[5] as Control).position.y > (slots[0] as Control).position.y)
var item_nodes := menu.get("_bag_item_nodes") as Dictionary
assert(
item_nodes.size() == expected_items,
"expected %d visible bag items, found %d: %s" % [
expected_items,
item_nodes.size(),
str(item_nodes.keys()),
],
func _make_bluegill() -> FishCatch:
var fish_catch := FishCatch.new()
fish_catch.fish = Bluegill
fish_catch.fish_id = Bluegill.id
fish_catch.catch_id = &"bluegill:inventory_storage_test"
fish_catch.weight_lb = Bluegill.get_minimum_weight()
fish_catch.display_scale = Bluegill.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.sale_value = Bluegill.get_sale_value_for_weight(
fish_catch.weight_lb
)
return fish_catch
func _find_slot(slots: Array, identity: StringName) -> GeneralInventorySlot:
for candidate: Variant in slots:
var slot := candidate as GeneralInventorySlot
if slot != null and slot.entry_identity == identity:
return slot
return null

View file

@ -203,7 +203,7 @@ func _run() -> void:
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var save_data: Dictionary = parsed
assert(int(save_data.get("save_version", -1)) == 7)
assert(int(save_data.get("save_version", -1)) == 8)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
_validate_pause_session_switch(main, session)

View file

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

View file

@ -40,9 +40,9 @@ func _run() -> void:
func _validate_catalog() -> void:
assert(CatalogResource.candidates.size() == 314)
assert(CatalogResource.candidates.size() == 316)
var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates)
assert(ordered.size() == 54)
assert(ordered.size() == 56)
var previous_number: int = 0
var catalog_numbers: Dictionary[int, bool] = {}
for fish: FishDataType in CatalogResource.candidates:
@ -113,6 +113,10 @@ func _validate_page() -> void:
LogbookCatalog.Category.OTHER,
])
assert(category_tabs.size() == category_tab_categories.size())
assert(
LogbookCatalog.category_label(LogbookCatalog.Category.OTHER)
== "Insects"
)
for tab_node: Variant in category_tabs:
var category_tab := tab_node as Button
assert(category_tab.size == LogbookPage.LOGBOOK_TAB_SIZE)
@ -203,16 +207,18 @@ func _validate_page() -> void:
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).size() == 2)
assert(
(page.get("_entry_buttons") as Dictionary).has(&"unknown_3906")
)
assert(
(page.get("_entry_buttons") as Dictionary).has(&"unknown_6406")
)
page.call("_select_category", LogbookCatalog.Category.OTHER)
await create_timer(0.25).timeout
assert((page.get("_entry_buttons") as Dictionary).is_empty())
assert((page.get("_entry_buttons") as Dictionary).size() == 1)
assert(
(page.get("_empty_state") as Label).text
== "No entries available."
(page.get("_entry_buttons") as Dictionary).has(&"unknown_8001")
)
page.call("_select_category", LogbookCatalog.Category.FRESH_WATER)
await create_timer(0.25).timeout

View file

@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 7)
assert(int(migrated.get("save_version", -1)) == 8)
var experience_data: Dictionary = migrated.get("experience", {})
assert(int(experience_data.get("total_experience", -1)) == 0)
var world_data: Dictionary = migrated.get("world", {})

View file

@ -62,10 +62,14 @@ func _run() -> void:
assert(StringName(player.get("_animation_action_id")).is_empty())
assert(animation_player.current_animation == &"idle")
Input.action_press(&"sneak")
var visuals := player.get_node("Visuals") as Node3D
assert(player.play_net_strike_visual())
player.resolve_net_strike_visual(true)
player.net_success_contact_pause_duration = 0.05
player.showcase_turn_duration = 0.05
player.showcase_camera_transition_duration = 0.05
player.net_showcase_camera_yaw_offset = 0.0
var crab_catch := FishCatch.new()
crab_catch.fish = CrabBrown
crab_catch.fish_id = CrabBrown.id
@ -89,9 +93,27 @@ func _run() -> void:
await contact_pause.finished
assert(StringName(player.get("_animation_action_id")).is_empty())
assert(bool(player.get("_showcase_animation_active")))
var stored_showcase_rotation: Vector3 = player.get(
"_showcase_visual_rotation"
)
await create_timer(0.1).timeout
assert(
absf(wrapf(
visuals.rotation.y - stored_showcase_rotation.y,
-PI,
PI,
)) > 3.0
)
var catch_display := player.get("_catch_display") as Node3D
assert(catch_display != null and catch_display.visible)
player.end_catch_showcase(Callable(), true)
player.end_catch_showcase(Callable(), true, true)
assert(
absf(wrapf(
visuals.rotation.y - stored_showcase_rotation.y,
-PI,
PI,
)) < 0.01
)
Input.action_release(&"sneak")
player.queue_free()

View file

@ -45,6 +45,17 @@ func _run() -> void:
for owned: OwnedItem in tackle_items:
sorted_ids.append(owned.item_id)
assert(sorted_ids == EXPECTED_BAIT_ORDER)
var bag := PlayerBag.new()
bag.setup(Catalog)
assert(bag.add_item(&"worms", 1))
assert(bag.add_item(&"shrimp", 1))
var worms_slot: int = bag.get_storage_slot(&"worms")
var shrimp_slot: int = bag.get_storage_slot(&"shrimp")
assert(worms_slot >= 0 and shrimp_slot >= 0 and worms_slot != shrimp_slot)
assert(bag.move_item_to_storage_slot(&"worms", shrimp_slot))
assert(bag.get_storage_slot(&"worms") == shrimp_slot)
assert(bag.get_storage_slot(&"shrimp") == worms_slot)
bag.free()
player_menu.free()
print("Tackle order validation: PASS")
quit()

View file

@ -0,0 +1,99 @@
extends SceneTree
const StarterIslandScene = preload(
"res://world/regions/starter_island_region.tscn"
)
const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_beetle_data()
await _validate_tree_anchors()
_validate_anchored_presentation()
_validate_three_dimensional_targeting()
print("Tree gathering prototype validation: PASS")
quit()
func _validate_beetle_data() -> void:
var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common")
assert(beetle != null and beetle.is_available())
assert(beetle.catch_data == FishCatalog.get_fish_by_id(&"beetle_stag_common"))
assert(beetle.catch_data.display_texture != null)
assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(beetle.catch_data.collection_group == &"Beetles")
assert(beetle.required_tool_id == &"crab_net")
assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks")
assert(beetle.population == 3)
assert(is_equal_approx(beetle.sprite_pixel_size, 0.005))
assert(beetle.is_stationary_spawn())
assert(not beetle.can_be_scared())
func _validate_tree_anchors() -> void:
var region := StarterIslandScene.instantiate() as WorldRegion
root.add_child(region)
await process_frame
var anchor_set: GatherableAnchorSet3D = region.get_gatherable_anchor_set(
&"starter_reachable_tree_trunks"
)
assert(anchor_set != null)
var positions: PackedVector3Array = anchor_set.get_spawn_positions()
assert(positions.size() == 8)
for position: Vector3 in positions:
assert(position.is_finite())
assert(position.y >= 4.3 and position.y <= 4.5)
region.queue_free()
func _validate_anchored_presentation() -> void:
var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common")
var presentation := WorldGatherable.new()
root.add_child(presentation)
presentation.configure("beetle-visual", beetle, Vector3.ZERO, 0.0)
var sprite := presentation.get_node("GatherableSprite") as Sprite3D
assert(sprite != null)
assert(is_zero_approx(sprite.position.y))
assert(is_equal_approx(sprite.pixel_size, 0.005))
assert(not sprite.shaded)
assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED)
assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST)
presentation.queue_free()
func _validate_three_dimensional_targeting() -> void:
var service := NetworkWorldSpawnService.new()
var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common")
service.set(
"_entities",
{
"beetle-test": {
"entity_id": "beetle-test",
"type_id": &"beetle_stag_common",
"data": beetle,
"position": Vector3(2.0, 4.4, 3.0),
"locked": false,
},
},
)
assert(
service.find_capture_target(
Vector3(2.0, 4.4, 3.0),
&"crab_net",
)
== "beetle-test"
)
assert(
service.find_capture_target(
Vector3(2.0, 3.0, 3.0),
&"crab_net",
).is_empty()
)
service.free()

View file

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

View file

@ -0,0 +1,199 @@
extends SceneTree
const ItemCatalogResource: ItemCatalog = preload(
"res://items/catalog/item_catalog.tres"
)
const Bluegill: FishData = preload(
"res://fish/species/bluegill/bluegill.tres"
)
const MainShopBuyer: FishBuyerProfile = preload(
"res://economy/buyers/main_fishing_shop.tres"
)
const FishCatalogResource: FishPool = preload(
"res://fish/pools/fish_catalog.tres"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var root := Node.new()
get_root().add_child(root)
var bag := PlayerBag.new()
var catches := FishInventory.new()
var capacity := PlayerCoolerCapacity.new()
var layout := PlayerInventoryLayout.new()
var hotbar := PlayerHotbar.new()
for node: Node in [bag, catches, capacity, layout, hotbar]:
root.add_child(node)
bag.setup(ItemCatalogResource)
layout.setup(bag, catches, ItemCatalogResource, capacity)
bag.set_inventory_layout(layout)
catches.set_inventory_layout(layout)
hotbar.setup(bag, ItemCatalogResource, catches, layout)
assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36])
assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72])
assert(bag.add_item(&"coffee", 3))
assert(layout.get_inventory_count() == 1)
assert(layout.get_inventory_capacity() == 9)
assert(layout.get_hotbar_count() == 0)
assert(hotbar.assign_item(2, &"coffee"))
assert(layout.get_inventory_count() == 0)
assert(layout.get_hotbar_count() == 1)
assert(
layout.get_container(PlayerInventoryLayout.EntryKind.ITEM, &"coffee")
== PlayerInventoryLayout.InventoryContainer.HOTBAR
)
assert(hotbar.clear_slot(2))
assert(layout.get_inventory_count() == 1)
assert(layout.get_hotbar_count() == 0)
var fish_catch := FishCatch.new()
fish_catch.fish = Bluegill
fish_catch.fish_id = Bluegill.id
fish_catch.catch_id = &"bluegill:unified_inventory_test"
fish_catch.weight_lb = Bluegill.get_minimum_weight()
fish_catch.display_scale = Bluegill.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.sale_value = Bluegill.get_sale_value_for_weight(
fish_catch.weight_lb
)
assert(catches.add_catch(fish_catch))
assert(layout.get_inventory_count() == 2)
assert(layout.move_entry_to_first_free(
PlayerInventoryLayout.EntryKind.CATCH,
fish_catch.catch_id,
PlayerInventoryLayout.InventoryContainer.STORAGE,
))
assert(layout.get_inventory_count() == 1)
assert(layout.get_storage_count() == 1)
assert(layout.get_storage_capacity() == 9)
var inventory_grid := GeneralInventoryGrid.new()
root.add_child(inventory_grid)
inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10)
inventory_grid.setup(
layout,
bag,
catches,
hotbar,
ItemCatalogResource,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
var all_inventory_slots: Array = inventory_grid.get("_slots")
assert(all_inventory_slots.size() == 36)
assert(inventory_grid.get_slots().size() == 9)
for slot_index: int in all_inventory_slots.size():
var slot := all_inventory_slots[slot_index] as GeneralInventorySlot
assert(slot.custom_minimum_size == Vector2(78.0, 78.0))
assert(slot.disabled == (slot_index >= 9))
assert(slot.tooltip_text.is_empty())
var normal := slot.get_theme_stylebox("normal") as StyleBoxFlat
assert(normal != null and normal.corner_radius_top_left == 39)
var locked_slot := all_inventory_slots[9] as GeneralInventorySlot
var locked_icon := locked_slot.get("_icon") as TextureRect
assert(locked_icon != null)
assert(is_equal_approx(locked_icon.size.x, 24.0))
assert(is_equal_approx(locked_icon.size.y, 24.0))
assert(is_equal_approx(locked_icon.modulate.a, 0.18))
var staged_slot := all_inventory_slots[0] as GeneralInventorySlot
staged_slot.set_staged(true)
assert(
(staged_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color
== Color(UtilityPageStyle.OCEAN_SELECTED, 0.92)
)
staged_slot.set_staged(false)
var sale_tray_slot := ShopSaleTraySlot.new()
root.add_child(sale_tray_slot)
assert(
sale_tray_slot.custom_minimum_size
== GeneralInventoryGrid.DEFAULT_SLOT_SIZE
)
var sale_tray_style := sale_tray_slot.get_theme_stylebox(
"normal"
) as StyleBoxFlat
assert(sale_tray_style != null)
assert(sale_tray_style.corner_radius_top_left == 26)
var wallet := PlayerWallet.new()
root.add_child(wallet)
assert(wallet.restore_balance(15000))
assert(layout.get_next_backpack_cost() == 1500)
assert(layout.purchase_backpack(wallet))
assert(layout.get_inventory_capacity() == 18)
assert(inventory_grid.get_slots().size() == 18)
assert(layout.get_next_backpack_cost() == 4500)
assert(layout.purchase_backpack(wallet))
assert(layout.get_inventory_capacity() == 27)
assert(inventory_grid.get_slots().size() == 27)
assert(layout.get_next_backpack_cost() == 9000)
assert(layout.purchase_backpack(wallet))
assert(layout.get_inventory_capacity() == 36)
assert(inventory_grid.get_slots().size() == 36)
assert(layout.get_next_backpack_cost() == -1)
var storage_grid := GeneralInventoryGrid.new()
root.add_child(storage_grid)
storage_grid.setup(
layout,
bag,
catches,
hotbar,
ItemCatalogResource,
PlayerInventoryLayout.InventoryContainer.STORAGE,
)
var all_storage_slots: Array = storage_grid.get("_slots")
assert(all_storage_slots.size() == 72)
assert(storage_grid.get_slots().size() == 9)
assert(
(all_storage_slots[9] as GeneralInventorySlot).accessibility_name
== "locked storage slot 10"
)
var storage_wallet := PlayerWallet.new()
root.add_child(storage_wallet)
assert(storage_wallet.restore_balance(9500))
for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]:
assert(capacity.purchase(storage_wallet))
assert(layout.get_storage_capacity() == expected_capacity)
assert(storage_grid.get_slots().size() == expected_capacity)
assert(capacity.get_next_capacity() == -1)
assert(capacity.get_next_cost() == -1)
var saved := layout.to_save_data()
assert(layout.move_entry_to_first_free(
PlayerInventoryLayout.EntryKind.ITEM,
&"coffee",
PlayerInventoryLayout.InventoryContainer.STORAGE,
))
assert(layout.restore_from_save_data(saved))
assert(layout.is_item_in_inventory(&"coffee"))
assert(
layout.get_container(
PlayerInventoryLayout.EntryKind.CATCH,
fish_catch.catch_id,
) == PlayerInventoryLayout.InventoryContainer.STORAGE
)
var network_sale := NetworkSaleService.new()
var session := NetworkSession.new()
root.add_child(session)
root.add_child(network_sale)
network_sale.set("_session", session)
network_sale.set("_item_catalog", ItemCatalogResource)
network_sale.set("_fish_catalog", FishCatalogResource)
var result: Dictionary = network_sale.call(
"_build_authoritative_result",
1,
"mixed_sale_test",
[],
[{"item_id": "coffee", "quantity": 2}],
MainShopBuyer,
)
assert(bool(result.get("accepted", false)))
assert(int(result.get("payout", -1)) == 20)
assert((result.get("items", []) as Array).size() == 1)
print("Unified inventory validation: PASS")
root.free()
quit()

View file

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

View file

@ -2,7 +2,12 @@ extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18170
const EXPECTED_POPULATION: int = 2
const EXPECTED_POPULATION: int = 8
const EXPECTED_BY_TYPE: Dictionary = {
&"crab_brown": 2,
&"clam_manila": 3,
&"beetle_stag_common": 3,
}
func _initialize() -> void:
@ -28,6 +33,7 @@ func _run_host() -> void:
assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1"))
assert(session.set_host_open(true))
await _wait_for_population(service)
_freeze_timed_entries(service)
_validate_population(service, true)
var remote_peer_id: int = 0
@ -83,6 +89,9 @@ func _run_client() -> void:
))
await _wait_for_population(service)
_validate_population(service, false)
# Give the host process time to observe the authenticated peer before this
# deliberately short client validation disconnects.
await create_timer(0.75).timeout
print("World spawn multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
@ -106,8 +115,10 @@ func _validate_population(service: Node, expect_authoritative_state: bool) -> vo
var presentations: Dictionary = service.get("_presentations")
assert(entities.size() == EXPECTED_POPULATION)
assert(presentations.size() == EXPECTED_POPULATION)
var counts: Dictionary = {}
for state: Dictionary in entities.values():
assert(state.get("type_id") == &"crab_brown")
var type_id := state.get("type_id") as StringName
counts[type_id] = int(counts.get(type_id, 0)) + 1
var position: Variant = state.get("position")
assert(typeof(position) == TYPE_VECTOR3)
assert((position as Vector3).is_finite())
@ -120,27 +131,43 @@ func _validate_population(service: Node, expect_authoritative_state: bool) -> vo
if expect_authoritative_state:
var quality: int = int(state.get("quality", -1))
assert(FishQuality.is_valid(quality))
assert(entry.get_movement_speed_for_quality(quality) > 0.0)
assert(entry.get_scare_radius_for_quality(quality) > 0.0)
if entry.is_stationary_spawn():
assert(entry.get_movement_speed_for_quality(quality) <= 0.0)
assert(not entry.can_be_scared())
else:
assert(entry.get_movement_speed_for_quality(quality) > 0.0)
assert(entry.get_scare_radius_for_quality(quality) > 0.0)
assert(counts == EXPECTED_BY_TYPE)
func _freeze_timed_entries(service: Node) -> void:
for state: Dictionary in (service.get("_entities") as Dictionary).values():
var entry := state.get("data") as GatherableData
if entry != null and entry.active_lifetime_seconds > 0.0:
state["expires_at"] = INF
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:
var crab_ids: Array[String] = []
for entity_id: String in entities:
var state: Dictionary = entities[entity_id]
if state.get("type_id") == &"crab_brown":
crab_ids.append(entity_id)
assert(crab_ids.size() == 2)
for entity_id: String in crab_ids:
service.call(
"_despawn_entity",
str(entity_id),
entity_id,
&"captured",
false,
true,
)
assert((service.get("_entities") as Dictionary).is_empty())
assert((service.get("_entities") as Dictionary).size() == 6)
var now: float = Time.get_ticks_msec() / 1000.0
var respawns: Array = service.get("_respawns")
assert(respawns.size() == EXPECTED_POPULATION)
assert(respawns.size() == crab_ids.size())
for index: int in respawns.size():
var respawn: Dictionary = respawns[index]
var delay: float = float(respawn.get("due", 0.0)) - now
@ -149,19 +176,20 @@ func _validate_respawn_budget(service: Node) -> void:
respawns[index] = respawn
service.call("_update_respawns")
assert((service.get("_entities") as Dictionary).size() == 1)
assert((service.get("_entities") as Dictionary).size() == 7)
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("_entities") as Dictionary).size() == 7)
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("_entities") as Dictionary).size() == 8)
assert((service.get("_respawns") as Array).is_empty())
_validate_population(service, true)
func _create_initialized_main() -> Node:

View file

@ -52,6 +52,30 @@ func _validate_catalog_statuses() -> void:
assert(is_equal_approx(brown.minimum_surface_y, -0.44))
assert(FishCatalog.get_fish_by_id(&"crab_brown") == brown.catch_data)
assert(not brown.catch_data.is_fishable())
var clam: GatherableData = Gatherables.get_entry(&"clam_manila")
assert(clam != null and clam.is_available())
assert(clam.catch_data.collection_method == FishData.CollectionMethod.DIGGING)
assert(clam.catch_data.logbook_section == FishData.LogbookSection.SHELLFISH)
assert(clam.required_tool_id == &"standard_shovel")
assert(clam.diggable_area_id == &"starter_beach")
assert(clam.presentation_mode == GatherableData.PresentationMode.WATER_SPURT)
assert(clam.is_stationary_hotspot())
assert(not clam.requires_sneaking)
assert(not clam.can_be_scared())
assert(is_equal_approx(clam.active_lifetime_seconds, 10.0))
assert(FishCatalog.get_fish_by_id(&"clam_manila") == clam.catch_data)
assert(not clam.catch_data.is_fishable())
var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common")
assert(beetle != null and beetle.is_available())
assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET)
assert(beetle.required_tool_id == &"crab_net")
assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks")
assert(beetle.is_stationary_spawn())
assert(not beetle.is_stationary_hotspot())
assert(not beetle.requires_sneaking)
assert(not beetle.can_be_scared())
assert(FishCatalog.get_fish_by_id(&"beetle_stag_common") == beetle.catch_data)
assert(not beetle.catch_data.is_fishable())
for type_id: StringName in [
&"crab_ghost",
@ -76,8 +100,10 @@ func _validate_catalog_statuses() -> void:
)
var available: Array[GatherableData] = Gatherables.get_available_entries()
assert(available.size() == 1)
assert(available.front() == brown)
assert(available.size() == 3)
assert(available.has(brown))
assert(available.has(clam))
assert(available.has(beetle))
var rng := RandomNumberGenerator.new()
rng.seed = 24680
var captured_delay: float = brown.get_respawn_delay(&"captured", rng)
@ -111,6 +137,18 @@ func _validate_billboard_presentation() -> void:
assert(not sprite.shaded)
gatherable.free()
var hotspot := WorldGatherableType.new()
hotspot.call("_ensure_water_spurt_visual")
var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D
assert(hole != null)
assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
var hole_material := hole.mesh.surface_get_material(0) as StandardMaterial3D
assert(hole_material != null)
assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED)
assert(is_equal_approx(hole_material.albedo_color.a, 1.0))
hotspot.queue_free()
func _validate_quality_behavior(entry: GatherableData) -> void:
var prior_speed: float = -1.0

View file

@ -31,19 +31,10 @@ func _run_host() -> void:
main.get_node("%WorldWeatherService") as WorldWeatherService
)
assert(session.start_private_host(TEST_PORT))
var game_ui := main.get_node("%GameUI") as CanvasLayer
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
assert(chat_ui.call("_handle_chat_command", "/weather clear"))
assert(world_weather.get_weather() == WorldWeatherService.Weather.SUNNY)
world_time.synchronize_time(INITIAL_HOST_TIME)
assert(is_equal_approx(
world_time.get_persistent_time_hours(), INITIAL_HOST_TIME
assert(world_time.set_authoritative_time(INITIAL_HOST_TIME))
assert(world_weather.set_authoritative_weather(
WorldWeatherService.Weather.RAINY
))
assert(chat_ui.call("_handle_chat_command", "/weather rainy"))
assert(
world_weather.get_persistent_weather()
== WorldWeatherService.Weather.RAINY
)
assert(session.set_host_open(true))
var remote_peer_id: int = 0
@ -61,35 +52,12 @@ func _run_host() -> void:
assert(session.peer_supports_capability(
remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY
))
var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
remote_peer_id
)
assert(remote_record != null and remote_record.identity_authenticated)
assert(session.set_peer_operator(
remote_peer_id,
remote_record.identity_fingerprint,
true,
))
assert(world_time.get_phase() == WorldTimeService.Phase.DUSK)
var command_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < command_deadline
and _wrapped_time_difference(
world_time.get_time_hours(), UPDATED_HOST_TIME
) > TIME_TOLERANCE_HOURS
):
await process_frame
assert(is_equal_approx(
world_time.get_persistent_time_hours(), UPDATED_HOST_TIME
await create_timer(1.0).timeout
assert(world_time.set_authoritative_time(UPDATED_HOST_TIME))
assert(world_weather.set_authoritative_weather(
WorldWeatherService.Weather.FOGGY
))
var fog_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy():
await process_frame
assert(
world_weather.get_persistent_weather()
== WorldWeatherService.Weather.FOGGY
)
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < disconnect_deadline
@ -131,12 +99,6 @@ func _run_client() -> void:
assert(session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
))
assert(world_time.restore_persistent_time_hours(15.25))
assert(world_weather.restore_persistent_state(
WorldWeatherService.Weather.CLOUDY,
222.0,
))
var initial_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < initial_deadline
@ -170,11 +132,7 @@ func _run_client() -> void:
assert(is_equal_approx(clock_panel.position.y, 10.0))
assert(is_equal_approx(weather_icon.position.y, 10.0))
assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y)
var operator_deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
await process_frame
assert(session.is_local_operator())
assert(chat_ui.call("_handle_chat_command", "/time night"))
assert(not chat_ui.has_method("_handle_chat_command"))
var update_deadline: int = Time.get_ticks_msec() + 10000
while (
@ -188,10 +146,7 @@ func _run_client() -> void:
world_time.get_time_hours(), UPDATED_HOST_TIME
) <= TIME_TOLERANCE_HOURS)
assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT)
assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25))
assert(clock_label.text == world_time.get_clock_text())
await create_timer(0.6).timeout
assert(chat_ui.call("_handle_chat_command", "/weather foggy"))
var fog_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < fog_deadline
@ -200,14 +155,6 @@ func _run_client() -> void:
await process_frame
assert(world_weather.is_foggy())
assert(weather_icon.get_weather() == WorldWeatherService.Weather.FOGGY)
assert(
world_weather.get_persistent_weather()
== WorldWeatherService.Weather.CLOUDY
)
assert(is_equal_approx(
world_weather.get_persistent_seconds_remaining(),
222.0,
))
chat_ui.call("set_dock_right", true)
await process_frame
assert(clock_panel.position.x > 1000.0)

View file

@ -29,6 +29,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_clock_boundaries_and_duration()
_validate_system_clock_authority()
_validate_persistent_host_clock()
_validate_fishing_availability()
_validate_fishing_spot_context()
@ -40,7 +41,34 @@ func _run() -> void:
func _validate_clock_boundaries_and_duration() -> void:
assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 3600.0)
assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 86400.0)
assert(is_equal_approx(
WorldTimeServiceType.HOURS_PER_REAL_SECOND,
1.0 / 3600.0,
))
assert(is_equal_approx(
WorldTimeServiceType.time_hours_from_datetime({
"hour": 17, "minute": 30, "second": 18,
}),
17.505,
))
assert(
WorldTimeServiceType.date_id_from_datetime({
"year": 2026, "month": 8, "day": 18,
}) == "2026-08-18"
)
assert(
WorldTimeServiceType.calendar_cycle_id_from_datetime({
"year": 2026, "month": 8, "day": 18,
"hour": 7, "minute": 59, "second": 0,
}, WorldTimeServiceType.DAY_START_HOUR) == "2026-08-17"
)
assert(
WorldTimeServiceType.calendar_cycle_id_from_datetime({
"year": 2026, "month": 8, "day": 18,
"hour": 8, "minute": 0, "second": 0,
}, WorldTimeServiceType.DAY_START_HOUR) == "2026-08-18"
)
assert(
WorldTimeServiceType.phase_for_hour(7.49)
== WorldTimeServiceType.Phase.NIGHT
@ -71,12 +99,12 @@ func _validate_clock_boundaries_and_duration() -> void:
var clock := WorldTimeServiceType.new()
root.add_child(clock)
clock.begin_session(8.0)
clock.advance_time(1800.0)
clock.begin_test_session(8.0)
clock.advance_time(12.0 * 60.0 * 60.0)
assert(is_equal_approx(clock.get_time_hours(), 20.0))
assert(clock.is_night_period())
assert(clock.is_transition())
clock.advance_time(1800.0)
clock.advance_time(12.0 * 60.0 * 60.0)
assert(is_equal_approx(clock.get_time_hours(), 8.0))
assert(not clock.is_night_period())
assert(clock.is_transition())
@ -88,10 +116,10 @@ func _validate_clock_boundaries_and_duration() -> void:
func(time_hours: float, _phase: WorldTimeService.Phase) -> void:
emitted_times.append(time_hours)
)
clock.begin_session(12.0 + 31.0 / 60.0)
clock.begin_test_session(12.0 + 31.0 / 60.0)
emitted_times.clear()
for _frame: int in 1201:
clock.advance_time(1.0 / 240.0)
for _frame: int in 7201:
clock.advance_time(1.0 / 60.0)
assert(clock.get_clock_text() == "12:33 pm")
assert(emitted_times.size() >= 2)
clock.queue_free()
@ -101,20 +129,41 @@ func _validate_persistent_host_clock() -> void:
var clock := WorldTimeServiceType.new()
root.add_child(clock)
assert(clock.restore_persistent_time_hours(18.75))
clock.set_persistence_tracking_enabled(true)
clock.begin_session(clock.get_persistent_time_hours())
clock.begin_test_session(18.75)
assert(is_equal_approx(clock.get_time_hours(), 18.75))
clock.synchronize_time(19.25)
assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
clock.set_persistence_tracking_enabled(false)
clock.synchronize_time(6.5)
assert(is_equal_approx(clock.get_time_hours(), 6.5))
assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25))
assert(is_equal_approx(clock.get_persistent_time_hours(), 6.5))
assert(not clock.restore_persistent_time_hours(-1.0))
assert(not clock.restore_persistent_time_hours(24.0))
clock.queue_free()
func _validate_system_clock_authority() -> void:
var clock := WorldTimeServiceType.new()
root.add_child(clock)
clock.begin_authoritative_session()
var system_datetime: Dictionary = Time.get_datetime_dict_from_system(false)
var system_hour: float = WorldTimeServiceType.time_hours_from_datetime(
system_datetime
)
assert(clock.is_using_system_clock())
assert(_wrapped_time_difference(
clock.get_time_hours(), system_hour
) < 2.0 / 3600.0)
assert(
clock.get_calendar_date_id()
== WorldTimeServiceType.date_id_from_datetime(system_datetime)
)
assert(clock.set_authoritative_time(12.0))
assert(not clock.is_using_system_clock())
clock.clear_editor_time_override()
assert(clock.is_using_system_clock())
clock.queue_free()
func _validate_fishing_availability() -> void:
var day_context := FishingContextType.new()
day_context.location_tags = [&"starter_pond"]
@ -165,7 +214,7 @@ func _validate_fishing_availability() -> void:
func _validate_fishing_spot_context() -> void:
var clock := WorldTimeServiceType.new()
clock.begin_session(20.25)
clock.begin_test_session(20.25)
var fishing_spot := FishingSpotType.new()
fishing_spot.set("_world_time", clock)
var region := FishableWaterRegionType.new()
@ -246,6 +295,10 @@ func _validate_environment_presentation() -> void:
float(runtime_sky_material.get_shader_parameter("moon_visibility"))
< 0.01
)
assert(
float(runtime_sky_material.get_shader_parameter("star_visibility"))
< 0.01
)
var day_ambient_energy: float = runtime_environment.ambient_light_energy
assert(not runtime_environment.fog_enabled)
assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35))
@ -257,6 +310,9 @@ func _validate_environment_presentation() -> void:
)
assert("uniform float fog_horizon_occlusion" in sky_shader.code)
assert("fog_horizon_color.rgb" in sky_shader.code)
assert("uniform float star_visibility" in sky_shader.code)
assert("float procedural_star_field" in sky_shader.code)
assert("upper_sky_fade" in sky_shader.code)
assert("fog_disabled" in water_shader.code)
assert("surface_view_position = view_vertex.xyz" in water_shader.code)
assert("surface_view_distance = length(surface_view_position)" in water_shader.code)
@ -301,6 +357,17 @@ func _validate_environment_presentation() -> void:
runtime_sky_material.get_shader_parameter("sun_direction") as Vector3
)
assert(night_moon_visibility > 0.99)
assert(
float(runtime_sky_material.get_shader_parameter("star_visibility"))
> 0.99
)
assert((
runtime_sky_material.get_shader_parameter("star_color") as Color
).is_equal_approx(WorldTimeVisualController.STAR_COLOR))
assert(is_equal_approx(
float(runtime_sky_material.get_shader_parameter("star_strength")),
WorldTimeVisualController.STAR_STRENGTH,
))
assert(night_moon_direction.y > 0.85)
assert(night_moon_direction.is_equal_approx(-night_sun_direction))
assert(runtime_environment.ambient_light_energy < day_ambient_energy)
@ -339,6 +406,9 @@ func _validate_environment_presentation() -> void:
assert(is_equal_approx(float(
runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
), 1.0))
assert(is_zero_approx(float(
runtime_sky_material.get_shader_parameter("star_visibility")
)))
assert((
runtime_sky_material.get_shader_parameter("fog_horizon_color") as Color
).is_equal_approx(WorldTimeVisualController.NIGHT_FOG))
@ -425,6 +495,10 @@ func _validate_environment_presentation() -> void:
assert(is_zero_approx(float(
runtime_sky_material.get_shader_parameter("fog_horizon_occlusion")
)))
var dusk_star_visibility := float(
runtime_sky_material.get_shader_parameter("star_visibility")
)
assert(dusk_star_visibility > 0.45 and dusk_star_visibility < 0.55)
var dusk_horizon: Color = runtime_sky_material.get_shader_parameter(
"sky_horizon_color"
) as Color
@ -442,3 +516,8 @@ func _validate_weather_clock_icon() -> void:
weather_icon.set_weather(WorldWeatherService.Weather.CLOUDY)
assert(weather_icon.tooltip_text == "cloudy")
weather_icon.queue_free()
static func _wrapped_time_difference(left: float, right: float) -> float:
var difference: float = absf(left - right)
return minf(difference, WorldTimeServiceType.HOURS_PER_DAY - difference)

View file

@ -26,6 +26,7 @@ func _run() -> void:
_validate_weather_scheduler()
_validate_authoritative_weather_override()
_validate_weather_persistence()
_validate_legacy_forecast_save_migration()
_validate_snapshot_bounds()
_validate_fishing_weather_seams()
_validate_fishing_spot_context()
@ -65,14 +66,9 @@ func _validate_weather_scheduler() -> void:
func _duration_is_valid(weather: WorldWeatherServiceType) -> bool:
var seconds: float = weather.get_seconds_remaining()
match weather.get_weather():
WorldWeatherServiceType.Weather.SUNNY:
return seconds >= 480.0 and seconds <= 900.0
WorldWeatherServiceType.Weather.CLOUDY:
return seconds >= 300.0 and seconds <= 720.0
WorldWeatherServiceType.Weather.RAINY, WorldWeatherServiceType.Weather.FOGGY:
return seconds >= 300.0 and seconds <= 600.0
return false
return is_equal_approx(
seconds, WorldWeatherServiceType.WEATHER_PERIOD_SECONDS
)
func _validate_authoritative_weather_override() -> void:
@ -94,7 +90,7 @@ func _validate_authoritative_weather_override() -> void:
var clock := WorldTimeServiceType.new()
root.add_child(clock)
clock.begin_session(WorldTimeServiceType.DAY_START_HOUR)
clock.begin_test_session(WorldTimeServiceType.DAY_START_HOUR)
var schedule: Array[Dictionary] = []
for index: int in WorldWeatherServiceType.DAILY_PLAN_SEGMENT_COUNT:
schedule.append({
@ -159,8 +155,11 @@ func _validate_weather_persistence() -> void:
))
weather.set_persistence_tracking_enabled(true)
weather.begin_authoritative_session(20260803)
assert(weather.is_raining())
assert(is_equal_approx(weather.get_seconds_remaining(), 187.5))
assert(weather.get_weather() == WorldWeatherServiceType.DEFAULT_WEATHER)
assert(is_equal_approx(
weather.get_seconds_remaining(),
WorldWeatherServiceType.WEATHER_PERIOD_SECONDS,
))
assert(not weather.restore_persistent_state(
WorldWeatherServiceType.Weather.RAINY,
WorldWeatherServiceType.MAX_PERSISTED_SECONDS + 1.0,
@ -184,11 +183,49 @@ func _validate_snapshot_bounds() -> void:
assert(not NetworkWorldWeatherServiceType.validate_snapshot({
"session_id": "session",
"weather": int(WorldWeatherServiceType.Weather.RAINY),
"seconds_remaining": 1801.0,
"seconds_remaining": 3601.0,
"sequence": 2,
}))
func _validate_legacy_forecast_save_migration() -> void:
var legacy_schedule: Array[Dictionary] = []
for index: int in JobCatalog.LEGACY_WEATHER_SEGMENT_COUNT:
legacy_schedule.append({
"start_hour": fposmod(
WorldTimeService.DAY_START_HOUR
+ float(index) * JobCatalog.LEGACY_WEATHER_SEGMENT_HOURS,
WorldTimeService.HOURS_PER_DAY,
),
"weather": int(WorldWeatherService.Weather.SUNNY),
})
var legacy_job: Dictionary = {
"id": "legacy-catch",
"title": "legacy catch",
"description": "catch one fish",
"kind": int(JobCatalog.Kind.CATCH_TOTAL),
"target": 1,
"fish_coin": 1,
"experience": 1,
}
var save_data: Dictionary = PlayerJobService.default_save_data()
save_data["host_board"] = {
"plan_id": "legacy-plan",
"cycle": 4,
"schedule_anchor_index": 0,
"jobs": [legacy_job],
"weather_schedule": legacy_schedule,
}
save_data["active_plan_id"] = "legacy-plan"
assert(PlayerJobService.validate_save_data(save_data))
var jobs := PlayerJobService.new()
assert(jobs.restore_from_save_data(save_data))
var migrated: Dictionary = jobs.to_save_data()
assert((migrated.get("host_board", {}) as Dictionary).is_empty())
assert(str(migrated.get("active_plan_id", "")).is_empty())
jobs.free()
func _validate_fishing_weather_seams() -> void:
var clear_context := FishingContextType.new()
var rain_context := FishingContextType.new()
@ -255,7 +292,7 @@ func _validate_weather_presentation() -> void:
world_root.add_child(sun)
var clock := WorldTimeServiceType.new()
world_root.add_child(clock)
clock.begin_session(14.0)
clock.begin_test_session(14.0)
var weather := WorldWeatherServiceType.new()
world_root.add_child(weather)
weather.begin_remote_session()
@ -279,6 +316,34 @@ func _validate_weather_presentation() -> void:
var runtime_sky_material := (
runtime_environment.sky.sky_material as ShaderMaterial
)
clock.set_authoritative_time(0.0)
visuals.apply_time_immediately(0.0)
assert(float(
runtime_sky_material.get_shader_parameter("star_visibility")
) > 0.99)
visuals.apply_weather_immediately(
WorldWeatherServiceType.Weather.CLOUDY
)
assert(is_equal_approx(float(
runtime_sky_material.get_shader_parameter("star_visibility")
), 0.18))
visuals.apply_weather_immediately(
WorldWeatherServiceType.Weather.RAINY
)
assert(is_equal_approx(float(
runtime_sky_material.get_shader_parameter("star_visibility")
), 0.03))
visuals.apply_weather_immediately(
WorldWeatherServiceType.Weather.FOGGY
)
assert(is_zero_approx(float(
runtime_sky_material.get_shader_parameter("star_visibility")
)))
visuals.apply_weather_immediately(
WorldWeatherServiceType.Weather.SUNNY
)
clock.set_authoritative_time(14.0)
visuals.apply_time_immediately(14.0)
var clear_background_energy: float = (
runtime_environment.background_energy_multiplier
)

View file

@ -193,7 +193,6 @@ func setup(
_service.local_message_confirmed.connect(_on_local_message_confirmed)
_service.history_replaced.connect(_on_history)
_service.send_rejected.connect(_on_rejected)
_service.world_command_finished.connect(_on_world_command_finished)
_session.peer_removed.connect(_on_peer_removed)
_entry.text = _settings.current_settings.chat_draft
_entry.caret_column = _entry.text.length()
@ -835,8 +834,6 @@ func _send() -> void:
if body.strip_edges().is_empty():
close_chat()
return
if _handle_chat_command(body):
return
_send_pending = true
_pending_send_body = NetworkChatProtocol.sanitize_body(body)
_entry.editable = false
@ -852,85 +849,6 @@ func _send() -> void:
_set_status("Sending…")
func _handle_chat_command(body: String) -> bool:
var command_text := body.strip_edges()
if not command_text.begins_with("/"):
return false
var parts: PackedStringArray = command_text.split(" ", false)
var command := String(parts[0]).trim_prefix("/").to_lower()
match command:
"time":
_handle_time_command(parts)
"weather":
_handle_weather_command(parts)
"":
_show_command_error("Enter a command after /.")
_:
_show_command_error("Unknown command: /%s" % command)
_entry.clear()
_flush_draft()
return true
func _show_command_error(message: String) -> void:
_set_status(message)
close_chat(true)
func _handle_time_command(parts: PackedStringArray) -> void:
if parts.size() != 2:
_show_command_error("Usage: /time [dawn, day, dusk, night]")
return
if _service == null or _world_time == null:
_show_command_error("World time is unavailable.")
return
var phase_name := String(parts[1]).to_lower()
if phase_name not in ["dawn", "day", "dusk", "night"]:
_show_command_error("Usage: /time [dawn, day, dusk, night]")
return
if not _service.request_world_time_change(phase_name):
_show_command_error(
"Only the host or an operator can change world time."
)
return
close_chat()
func _handle_weather_command(parts: PackedStringArray) -> void:
if parts.size() != 2:
_show_command_error(
"Usage: /weather [clear, cloudy, rainy, foggy]"
)
return
if _service == null or _world_weather == null:
_show_command_error("World weather is unavailable.")
return
var weather_name := String(parts[1]).to_lower()
match weather_name:
"clear", "sunny":
weather_name = "clear"
"cloudy", "rainy", "foggy":
pass
_:
_show_command_error(
"Usage: /weather [clear, cloudy, rainy, foggy]"
)
return
if not _service.request_world_weather_change(weather_name):
_show_command_error(
"Only the host or an operator can change world weather."
)
return
close_chat()
func _on_world_command_finished(success: bool, message: String) -> void:
if success:
_set_status("")
elif not message.is_empty():
_set_status(message)
func _on_local_message_confirmed(message: Dictionary) -> void:
if (
not _send_pending

View file

@ -0,0 +1,159 @@
class_name GeneralInventoryGrid
extends Control
signal entry_selected(kind: int, identity: StringName)
signal slot_activated(slot: GeneralInventorySlot)
signal context_requested(slot: GeneralInventorySlot)
signal context_changed(slot: GeneralInventorySlot, text: String, active: bool)
const DEFAULT_SLOT_SIZE := Vector2(52.0, 52.0)
const DEFAULT_SLOT_SEPARATION: int = 5
const COLUMNS: int = PlayerInventoryLayout.INVENTORY_COLUMNS
var _layout: PlayerInventoryLayout
var _bag: PlayerBag
var _fish_inventory: FishInventory
var _hotbar: PlayerHotbar
var _item_catalog: ItemCatalog
var _container: int = PlayerInventoryLayout.InventoryContainer.INVENTORY
var _grid: GridContainer
var _slots: Array[GeneralInventorySlot] = []
var _slot_size: Vector2 = DEFAULT_SLOT_SIZE
var _slot_separation: int = DEFAULT_SLOT_SEPARATION
func _ready() -> void:
_grid = GridContainer.new()
_grid.columns = COLUMNS
_apply_grid_separation()
add_child(_grid)
func set_slot_presentation(slot_size: Vector2, separation: int) -> void:
_slot_size = Vector2(maxf(1.0, slot_size.x), maxf(1.0, slot_size.y))
_slot_separation = maxi(0, separation)
if _grid != null:
_apply_grid_separation()
if is_node_ready() and _layout != null:
var active_capacity := (
_layout.get_inventory_capacity()
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else _layout.get_storage_capacity()
)
_rebuild(
PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT,
active_capacity,
)
func setup(
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
container: int,
) -> void:
_layout = layout
_bag = bag
_fish_inventory = fish_inventory
_hotbar = hotbar
_item_catalog = item_catalog
_container = container
if _layout != null and not _layout.layout_changed.is_connected(refresh):
_layout.layout_changed.connect(refresh)
refresh()
func refresh() -> void:
if not is_node_ready() or _layout == null:
return
var active_capacity := (
_layout.get_inventory_capacity()
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else _layout.get_storage_capacity()
)
var visible_capacity := (
PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT
if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY
else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT
)
if _slots.size() != visible_capacity:
_rebuild(visible_capacity, active_capacity)
else:
for index: int in _slots.size():
_slots[index].set_locked(index >= active_capacity)
for slot: GeneralInventorySlot in _slots:
slot.refresh()
func get_slots() -> Array[GeneralInventorySlot]:
var active: Array[GeneralInventorySlot] = []
for slot: GeneralInventorySlot in _slots:
if not slot.disabled:
active.append(slot)
return active
func get_first_occupied_slot() -> GeneralInventorySlot:
for slot: GeneralInventorySlot in _slots:
if not slot.entry_identity.is_empty():
return slot
return _slots.front() if not _slots.is_empty() else null
func _rebuild(visible_capacity: int, active_capacity: int) -> void:
for child: Node in _grid.get_children():
child.queue_free()
_slots.clear()
for slot_index: int in visible_capacity:
var slot := GeneralInventorySlot.new()
slot.custom_minimum_size = _slot_size
slot.entry_selected.connect(
func(kind: int, identity: StringName) -> void:
entry_selected.emit(kind, identity)
)
slot.pressed.connect(func() -> void: slot_activated.emit(slot))
slot.context_requested.connect(
func(source: GeneralInventorySlot) -> void:
context_requested.emit(source)
)
slot.context_changed.connect(
func(
source: GeneralInventorySlot,
text: String,
active: bool,
) -> void:
context_changed.emit(source, text, active)
)
_grid.add_child(slot)
slot.set_presentation_size(_slot_size)
slot.configure(
slot_index,
_container,
slot_index >= active_capacity,
_layout,
_bag,
_fish_inventory,
_hotbar,
_item_catalog,
)
_slots.append(slot)
custom_minimum_size = Vector2(
float(COLUMNS) * _slot_size.x
+ float(COLUMNS - 1) * _slot_separation,
ceilf(float(visible_capacity) / float(COLUMNS)) * _slot_size.y
+ maxf(
0.0,
ceilf(float(visible_capacity) / float(COLUMNS)) - 1.0,
) * _slot_separation,
)
size = custom_minimum_size
ControllerFocusNavigation.configure_spatial_neighbors(_slots)
func _apply_grid_separation() -> void:
_grid.add_theme_constant_override("h_separation", _slot_separation)
_grid.add_theme_constant_override("v_separation", _slot_separation)

View file

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

View file

@ -0,0 +1,346 @@
class_name GeneralInventorySlot
extends Button
signal entry_selected(kind: int, identity: StringName)
signal entry_moved
signal context_requested(slot: GeneralInventorySlot)
signal context_changed(slot: GeneralInventorySlot, text: String, active: bool)
const ItemDataType = preload("res://items/item_data.gd")
const LOCK_ICON: Texture2D = preload("res://ui/icons/pictograms/lock_light.png")
var slot_index: int = -1
var container: int = -1
var entry_kind: int = -1
var entry_identity: StringName
var _locked: bool = false
var _layout: PlayerInventoryLayout
var _bag: PlayerBag
var _fish_inventory: FishInventory
var _hotbar: PlayerHotbar
var _item_catalog: ItemCatalog
var _icon: TextureRect
var _quantity: Label
var _presentation_size := Vector2(52.0, 52.0)
var _context_text: String = ""
var _context_hovered: bool = false
var _context_focused: bool = false
var _staged: bool = false
func _ready() -> void:
focus_mode = Control.FOCUS_ALL
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
pressed.connect(_on_pressed)
focus_entered.connect(_set_context_focused.bind(true))
focus_exited.connect(_set_context_focused.bind(false))
mouse_entered.connect(_set_context_hovered.bind(true))
mouse_exited.connect(_set_context_hovered.bind(false))
gui_input.connect(_on_gui_input)
_icon = TextureRect.new()
_icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_icon)
_quantity = Label.new()
_quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_quantity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_quantity.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
_quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_quantity)
_apply_presentation()
func set_presentation_size(presentation_size: Vector2) -> void:
_presentation_size = presentation_size
custom_minimum_size = presentation_size
if is_node_ready():
_apply_presentation()
func configure(
new_slot_index: int,
new_container: int,
is_locked: bool,
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
) -> void:
slot_index = new_slot_index
container = new_container
_locked = is_locked
_layout = layout
_bag = bag
_fish_inventory = fish_inventory
_hotbar = hotbar
_item_catalog = item_catalog
name = "InventorySlot%d" % slot_index
refresh()
func set_locked(is_locked: bool) -> void:
if _locked == is_locked:
return
_locked = is_locked
refresh()
func refresh() -> void:
call_deferred("_update_context_presence")
entry_kind = -1
entry_identity = StringName()
_context_text = ""
_icon.texture = null
_quantity.text = ""
disabled = _locked
_apply_icon_geometry()
if _locked:
_icon.texture = LOCK_ICON
_icon.modulate = Color(UtilityPageStyle.OCEAN_DISABLED, 0.18)
var storage_slot := (
container == PlayerInventoryLayout.InventoryContainer.STORAGE
)
_context_text = (
"locked storage slot" if storage_slot else "locked backpack slot"
)
tooltip_text = ""
accessibility_name = "%s %d" % [_context_text, slot_index + 1]
return
_icon.modulate = Color.WHITE
_context_text = "empty slot"
tooltip_text = ""
accessibility_name = "empty %s slot %d" % [
"storage"
if container == PlayerInventoryLayout.InventoryContainer.STORAGE
else "inventory",
slot_index + 1,
]
if _layout == null:
return
var entry := _layout.get_entry_at(container, slot_index)
if entry.is_empty():
return
entry_kind = int(entry.get("kind", -1))
entry_identity = StringName(str(entry.get("identity", "")))
if entry_kind == PlayerInventoryLayout.EntryKind.ITEM:
var item: ItemDataType = (
_item_catalog.get_item_by_id(entry_identity)
if _item_catalog != null else null
)
if item == null:
return
_icon.texture = item.icon
var quantity := _bag.get_quantity(entry_identity) if _bag != null else 0
_quantity.text = "×%d" % quantity if quantity > 1 else ""
_context_text = _item_context_text(item, quantity)
accessibility_name = "%s, slot %d" % [item.display_name, slot_index + 1]
elif entry_kind == PlayerInventoryLayout.EntryKind.CATCH:
var fish_catch = (
_fish_inventory.get_catch_by_id(entry_identity)
if _fish_inventory != null else null
)
if fish_catch == null:
return
_icon.texture = fish_catch.fish.display_texture
_context_text = _catch_context_text(fish_catch)
var catch_name: String = FishQuality.qualified_name(
fish_catch.fish.display_name, fish_catch.quality
)
accessibility_name = "%s, slot %d" % [catch_name, slot_index + 1]
func _on_pressed() -> void:
if _locked:
return
entry_selected.emit(entry_kind, entry_identity)
func _on_gui_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if (
mouse_event != null
and mouse_event.button_index == MOUSE_BUTTON_RIGHT
and mouse_event.pressed
and not _locked
and not entry_identity.is_empty()
):
context_requested.emit(self)
accept_event()
func _set_context_hovered(active: bool) -> void:
_context_hovered = active
_update_context_presence()
func _set_context_focused(active: bool) -> void:
_context_focused = active
_update_context_presence()
func _update_context_presence() -> void:
if _locked or entry_identity.is_empty():
context_changed.emit(self, "", false)
return
var active: bool = _context_hovered or _context_focused
context_changed.emit(self, _context_text if active else "", active)
func set_move_source(active: bool) -> void:
modulate = Color(1.0, 1.0, 1.0, 0.42) if active else Color.WHITE
func set_staged(active: bool) -> void:
if _staged == active:
return
_staged = active
_apply_style()
func _item_context_text(item: ItemDataType, quantity: int) -> String:
var lines: Array[String] = [item.display_name]
lines.append("%s • quantity %d" % [item.get_category_name(), quantity])
if item.equippable:
lines.append("equippable")
elif item.usable:
lines.append("usable")
if not item.description.strip_edges().is_empty():
lines.append(item.description.strip_edges())
return "\n".join(lines)
func _catch_context_text(fish_catch: FishCatch) -> String:
var catch_name: String = FishQuality.qualified_name(
fish_catch.fish.display_name,
fish_catch.quality,
)
return "%s\n%0.2f lb • value %d\n%s" % [
catch_name,
fish_catch.weight_lb,
fish_catch.sale_value,
fish_catch.fish.logbook_fact,
]
func _get_drag_data(_at_position: Vector2) -> Variant:
if _locked or entry_identity.is_empty() or _icon.texture == null:
return null
var preview := TextureRect.new()
preview.custom_minimum_size = Vector2(56.0, 56.0)
preview.texture = _icon.texture
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
set_drag_preview(preview)
if entry_kind == PlayerInventoryLayout.EntryKind.CATCH:
return {
"kind": "cooler_fish",
"catch_id": String(entry_identity),
}
return {
"kind": "bag_item",
"item_id": String(entry_identity),
}
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if _locked or typeof(data) != TYPE_DICTIONARY or _layout == null:
return false
var kind := str((data as Dictionary).get("kind", ""))
return kind in ["bag_item", "cooler_fish", "hotbar_slot"]
func _drop_data(_at_position: Vector2, data: Variant) -> void:
if not _can_drop_data(Vector2.ZERO, data):
return
var payload := data as Dictionary
var payload_kind := str(payload.get("kind", ""))
var moved: bool = false
if payload_kind == "hotbar_slot":
moved = (
_hotbar != null
and _hotbar.move_slot_to_container(
int(payload.get("slot_index", -1)),
container,
slot_index,
)
)
else:
var kind := (
PlayerInventoryLayout.EntryKind.CATCH
if payload_kind == "cooler_fish"
else PlayerInventoryLayout.EntryKind.ITEM
)
var identity := StringName(str(
payload.get(
"catch_id" if payload_kind == "cooler_fish" else "item_id",
"",
)
))
moved = _layout.move_entry(kind, identity, container, slot_index)
if moved:
entry_moved.emit()
func _apply_style() -> void:
var radius: int = roundi(minf(_presentation_size.x, _presentation_size.y) * 0.5)
var normal := UtilityPageStyle.rounded_style(
Color(
UtilityPageStyle.OCEAN_SELECTED
if _staged else UtilityPageStyle.OCEAN_FIELD,
0.92 if _staged else 0.88,
),
radius,
)
var hover := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_SELECTED, 0.92), radius
)
var locked := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_FIELD, 0.38), radius
)
for state: StringName in [&"normal", &"pressed"]:
add_theme_stylebox_override(state, normal)
add_theme_stylebox_override("disabled", locked)
for state: StringName in [&"hover", &"focus"]:
add_theme_stylebox_override(state, hover)
func _apply_presentation() -> void:
_apply_icon_geometry()
var is_large: bool = _presentation_size.x >= 70.0
var quantity_width: float = 42.0 if is_large else 34.0
var quantity_height: float = 20.0 if is_large else 17.0
_quantity.position = Vector2(
-quantity_width - (8.0 if is_large else 5.0),
-quantity_height - (6.0 if is_large else 4.0),
)
_quantity.size = Vector2(quantity_width, quantity_height)
_quantity.add_theme_font_size_override("font_size", 14 if is_large else 11)
_apply_style()
func _apply_icon_geometry() -> void:
var is_large: bool = _presentation_size.x >= 70.0
var margin: float
if _locked:
var lock_size: float = 24.0 if is_large else 18.0
margin = maxf(
(_presentation_size.x - lock_size) * 0.5,
0.0,
)
else:
margin = 10.0 if is_large else 7.0
_icon.offset_left = margin
_icon.offset_top = margin
_icon.offset_right = -margin
_icon.offset_bottom = -margin

View file

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

View file

@ -6,7 +6,10 @@ const CONTENT_MARGIN_LEFT := 18.0
const CONTENT_MARGIN_TOP := 98.0
const CONTENT_MARGIN_RIGHT := 18.0
const CONTENT_MARGIN_BOTTOM := 16.0
# Retained for the Logbook's separate handwritten presentation. Inventory
# notepads deliberately prioritize legibility with Tuffy.
const HANDWRITTEN_FONT: Font = preload("res://ui/fonts/seattle_avenue.otf")
const NOTEPAD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
const NOTEPAD_TEXTURE: Texture2D = preload("res://art/ui/ui_notepad.png")
const NOTEPAD_CANONICAL_OVERSCAN: float = 1.2
const NOTEPAD_ART_OFFSET := Vector2(10.0, 20.0)
@ -24,11 +27,11 @@ func _ready() -> void:
static func apply_handwritten_to(root: Control) -> void:
root.add_theme_font_override("font", HANDWRITTEN_FONT)
root.add_theme_font_override("font", NOTEPAD_FONT)
for descendant: Node in root.find_children("*", "Control", true, false):
var control := descendant as Control
control.add_theme_font_override(
"font", HANDWRITTEN_FONT
"font", NOTEPAD_FONT
)
if control is Label:
control.add_theme_color_override("font_color", INK_COLOR)
@ -37,7 +40,7 @@ static func apply_handwritten_to(root: Control) -> void:
func _draw() -> void:
draw_texture_rect(NOTEPAD_TEXTURE, get_art_rect(), false)
var font: Font = HANDWRITTEN_FONT
var font: Font = NOTEPAD_FONT
draw_string(
font,
Vector2(20.0, 58.0),

View file

@ -0,0 +1,71 @@
class_name ShopSaleTraySlot
extends Button
signal remove_requested(key: String)
signal drop_requested(payload: Dictionary)
var entry_key: String = ""
var _icon: TextureRect
var _quantity: Label
func _ready() -> void:
custom_minimum_size = GeneralInventoryGrid.DEFAULT_SLOT_SIZE
focus_mode = Control.FOCUS_ALL
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
pressed.connect(func() -> void: remove_requested.emit(entry_key))
_icon = TextureRect.new()
_icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_icon.offset_left = 6.0
_icon.offset_top = 4.0
_icon.offset_right = -6.0
_icon.offset_bottom = -4.0
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_icon)
_quantity = Label.new()
_quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_quantity.position = Vector2(-34.0, -20.0)
_quantity.size = Vector2(30.0, 16.0)
_quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_quantity)
var normal := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_FIELD, 0.96), 26
)
var hover := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_SELECTED, 0.96), 26
)
for state: StringName in [&"normal", &"pressed", &"disabled"]:
add_theme_stylebox_override(state, normal)
for state: StringName in [&"hover", &"focus"]:
add_theme_stylebox_override(state, hover)
func configure(
key: String,
icon: Texture2D,
label: String,
quantity: int = 1,
) -> void:
entry_key = key
_icon.texture = icon
_quantity.text = "×%d" % quantity if quantity > 1 else ""
tooltip_text = "%s · select to remove" % label
accessibility_name = tooltip_text
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
return (
typeof(data) == TYPE_DICTIONARY
and str((data as Dictionary).get("kind", "")) in [
"bag_item", "cooler_fish"
]
)
func _drop_data(_at_position: Vector2, data: Variant) -> void:
if _can_drop_data(Vector2.ZERO, data):
drop_requested.emit((data as Dictionary).duplicate(true))

View file

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

View file

@ -104,7 +104,7 @@ const SHOP_SECTION_LABELS: Array[String] = [
"Snacks",
"Equipment",
"Art Supplies",
"Sell Fish",
"Sell",
]
const SUPPLY_ICON_GRID_COLUMNS: int = 9
const SUPPLY_ICON_TILE_SIZE := Vector2(72.0, 72.0)
@ -154,6 +154,9 @@ const ROD_PRICE_ICON_SIZE: float = 22.0
@onready var _cooler_cost: CurrencyAmount = %CoolerCost
@onready var _cooler_price_bubble: PanelContainer = %CoolerPriceBubble
@onready var _cooler_purchase: Button = %CoolerPurchase
@onready var _backpack_cost: CurrencyAmount = %BackpackCost
@onready var _backpack_price_bubble: PanelContainer = %BackpackPriceBubble
@onready var _backpack_purchase: Button = %BackpackPurchase
var _player: PlayerType
var _wallet: PlayerWalletType
@ -162,10 +165,16 @@ var _upgrades: PlayerFishingUpgradesType
var _fishing_spot: FishingSpotType
var _interaction: ShopInteractionType
var _bag: PlayerBagType
var _inventory: FishInventory
var _hotbar: PlayerHotbar
var _inventory_layout: PlayerInventoryLayout
var _item_catalog: ItemCatalogType
var _cooler_capacity: PlayerCoolerCapacityType
var _art_unlocks: PlayerArtUnlocksType
var _network_shop: NetworkShopService
var _network_sale: NetworkSaleService
var _reservations: PlayerAssetReservationService
var _sell_inventory: ShopSellInventory
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
@ -191,6 +200,7 @@ func _ready() -> void:
_reel_purchase.pressed.connect(_purchase_reel_speed)
_barrier_purchase.pressed.connect(_purchase_barrier_power)
_cooler_purchase.pressed.connect(_purchase_cooler_capacity)
_backpack_purchase.pressed.connect(_purchase_backpack_capacity)
func setup_controller_mapping(
@ -327,7 +337,9 @@ func _request_shop_cooler() -> bool:
if not _is_transaction_context_valid():
_set_feedback("The fishing shop is no longer available.")
return false
sell_fish_requested.emit()
activate_shop_cooler_page()
if _sell_inventory != null:
_sell_inventory.activate()
return _cooler_page_active
@ -339,6 +351,7 @@ func _focus_shop_section() -> void:
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
_backpack_purchase,
]:
if not upgrade_button.disabled:
upgrade_button.grab_focus()
@ -403,7 +416,7 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void:
_update_shop_tab_selection()
return
if _cooler_page_active:
shop_cooler_return_requested.emit()
deactivate_shop_cooler_page()
_shop_section = section_index as ShopSection
var showing_upgrades: bool = _shop_section == ShopSection.UPGRADES
_upgrades_content.visible = showing_upgrades
@ -494,6 +507,7 @@ func _apply_shop_styles() -> void:
_reel_price_bubble,
_barrier_price_bubble,
_cooler_price_bubble,
_backpack_price_bubble,
]:
var price_style := UtilityPageStyleType.rounded_style(
UtilityPageStyleType.OCEAN_FIELD,
@ -510,6 +524,7 @@ func _apply_shop_styles() -> void:
_reel_cost,
_barrier_cost,
_cooler_cost,
_backpack_cost,
]:
var amount_label := price.get_amount_label()
amount_label.add_theme_color_override(
@ -521,6 +536,7 @@ func _apply_shop_styles() -> void:
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
_backpack_purchase,
]:
UtilityPageStyleType.apply_ocean_button(button)
_feedback.add_theme_color_override(
@ -536,10 +552,15 @@ func setup(
fishing_spot: FishingSpotType,
interaction: ShopInteractionType,
bag: PlayerBagType,
inventory: FishInventory,
hotbar: PlayerHotbar,
inventory_layout: PlayerInventoryLayout,
item_catalog: ItemCatalogType,
cooler_capacity: PlayerCoolerCapacityType,
art_unlocks: PlayerArtUnlocksType,
network_shop: NetworkShopService,
network_sale: NetworkSaleService,
reservations: PlayerAssetReservationService,
) -> void:
_player = player
_wallet = wallet
@ -548,10 +569,28 @@ func setup(
_fishing_spot = fishing_spot
_interaction = interaction
_bag = bag
_inventory = inventory
_hotbar = hotbar
_inventory_layout = inventory_layout
_item_catalog = item_catalog
_cooler_capacity = cooler_capacity
_art_unlocks = art_unlocks
_network_shop = network_shop
_network_sale = network_sale
_reservations = reservations
_sell_inventory = ShopSellInventory.new()
_sell_inventory.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_shop_cooler_mount.add_child(_sell_inventory)
_sell_inventory.setup(
_inventory_layout,
_bag,
_inventory,
_hotbar,
_item_catalog,
_buyer,
_reservations,
_network_sale,
)
if (
_network_shop != null
and not _network_shop.local_purchase_pending.is_connected(
@ -574,6 +613,12 @@ func setup(
_on_cooler_capacity_changed
):
_cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed)
if not _inventory_layout.backpack_capacity_changed.is_connected(
_on_backpack_capacity_changed
):
_inventory_layout.backpack_capacity_changed.connect(
_on_backpack_capacity_changed
)
if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed):
_art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed)
_refresh_all()
@ -611,6 +656,8 @@ func open_shop() -> bool:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_set_feedback("")
deactivate_shop_cooler_page()
if _sell_inventory != null:
_sell_inventory.clear_staged()
show()
_shop_tab_bar.show()
_controller_zone = ControllerZone.TABS
@ -780,6 +827,7 @@ func _refresh_all() -> void:
_refresh_upgrades()
_refresh_supplies()
_refresh_cooler_capacity()
_refresh_backpack_capacity()
func _refresh_wallet() -> void:
@ -1515,25 +1563,60 @@ func _refresh_cooler_capacity() -> void:
)
var cooler_effect: String
if cost < 0:
cooler_effect = "%d fish · maximum level" % (
cooler_effect = "%d slots · maximum level" % (
_cooler_capacity.get_capacity()
)
_cooler_price_bubble.hide()
else:
_cooler_price_bubble.show()
cooler_effect = "%d%d fish" % [
cooler_effect = "%d%d slots" % [
_cooler_capacity.get_capacity(),
next_capacity,
]
_cooler_cost.set_amount(cost)
_cooler_purchase.tooltip_text = _upgrade_tooltip(
"cooler capacity",
"storage capacity",
level,
cooler_effect,
)
_cooler_purchase.accessibility_name = _cooler_purchase.tooltip_text
func _refresh_backpack_capacity() -> void:
if _inventory_layout == null:
return
var level := _inventory_layout.get_backpack_level()
var cost := _inventory_layout.get_next_backpack_cost()
var next_capacity := _inventory_layout.get_next_inventory_capacity()
_backpack_purchase.disabled = (
cost < 0
or _transaction_in_progress
or _closing
or _network_shop == null
or not _network_shop.can_request_backpack_purchase()
or not _inventory_layout.can_purchase_backpack(_wallet)
)
var effect: String
if cost < 0:
effect = "%d slots · maximum level" % (
_inventory_layout.get_inventory_capacity()
)
_backpack_price_bubble.hide()
else:
_backpack_price_bubble.show()
effect = "%d%d slots" % [
_inventory_layout.get_inventory_capacity(),
next_capacity,
]
_backpack_cost.set_amount(cost)
_backpack_purchase.tooltip_text = _upgrade_tooltip(
"backpack capacity",
level,
effect,
)
_backpack_purchase.accessibility_name = _backpack_purchase.tooltip_text
func _purchase_reel_speed() -> void:
_purchase_upgrade(true)
@ -1555,7 +1638,7 @@ func _purchase_supply(item_id: StringName) -> void:
item_id, owned, item.max_stack
)
if quantity <= 0 or not _bag.can_add_item(item_id, quantity):
_set_feedback("Your Bag is full.")
_set_feedback("Your inventory is full.")
return
var total_cost: int = FishingShopStockType.get_purchase_cost(
item_id, quantity, _bag.is_bait_unlocked(item_id)
@ -1617,6 +1700,23 @@ func _purchase_cooler_capacity() -> void:
_network_shop.request_cooler_capacity_upgrade()
func _purchase_backpack_capacity() -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
_set_feedback("unable to complete purchase.")
return
var cost := _inventory_layout.get_next_backpack_cost()
if cost < 0:
_set_feedback("Upgrade is already at maximum.")
return
if not _wallet.can_afford(cost):
_set_feedback("Insufficient funds.")
return
if _network_shop == null:
_set_feedback("Purchase could not be completed.")
return
_network_shop.request_backpack_capacity_upgrade()
func _purchase_upgrade(is_reel_speed: bool) -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
_set_feedback("unable to complete purchase.")
@ -1721,6 +1821,7 @@ func _on_wallet_changed(_balance: int, _delta: int) -> void:
_refresh_upgrades()
_refresh_supplies()
_refresh_cooler_capacity()
_refresh_backpack_capacity()
func _on_upgrades_changed(
@ -1738,6 +1839,10 @@ func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
_refresh_cooler_capacity()
func _on_backpack_capacity_changed(_level: int, _capacity: int) -> void:
_refresh_backpack_capacity()
func _on_art_unlocks_changed(_unlock_mask: int) -> void:
_refresh_supplies()

View file

@ -1,11 +1,10 @@
[gd_scene load_steps=9 format=3]
[gd_scene load_steps=8 format=3]
[ext_resource type="Script" path="res://ui/fishing_shop.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[ext_resource type="Texture2D" path="res://items/icons/shop/64_speed_plus.png" id="3_reel"]
[ext_resource type="Texture2D" path="res://items/icons/shop/64_power_plus.png" id="4_barrier"]
[ext_resource type="Texture2D" path="res://items/icons/shop/32_currency.png" id="5_coin"]
[ext_resource type="Texture2D" path="res://items/icons/equipment/64_cooler_plus.png" id="6_cooler"]
[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="7_close"]
[ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="8_currency"]
@ -269,10 +268,10 @@ mouse_filter = 2
unique_name_in_owner = true
offset_right = 144.0
offset_bottom = 144.0
tooltip_text = "cooler capacity"
icon = ExtResource("6_cooler")
tooltip_text = "storage capacity"
icon = ExtResource("7_close")
expand_icon = true
icon_max_width = 128
icon_max_width = 92
[node name="CoolerPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"]
unique_name_in_owner = true
@ -288,3 +287,32 @@ unique_name_in_owner = true
layout_mode = 2
amount = 75
icon_size = 24.0
[node name="BackpackTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
custom_minimum_size = Vector2(144, 168)
layout_mode = 2
mouse_filter = 2
[node name="BackpackPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile"]
unique_name_in_owner = true
offset_right = 144.0
offset_bottom = 144.0
tooltip_text = "backpack capacity"
icon = ExtResource("7_close")
expand_icon = true
icon_max_width = 92
[node name="BackpackPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile"]
unique_name_in_owner = true
z_index = 2
offset_left = 14.0
offset_top = 128.0
offset_right = 130.0
offset_bottom = 160.0
mouse_filter = 2
[node name="BackpackCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile/BackpackPriceBubble" instance=ExtResource("8_currency")]
unique_name_in_owner = true
layout_mode = 2
amount = 1500
icon_size = 24.0

View file

@ -27,6 +27,10 @@ const PlayerFishingUpgradesType = preload(
const ShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
const PlayerStorageType = preload("res://ui/player_storage.gd")
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const PlayerItemEffectsType = preload(
"res://progression/player_item_effects.gd"
)
@ -134,6 +138,8 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
@onready var _pause_menu: PauseMenuType = %PauseMenu
@onready var _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _player_storage: PlayerStorageType = %PlayerStorage
@onready var _storage_prompt: PanelContainer = %StoragePrompt
@onready var _shop_prompt: Control = %ShopPrompt
@onready var _shop_prompt_bubble: PanelContainer = %ShopPromptBubble
@onready var _shop_prompt_message: Label = %ShopPromptMessage
@ -174,10 +180,12 @@ var _gameplay_hud_hidden: bool = false
var _fishing_spot: FishingSpotType
var _system_menu_open: bool = false
var _shop_open: bool = false
var _storage_open: bool = false
var _chat_input_open: bool = false
var _player_menu_hotbar_visible: bool = false
var _main_shop_buyer: FishBuyerProfileType
var _shop_interaction: ShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _surface_drawing: NetworkSurfaceDrawingService
var _surface_drawing_hotbar_selected: bool = false
var _experience: PlayerExperienceType
@ -294,10 +302,12 @@ func setup(
fishing_spot: FishingSpotType,
bag: PlayerBagType,
hotbar: PlayerHotbarType,
inventory_layout: PlayerInventoryLayout,
item_catalog: ItemCatalogType,
main_shop_buyer: FishBuyerProfileType,
fishing_upgrades: PlayerFishingUpgradesType,
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType,
@ -398,6 +408,7 @@ func setup(
fishing_spot,
bag,
hotbar,
inventory_layout,
item_catalog,
cooler_capacity,
network_session,
@ -430,10 +441,28 @@ func setup(
fishing_spot,
shop_interaction,
bag,
inventory,
hotbar,
inventory_layout,
item_catalog,
cooler_capacity,
art_unlocks,
network_shop_service,
network_sale_service,
reservations,
)
_player_storage.setup(
player,
fishing_spot,
storage_interaction,
inventory_layout,
bag,
inventory,
hotbar,
item_catalog,
)
_player_storage.menu_visibility_changed.connect(
_on_storage_visibility_changed
)
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
@ -446,6 +475,7 @@ func setup(
)
_main_shop_buyer = main_shop_buyer
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_surface_drawing = surface_drawing
_surface_drawing_toolbar.setup(_surface_drawing, art_unlocks)
set_edge_docks(
@ -505,6 +535,7 @@ func _input(event: InputEvent) -> void:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
and not _chat_input_open
and not _showcase_active
and not _virtual_mouse_active
@ -552,6 +583,7 @@ func _can_use_character_call() -> bool:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
and not _chat_input_open
and not _showcase_active
and not _virtual_mouse_active
@ -565,8 +597,16 @@ func _character_call_yields_to_world_interaction(
) -> bool:
return (
event.is_action_pressed("interact")
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and (
(
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
or (
_storage_interaction != null
and _storage_interaction.is_local_player_in_range()
)
)
and _fishing_spot != null
and _fishing_spot.can_open_fishing_shop()
and not _fishing_shop.visible
@ -614,6 +654,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool:
or _system_menu_open
or _player_menu_open
or _shop_open
or _storage_open
):
return false
var use_mapping: bool = (
@ -825,6 +866,7 @@ func _can_start_virtual_mouse() -> bool:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
and not _chat_input_open
and _player != null
and _fishing_spot != null
@ -1182,6 +1224,7 @@ func _can_surface_drawing_be_active() -> bool:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
and not _chat_input_open
and not _showcase_active
and _fishing_spot != null
@ -1460,6 +1503,7 @@ func _refresh_active_bait_indicator_visibility() -> void:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
@ -1494,8 +1538,10 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
_surface_drawing.deactivate()
close_player_menu_for_session_end()
_fishing_shop.close_for_session_end()
_player_storage.close_for_session_end()
_fishing_panel.visible = false
_shop_prompt.hide()
_storage_prompt.hide()
_hotbar_ui.set_presentation_visible(false, false)
_hotbar_ui.set_gameplay_input_enabled(false)
else:
@ -1526,6 +1572,7 @@ func _can_toggle_gameplay_hud() -> bool:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
and not _chat_input_open
and not _showcase_active
and not _emote_radial_menu.is_open()
@ -1540,6 +1587,7 @@ func _refresh_gameplay_hud_visibility() -> void:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
_gameplay_transient_hud.visible = show_world_hud
_experience_presentation.visible = (
@ -1558,11 +1606,13 @@ func set_system_menu_open(is_open: bool) -> void:
and not is_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
_hotbar_ui.set_drag_enabled(_player_menu_open and not is_open)
if is_open:
_hotbar_ui.set_drag_enabled(false)
_shop_prompt.hide()
_storage_prompt.hide()
_emit_interactive_pointer_ui_changed()
@ -1570,6 +1620,53 @@ func get_fishing_shop() -> FishingShopType:
return _fishing_shop
func get_player_storage() -> PlayerStorageType:
return _player_storage
func set_storage_prompt_visible(
requested_visible: bool,
world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
) -> void:
if not _storage_prompt.has_meta(&"styled"):
_storage_prompt.set_meta(&"styled", true)
var style := UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.96), 12
)
style.anti_aliasing = false
_storage_prompt.add_theme_stylebox_override("panel", style)
_storage_prompt.visible = (
requested_visible
and _gameplay_ui_enabled
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
if _storage_prompt.visible and world_anchor.is_finite():
_position_storage_prompt(world_anchor)
func _position_storage_prompt(world_anchor: Vector3) -> void:
if _player == null:
_storage_prompt.hide()
return
var camera := _player.get_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor):
_storage_prompt.hide()
return
var camera_size := camera.get_viewport().get_visible_rect().size
var ui_size := _canonical_stage.size
if camera_size.x <= 0.0 or camera_size.y <= 0.0:
_storage_prompt.hide()
return
var point := camera.unproject_position(world_anchor) * ui_size / camera_size
_storage_prompt.position = Vector2(
clampf(point.x - _storage_prompt.size.x * 0.5, 8.0, ui_size.x - _storage_prompt.size.x - 8.0),
clampf(point.y - _storage_prompt.size.y - 12.0, 8.0, ui_size.y - _storage_prompt.size.y - 8.0),
)
func set_shop_prompt_visible(
requested_visible: bool,
world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
@ -1581,6 +1678,7 @@ func set_shop_prompt_visible(
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
if _shop_prompt.visible:
if world_anchor.is_finite():
@ -2216,9 +2314,30 @@ func _on_shop_visibility_changed(is_open: bool) -> void:
and not is_open
and not _system_menu_open
and not _player_menu_open
and not _storage_open
)
if is_open:
_shop_prompt.hide()
_storage_prompt.hide()
_emit_interactive_pointer_ui_changed()
func _on_storage_visibility_changed(is_open: bool) -> void:
_storage_open = is_open
_refresh_surface_drawing_activation()
_refresh_gameplay_hud_visibility()
_refresh_chat_availability()
_refresh_hotbar_visibility()
_hotbar_ui.set_gameplay_input_enabled(
_gameplay_ui_enabled
and not is_open
and not _system_menu_open
and not _player_menu_open
and not _shop_open
)
if is_open:
_shop_prompt.hide()
_storage_prompt.hide()
_emit_interactive_pointer_ui_changed()
@ -2338,6 +2457,7 @@ func _refresh_hotbar_visibility() -> void:
)
and not _system_menu_open
and not _shop_open
and not _storage_open
and (
not _player_menu_open
or _player_menu_hotbar_visible
@ -2349,7 +2469,11 @@ func _refresh_hotbar_visibility() -> void:
func _emit_interactive_pointer_ui_changed() -> void:
_refresh_active_bait_indicator_visibility()
interactive_pointer_ui_changed.emit(
_system_menu_open or _player_menu_open or _shop_open or _chat_input_open
_system_menu_open
or _player_menu_open
or _shop_open
or _storage_open
or _chat_input_open
)
@ -2368,6 +2492,7 @@ func _refresh_chat_availability() -> void:
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
_chat_ui.set_available(chat_available)
_chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open)

View file

@ -13,6 +13,7 @@
[ext_resource type="PackedScene" path="res://ui/surface_drawing_toolbar.tscn" id="11_drawing_toolbar"]
[ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"]
[ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"]
[ext_resource type="PackedScene" path="res://ui/player_storage.tscn" id="14_storage"]
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.032, 0.118, 0.15, 1)
@ -471,6 +472,32 @@ polygon = PackedVector2Array(-8, 0, 8, 0, 0, 10)
[node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")]
unique_name_in_owner = true
[node name="PlayerStorage" parent="UIRoot/CanonicalStage" instance=ExtResource("14_storage")]
unique_name_in_owner = true
[node name="StoragePrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
z_index = 56
offset_right = 220.0
offset_bottom = 50.0
mouse_filter = 2
theme = ExtResource("3_theme")
[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt"]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 7
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 7
[node name="Message" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"]
layout_mode = 2
text = "E open storage"
horizontal_alignment = 1
vertical_alignment = 1
theme_override_font_sizes/font_size = 18
[node name="ChatUI" type="Control" parent="UIRoot"]
unique_name_in_owner = true
layout_mode = 1

View file

@ -19,7 +19,9 @@ const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0)
const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0)
const HOTBAR_PRESENTATION_SCALE: float = 0.80
const HOTBAR_CANONICAL_POSITION := Vector2.ZERO
const HOTBAR_MENU_POSITION := Vector2(-136.0, -90.0)
# In inventory context the row intentionally straddles the panel's bottom
# edge, visually extending the storage layout into its nine quick-access slots.
const HOTBAR_MENU_POSITION := Vector2(0.0, -30.0)
const HOTBAR_GAMEPLAY_Z_INDEX: int = 35
# PlayerMenu is z=30 and its authored inventory panels are relative z=50.
const HOTBAR_MENU_Z_INDEX: int = 90

View file

@ -34,7 +34,7 @@ static func category_label(category: Category) -> String:
Category.SHELLFISH:
return "Shellfish"
_:
return "Misc"
return "Insects"
static func empty_state(category: Category) -> String:

View file

@ -18,6 +18,13 @@ const FishQualityType = preload("res://fish/fish_quality.gd")
const CurrencyPresentationType = preload(
"res://ui/currency_presentation.gd"
)
const INBOX_ENTRY_WIDTH: float = 742.0
const ATTACHMENT_COLUMN_X: float = 516.0
const ATTACHMENT_COLUMN_WIDTH: float = 266.0
const AMOUNT_FIELD_X_WITH_CURRENCY: float = 594.0
const AMOUNT_FIELD_X_PLAIN: float = 570.0
const AMOUNT_FIELD_WIDTH_WITH_CURRENCY: float = 130.0
const AMOUNT_FIELD_WIDTH_PLAIN: float = 154.0
var _service: NetworkMailService
var _reservations: PlayerAssetReservationService
@ -434,7 +441,7 @@ func _build_ui() -> void:
root.add_child(page)
_status = Label.new()
_status.position = Vector2(18, 408)
_status.size = Vector2(1024, 28)
_status.size = Vector2(758, 28)
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
@ -449,19 +456,19 @@ func _build_inbox() -> Control:
var title := Label.new()
title.text = "mail"
title.position = Vector2(12, 0)
title.size = Vector2(500, 42)
title.size = Vector2(300, 42)
title.add_theme_font_size_override("font_size", 30)
page.add_child(title)
_send_mail_button = Button.new()
_send_mail_button.text = "send mail"
_send_mail_button.position = Vector2(820, 0)
_send_mail_button.size = Vector2(150, 48)
_send_mail_button.position = Vector2(642, 0)
_send_mail_button.size = Vector2(140, 48)
_send_mail_button.pressed.connect(_show_compose)
page.add_child(_send_mail_button)
_archive_view_button = Button.new()
_archive_view_button.text = "archive"
_archive_view_button.position = Vector2(654, 0)
_archive_view_button.size = Vector2(154, 48)
_archive_view_button.position = Vector2(490, 0)
_archive_view_button.size = Vector2(140, 48)
_archive_view_button.pressed.connect(func() -> void:
_showing_archive = not _showing_archive
_archive_view_button.text = "inbox" if _showing_archive else "archive"
@ -470,16 +477,16 @@ func _build_inbox() -> Control:
page.add_child(_archive_view_button)
var scroll := ScrollContainer.new()
scroll.position = Vector2(12, 62)
scroll.size = Vector2(1036, 334)
scroll.size = Vector2(770, 334)
page.add_child(scroll)
_inbox_list = VBoxContainer.new()
_inbox_list.custom_minimum_size = Vector2(1008, 0)
_inbox_list.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 0)
_inbox_list.add_theme_constant_override("separation", 8)
scroll.add_child(_inbox_list)
_empty_label = Label.new()
_empty_label.text = "No letters yet."
_empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_empty_label.custom_minimum_size = Vector2(1008, 80)
_empty_label.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 80)
_inbox_list.add_child(_empty_label)
return page
@ -494,43 +501,43 @@ func _build_compose() -> Control:
page.add_child(title)
_greeting = OptionButton.new()
_greeting.position = Vector2(12, 48)
_greeting.size = Vector2(180, 46)
_greeting.size = Vector2(140, 46)
for id: String in NetworkMailProtocol.GREETINGS:
_greeting.add_item(GREETING_LABELS[id])
_greeting.set_item_metadata(_greeting.item_count - 1, id)
page.add_child(_greeting)
_recipient = OptionButton.new()
_recipient.position = Vector2(204, 48)
_recipient.size = Vector2(330, 46)
_recipient.position = Vector2(164, 48)
_recipient.size = Vector2(328, 46)
page.add_child(_recipient)
_body = TextEdit.new()
_body.position = Vector2(12, 106)
_body.size = Vector2(650, 218)
_body.size = Vector2(480, 218)
_body.placeholder_text = "write your letter…"
_body.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
_body.text_changed.connect(_update_send_state)
page.add_child(_body)
_salutation = OptionButton.new()
_salutation.position = Vector2(12, 336)
_salutation.size = Vector2(250, 46)
_salutation.size = Vector2(180, 46)
for id: String in NetworkMailProtocol.SALUTATIONS:
_salutation.add_item(SALUTATION_LABELS[id])
_salutation.set_item_metadata(_salutation.item_count - 1, id)
page.add_child(_salutation)
_signature = Label.new()
_signature.position = Vector2(274, 342)
_signature.size = Vector2(360, 36)
_signature.position = Vector2(204, 342)
_signature.size = Vector2(288, 36)
page.add_child(_signature)
_attachment_kind = OptionButton.new()
_attachment_kind.position = Vector2(688, 48)
_attachment_kind.size = Vector2(280, 46)
_attachment_kind.position = Vector2(ATTACHMENT_COLUMN_X, 48)
_attachment_kind.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 46)
for label: String in ["No gift", "Currency", "Fish", "Item"]:
_attachment_kind.add_item(label)
_attachment_kind.item_selected.connect(_refresh_attachment_choices)
page.add_child(_attachment_kind)
_attachment_choice = OptionButton.new()
_attachment_choice.position = Vector2(688, 106)
_attachment_choice.size = Vector2(280, 46)
_attachment_choice.position = Vector2(ATTACHMENT_COLUMN_X, 106)
_attachment_choice.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 46)
_attachment_choice.item_selected.connect(
func(_index: int) -> void:
_update_attachment_amount_limit()
@ -539,16 +546,16 @@ func _build_compose() -> Control:
page.add_child(_attachment_choice)
_coin_available_heading = Label.new()
_coin_available_heading.text = "Available"
_coin_available_heading.position = Vector2(688, 158)
_coin_available_heading.position = Vector2(ATTACHMENT_COLUMN_X, 158)
_coin_available_heading.size = Vector2(80, 28)
page.add_child(_coin_available_heading)
_coin_available = CurrencyPresentationType.instantiate_amount(0, 18.0)
_coin_available.position = Vector2(770, 158)
_coin_available.size = Vector2(198, 28)
_coin_available.position = Vector2(598, 158)
_coin_available.size = Vector2(184, 28)
_coin_available.alignment = BoxContainer.ALIGNMENT_BEGIN
page.add_child(_coin_available)
_attachment_amount_currency_icon = TextureRect.new()
_attachment_amount_currency_icon.position = Vector2(746, 204)
_attachment_amount_currency_icon.position = Vector2(570, 204)
_attachment_amount_currency_icon.size = Vector2(18, 18)
_attachment_amount_currency_icon.texture = preload(
"res://items/icons/shop/32_currency.png"
@ -563,27 +570,27 @@ func _build_compose() -> Control:
_attachment_amount_currency_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
page.add_child(_attachment_amount_currency_icon)
_attachment_amount = LineEdit.new()
_attachment_amount.position = Vector2(770, 190)
_attachment_amount.size = Vector2(140, 46)
_attachment_amount.position = Vector2(AMOUNT_FIELD_X_WITH_CURRENCY, 190)
_attachment_amount.size = Vector2(AMOUNT_FIELD_WIDTH_WITH_CURRENCY, 46)
_attachment_amount.placeholder_text = "0"
_attachment_amount.text = "0"
_attachment_amount.text_changed.connect(_on_attachment_amount_changed)
page.add_child(_attachment_amount)
_amount_minus = Button.new()
_amount_minus.text = ""
_amount_minus.position = Vector2(688, 190)
_amount_minus.size = Vector2(48, 46)
_amount_minus.position = Vector2(ATTACHMENT_COLUMN_X, 190)
_amount_minus.size = Vector2(44, 46)
_amount_minus.pressed.connect(_step_attachment_amount.bind(-1))
page.add_child(_amount_minus)
_amount_plus = Button.new()
_amount_plus.text = "+"
_amount_plus.position = Vector2(920, 190)
_amount_plus.position = Vector2(734, 190)
_amount_plus.size = Vector2(48, 46)
_amount_plus.pressed.connect(_step_attachment_amount.bind(1))
page.add_child(_amount_plus)
_attachment_summary = RichTextLabel.new()
_attachment_summary.position = Vector2(688, 246)
_attachment_summary.size = Vector2(280, 78)
_attachment_summary.position = Vector2(ATTACHMENT_COLUMN_X, 246)
_attachment_summary.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 78)
_attachment_summary.bbcode_enabled = true
_attachment_summary.fit_content = true
_attachment_summary.scroll_active = false
@ -591,14 +598,14 @@ func _build_compose() -> Control:
page.add_child(_attachment_summary)
_compose_cancel = Button.new()
_compose_cancel.text = "cancel"
_compose_cancel.position = Vector2(688, 342)
_compose_cancel.size = Vector2(126, 48)
_compose_cancel.position = Vector2(ATTACHMENT_COLUMN_X, 342)
_compose_cancel.size = Vector2(122, 48)
_compose_cancel.pressed.connect(_show_inbox)
page.add_child(_compose_cancel)
_send_button = Button.new()
_send_button.text = "send letter"
_send_button.position = Vector2(826, 342)
_send_button.size = Vector2(142, 48)
_send_button.position = Vector2(646, 342)
_send_button.size = Vector2(136, 48)
_send_button.pressed.connect(_send)
page.add_child(_send_button)
_recipient.item_selected.connect(func(_i: int) -> void:
@ -613,12 +620,12 @@ func _build_letter() -> Control:
var page := Control.new()
_letter_text = Label.new()
_letter_text.position = Vector2(26, 20)
_letter_text.size = Vector2(680, 350)
_letter_text.size = Vector2(500, 350)
_letter_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_letter_text.vertical_alignment = VERTICAL_ALIGNMENT_TOP
page.add_child(_letter_text)
_letter_gift = RichTextLabel.new()
_letter_gift.position = Vector2(730, 46)
_letter_gift.position = Vector2(552, 46)
_letter_gift.size = Vector2(230, 180)
_letter_gift.bbcode_enabled = true
_letter_gift.fit_content = true
@ -627,7 +634,7 @@ func _build_letter() -> Control:
page.add_child(_letter_gift)
_accept = Button.new()
_accept.text = "accept gift"
_accept.position = Vector2(730, 250)
_accept.position = Vector2(552, 250)
_accept.size = Vector2(230, 48)
_accept.pressed.connect(func() -> void:
_service.accept_gift(_current_mail_id)
@ -635,7 +642,7 @@ func _build_letter() -> Control:
page.add_child(_accept)
_decline = Button.new()
_decline.text = "decline gift"
_decline.position = Vector2(730, 308)
_decline.position = Vector2(552, 308)
_decline.size = Vector2(230, 48)
_decline.pressed.connect(func() -> void:
_service.decline_gift(_current_mail_id)
@ -649,13 +656,13 @@ func _build_letter() -> Control:
page.add_child(_letter_close)
_archive = Button.new()
_archive.text = "archive"
_archive.position = Vector2(550, 370)
_archive.position = Vector2(466, 370)
_archive.size = Vector2(150, 48)
_archive.pressed.connect(_archive_current)
page.add_child(_archive)
_delete = Button.new()
_delete.text = "delete"
_delete.position = Vector2(710, 370)
_delete.position = Vector2(626, 370)
_delete.size = Vector2(150, 48)
_delete.pressed.connect(_delete_current)
page.add_child(_delete)
@ -751,7 +758,7 @@ func _refresh_inbox() -> void:
empty.text = (
"No archived letters." if _showing_archive else "No letters yet."
)
empty.custom_minimum_size = Vector2(1008, 80)
empty.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 80)
empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_inbox_list.add_child(empty)
call_deferred("_refresh_controller_navigation")
@ -779,7 +786,7 @@ func _refresh_inbox() -> void:
first_line.left(72),
" · gift enclosed" if gift else "",
]
button.custom_minimum_size = Vector2(1008, 54)
button.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 54)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.pressed.connect(_open_letter.bind(str(letter["mail_id"])))
UtilityPageStyle.apply_ocean_button(button)
@ -826,8 +833,14 @@ func _refresh_attachment_choices(_index: int) -> void:
_coin_available_heading.visible = currency_selected
_coin_available.visible = currency_selected
_attachment_amount_currency_icon.visible = currency_selected
_attachment_amount.position.x = 770.0 if currency_selected else 746.0
_attachment_amount.size.x = 140.0 if currency_selected else 164.0
_attachment_amount.position.x = (
AMOUNT_FIELD_X_WITH_CURRENCY
if currency_selected else AMOUNT_FIELD_X_PLAIN
)
_attachment_amount.size.x = (
AMOUNT_FIELD_WIDTH_WITH_CURRENCY
if currency_selected else AMOUNT_FIELD_WIDTH_PLAIN
)
match _attachment_kind.selected:
0:
_attachment_choice.add_item("No attachment")

File diff suppressed because it is too large Load diff

View file

@ -72,15 +72,16 @@ unique_name_in_owner = true
visible = false
z_index = 40
layout_mode = 0
offset_left = 150.0
offset_left = 513.0
offset_top = 136.0
offset_right = 664.0
offset_right = 767.0
offset_bottom = 174.0
mouse_filter = 1
theme_override_constants/separation = 6
[node name="CoolerSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(124, 38)
layout_mode = 2
toggle_mode = true
@ -94,12 +95,13 @@ custom_minimum_size = Vector2(124, 38)
layout_mode = 2
toggle_mode = true
button_group = null
text = "Equipment"
text = "Inventory"
script = ExtResource("23_organizer_tab")
palette_index = 2
[node name="ItemsSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(124, 38)
layout_mode = 2
toggle_mode = true
@ -494,10 +496,10 @@ mouse_filter = 1
unique_name_in_owner = true
z_index = 50
layout_mode = 0
offset_left = 122.0
offset_top = 132.0
offset_right = 1018.0
offset_bottom = 574.0
offset_left = 199.0
offset_top = 166.0
offset_right = 1081.0
offset_bottom = 650.0
[node name="BagOuterMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall"]
layout_mode = 2
@ -524,7 +526,7 @@ horizontal_scroll_mode = 0
[node name="BagHost" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin/BagScroll"]
unique_name_in_owner = true
custom_minimum_size = Vector2(788, 358)
custom_minimum_size = Vector2(810, 420)
layout_mode = 2
mouse_filter = 1
@ -553,14 +555,26 @@ text = "your bag is empty"
horizontal_alignment = 1
vertical_alignment = 1
[node name="BagModalBlocker" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"]
unique_name_in_owner = true
visible = false
z_index = 110
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
[node name="BagDetailConstellation" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 984.0
offset_top = 266.0
offset_right = 1256.0
offset_bottom = 570.0
offset_left = 501.0
offset_top = 166.0
offset_right = 779.0
offset_bottom = 650.0
mouse_filter = 2
[node name="BagDetailBubble" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation"]
@ -572,7 +586,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("22_inventory_notepad")
title_text = "bag notes"
title_text = "inventory notes"
[node name="BagDetailLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble"]
layout_mode = 2
@ -601,6 +615,25 @@ theme_override_font_sizes/font_size = 15
horizontal_alignment = 1
autowrap_mode = 2
[node name="BagDetailActions" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
alignment = 1
[node name="BagFavoriteButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout/BagDetailActions" instance=ExtResource("12_ink_action")]
unique_name_in_owner = true
custom_minimum_size = Vector2(104, 50)
layout_mode = 2
text = "favorite"
allow_persistent_mark = true
[node name="BagSellButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout/BagDetailActions" instance=ExtResource("12_ink_action")]
unique_name_in_owner = true
custom_minimum_size = Vector2(104, 50)
layout_mode = 2
text = "sell fish"
[node name="TackleBoxPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
unique_name_in_owner = true
visible = false
@ -613,10 +646,10 @@ mouse_filter = 1
unique_name_in_owner = true
z_index = 50
layout_mode = 0
offset_left = 54.0
offset_left = 199.0
offset_top = 166.0
offset_right = 936.0
offset_bottom = 602.0
offset_right = 1081.0
offset_bottom = 650.0
[node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel"]
layout_mode = 2
@ -667,7 +700,7 @@ layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 28
theme_override_constants/v_separation = 12
columns = 3
columns = 4
[node name="BaitEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleColumns/BaitColumn/BaitBody"]
unique_name_in_owner = true
@ -720,7 +753,7 @@ layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 28
theme_override_constants/v_separation = 12
columns = 3
columns = 4
[node name="LureEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleColumns/LureColumn/LureBody"]
unique_name_in_owner = true
@ -735,13 +768,26 @@ text = "No lures collected."
horizontal_alignment = 1
vertical_alignment = 1
[node name="TackleModalBlocker" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"]
unique_name_in_owner = true
visible = false
z_index = 110
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
[node name="TackleDetailPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"]
unique_name_in_owner = true
visible = false
layout_mode = 0
offset_left = 952.0
offset_left = 501.0
offset_top = 166.0
offset_right = 1230.0
offset_bottom = 602.0
offset_right = 779.0
offset_bottom = 650.0
script = ExtResource("22_inventory_notepad")
title_text = "tackle box notes"
@ -861,9 +907,9 @@ custom_minimum_size = Vector2(140, 44)
[node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
unique_name_in_owner = true
layout_mode = 0
offset_left = 330.0
offset_left = 388.0
offset_top = 44.0
offset_right = 1170.0
offset_right = 1228.0
offset_bottom = 144.0
mouse_filter = 1
script = ExtResource("5_cluster")

317
ui/player_storage.gd Normal file
View file

@ -0,0 +1,317 @@
class_name PlayerStorage
extends Control
const INPUT_OWNER: StringName = &"player_storage"
signal menu_visibility_changed(is_open: bool)
var _player: Player
var _fishing_spot: FishingSpot
var _interaction: PlayerStorageInteraction
var _layout: PlayerInventoryLayout
var _inventory_grid: GeneralInventoryGrid
var _storage_grid: GeneralInventoryGrid
var _inventory_count: Label
var _storage_count: Label
var _feedback: Label
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
func _ready() -> void:
UtilityPageStyle.apply_page(self)
_build_ui()
hide()
func setup(
player: Player,
fishing_spot: FishingSpot,
interaction: PlayerStorageInteraction,
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
) -> void:
_player = player
_fishing_spot = fishing_spot
_interaction = interaction
_layout = layout
_inventory_grid.setup(
layout,
bag,
fish_inventory,
hotbar,
item_catalog,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
_storage_grid.setup(
layout,
bag,
fish_inventory,
hotbar,
item_catalog,
PlayerInventoryLayout.InventoryContainer.STORAGE,
)
_inventory_grid.entry_selected.connect(
_on_entry_selected.bind(
PlayerInventoryLayout.InventoryContainer.STORAGE
)
)
_storage_grid.entry_selected.connect(
_on_entry_selected.bind(
PlayerInventoryLayout.InventoryContainer.INVENTORY
)
)
if _layout != null and not _layout.layout_changed.is_connected(_refresh):
_layout.layout_changed.connect(_refresh)
_refresh()
func open_storage() -> bool:
if (
visible
or _player == null
or _interaction == null
or not _interaction.is_local_player_in_range()
or _fishing_spot == null
or not _fishing_spot.can_open_fishing_shop()
):
return false
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_feedback.text = "select an item to move it · drag to choose a slot"
_refresh()
show()
call_deferred("_focus_first_slot")
menu_visibility_changed.emit(true)
return true
func close_storage(restore_controls: bool = true) -> void:
if not visible:
return
var viewport := get_viewport()
if viewport != null:
viewport.gui_release_focus()
hide()
if _fishing_spot != null:
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
if restore_controls and _player != null:
_player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_enabled)
Input.mouse_mode = _prior_mouse_mode
menu_visibility_changed.emit(false)
func close_for_range_exit() -> void:
close_storage()
func close_for_water_recovery() -> void:
close_storage(false)
func close_for_session_end() -> void:
close_storage(false)
func consume_escape() -> bool:
if not visible:
return false
close_storage()
return true
func _unhandled_input(event: InputEvent) -> void:
if not visible or not event.is_action_pressed("ui_cancel"):
return
close_storage()
get_viewport().set_input_as_handled()
func _on_entry_selected(
kind: int,
identity: StringName,
target_container: int,
) -> void:
if kind < 0 or identity.is_empty() or _layout == null:
return
if _layout.move_entry_to_first_free(kind, identity, target_container):
_feedback.text = (
"moved to storage"
if target_container == PlayerInventoryLayout.InventoryContainer.STORAGE
else "moved to inventory"
)
else:
_feedback.text = (
"storage is full"
if target_container == PlayerInventoryLayout.InventoryContainer.STORAGE
else "inventory is full"
)
func _refresh() -> void:
if _layout == null:
return
_inventory_grid.refresh()
_storage_grid.refresh()
_inventory_count.text = "%d / %d" % [
_layout.get_inventory_count(),
_layout.get_inventory_capacity(),
]
_storage_count.text = "%d / %d" % [
_layout.get_storage_count(),
_layout.get_storage_capacity(),
]
call_deferred("_connect_grid_focus")
func _focus_first_slot() -> void:
var slot := _inventory_grid.get_first_occupied_slot()
if slot == null:
slot = _storage_grid.get_first_occupied_slot()
if slot != null:
slot.grab_focus()
func _connect_grid_focus() -> void:
var inventory_slots := _inventory_grid.get_slots()
var storage_slots := _storage_grid.get_slots()
if inventory_slots.is_empty() or storage_slots.is_empty():
return
var row_count := mini(
ceili(
float(inventory_slots.size())
/ float(PlayerInventoryLayout.INVENTORY_COLUMNS)
),
ceili(
float(storage_slots.size())
/ float(PlayerInventoryLayout.INVENTORY_COLUMNS)
),
)
for row: int in row_count:
var inventory_index := mini(
row * PlayerInventoryLayout.INVENTORY_COLUMNS
+ PlayerInventoryLayout.INVENTORY_COLUMNS - 1,
inventory_slots.size() - 1,
)
var storage_index := mini(
row * PlayerInventoryLayout.INVENTORY_COLUMNS,
storage_slots.size() - 1,
)
inventory_slots[inventory_index].focus_neighbor_right = (
inventory_slots[inventory_index].get_path_to(storage_slots[storage_index])
)
storage_slots[storage_index].focus_neighbor_left = (
storage_slots[storage_index].get_path_to(inventory_slots[inventory_index])
)
func _build_ui() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
z_index = 82
mouse_filter = Control.MOUSE_FILTER_STOP
var blocker := ColorRect.new()
blocker.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
blocker.color = Color(0.02, 0.08, 0.10, 0.55)
blocker.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(blocker)
var panel := PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.position = Vector2(-580.0, -310.0)
panel.size = Vector2(1160.0, 620.0)
panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_DEEP, 22),
)
add_child(panel)
var outer := MarginContainer.new()
for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
outer.add_theme_constant_override(side, 20)
panel.add_child(outer)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 12)
outer.add_child(layout)
var header := HBoxContainer.new()
layout.add_child(header)
var title := Label.new()
title.text = "private storage"
title.add_theme_font_size_override("font_size", 32)
title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
var close := Button.new()
close.text = "×"
close.tooltip_text = "Close storage"
close.custom_minimum_size = Vector2(48.0, 48.0)
close.flat = true
close.add_theme_font_size_override("font_size", 30)
close.pressed.connect(close_storage)
header.add_child(close)
var columns := HBoxContainer.new()
columns.size_flags_vertical = Control.SIZE_EXPAND_FILL
columns.add_theme_constant_override("separation", 18)
layout.add_child(columns)
var inventory_column := _build_column(columns, "inventory")
_inventory_count = inventory_column["count"]
_inventory_grid = GeneralInventoryGrid.new()
(inventory_column["scroll"] as ScrollContainer).add_child(_inventory_grid)
var storage_column := _build_column(columns, "storage")
_storage_count = storage_column["count"]
_storage_grid = GeneralInventoryGrid.new()
(storage_column["scroll"] as ScrollContainer).add_child(_storage_grid)
_feedback = Label.new()
_feedback.text = "select an item to move it · drag to choose a slot"
_feedback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_feedback.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
layout.add_child(_feedback)
func _build_column(parent: HBoxContainer, title_text: String) -> Dictionary:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(550.0, 0.0)
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_MID, 16),
)
parent.add_child(panel)
var margin := MarginContainer.new()
for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
margin.add_theme_constant_override(side, 12)
panel.add_child(margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 8)
margin.add_child(column)
var header := HBoxContainer.new()
column.add_child(header)
var title := Label.new()
title.text = title_text
title.add_theme_font_size_override("font_size", 22)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
var count := Label.new()
count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
count.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
header.add_child(count)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(scroll)
return {"count": count, "scroll": scroll}

1
ui/player_storage.gd.uid Normal file
View file

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

14
ui/player_storage.tscn Normal file
View file

@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/player_storage.gd" id="1_script"]
[node name="PlayerStorage" type="Control"]
unique_name_in_owner = true
visible = false
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_script")

View file

@ -191,7 +191,10 @@ func _build() -> void:
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
root.add_child(scroll)
_list = VBoxContainer.new()
_list.custom_minimum_size = Vector2(1032, 0)
_list.custom_minimum_size = Vector2(
UtilityPageStyle.LAPTOP_CONTENT_SIZE.x,
0.0,
)
_list.add_theme_constant_override("separation", 7)
scroll.add_child(_list)
_status = Label.new()
@ -207,18 +210,22 @@ func _build_host_settings(root: VBoxContainer) -> void:
"panel", UtilityPageStyle.row_style(false)
)
root.add_child(_host_settings_panel)
var row := HBoxContainer.new()
row.custom_minimum_size.y = 58.0
row.add_theme_constant_override("separation", 10)
_host_settings_panel.add_child(row)
var settings := VBoxContainer.new()
settings.custom_minimum_size.y = 104.0
settings.add_theme_constant_override("separation", 8)
_host_settings_panel.add_child(settings)
var identity_row := HBoxContainer.new()
identity_row.add_theme_constant_override("separation", 10)
settings.add_child(identity_row)
var label := Label.new()
label.text = "room name"
label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
row.add_child(label)
identity_row.add_child(label)
_room_name_edit = LineEdit.new()
_room_name_edit.custom_minimum_size = Vector2(300.0, 42.0)
_room_name_edit.custom_minimum_size = Vector2(0.0, 42.0)
_room_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_room_name_edit.max_length = DiscoveryClient.MAX_ROOM_NAME_LENGTH
_room_name_edit.placeholder_text = "Player Name's Server"
UtilityPageStyle.apply_ocean_line_edit(_room_name_edit)
@ -226,11 +233,14 @@ func _build_host_settings(root: VBoxContainer) -> void:
func(_value: String) -> void: _commit_room_name()
)
_room_name_edit.focus_exited.connect(_commit_room_name)
row.add_child(_room_name_edit)
_online_toggle = _build_host_toggle("online", row)
identity_row.add_child(_room_name_edit)
var access_row := HBoxContainer.new()
access_row.add_theme_constant_override("separation", 10)
settings.add_child(access_row)
_online_toggle = _build_host_toggle("online", access_row)
_online_state_label = _online_toggle.get_node("StateBadge/State") as Label
_online_toggle.pressed.connect(_on_online_pressed)
_discoverable_toggle = _build_host_toggle("discovery", row)
_discoverable_toggle = _build_host_toggle("discovery", access_row)
_discoverable_state_label = (
_discoverable_toggle.get_node("StateBadge/State") as Label
)
@ -244,12 +254,12 @@ func _build_host_settings(root: VBoxContainer) -> void:
_host_discovery_status.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
row.add_child(_host_discovery_status)
access_row.add_child(_host_discovery_status)
func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button:
var button := Button.new()
button.custom_minimum_size = Vector2(150.0, 46.0)
button.custom_minimum_size = Vector2(140.0, 46.0)
button.text = label_text
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
button.clip_contents = false
@ -448,7 +458,8 @@ func _build_active_rows() -> void:
for entry: PlayerListEntry in entries:
var row := _make_row()
var identity := Label.new()
identity.custom_minimum_size.x = 430
identity.custom_minimum_size.x = 250
identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var markers: Array[String] = []
if entry.is_host:
markers.append("host")
@ -470,7 +481,7 @@ func _build_active_rows() -> void:
)
row.add_child(identity)
var ping := Label.new()
ping.custom_minimum_size.x = 80
ping.custom_minimum_size.x = 60
ping.text = (
"Local" if entry.is_host
else "%d ms" % entry.ping_to_host_ms
@ -484,31 +495,31 @@ func _build_active_rows() -> void:
mute.text = "unmute" if entry.muted else "mute"
mute.disabled = entry.is_local_player
mute.pressed.connect(_toggle_mute.bind(entry))
UtilityPageStyle.apply_ocean_button(mute)
UtilityPageStyle.apply_compact_ocean_button(mute)
row.add_child(mute)
var block := Button.new()
block.text = "block"
block.disabled = entry.is_local_player
block.pressed.connect(_confirm_block.bind(entry))
UtilityPageStyle.apply_ocean_button(block)
UtilityPageStyle.apply_compact_ocean_button(block)
row.add_child(block)
if entry.can_manage_operator:
var operator := Button.new()
operator.text = "deop" if entry.is_operator else "op"
operator.pressed.connect(_confirm_operator.bind(entry))
UtilityPageStyle.apply_ocean_button(operator)
UtilityPageStyle.apply_compact_ocean_button(operator)
row.add_child(operator)
var kick := Button.new()
kick.text = "kick"
kick.disabled = not entry.can_kick
kick.pressed.connect(_confirm_kick.bind(entry))
UtilityPageStyle.apply_ocean_button(kick)
UtilityPageStyle.apply_compact_ocean_button(kick)
row.add_child(kick)
var ban := Button.new()
ban.text = "ban"
ban.disabled = not entry.can_ban
ban.pressed.connect(_confirm_ban.bind(entry))
UtilityPageStyle.apply_ocean_button(ban)
UtilityPageStyle.apply_compact_ocean_button(ban)
row.add_child(ban)
@ -516,7 +527,8 @@ func _build_session_artwork_controls() -> void:
var counts: Vector2i = _service.get_session_artwork_counts()
var row := _make_row()
var label := Label.new()
label.custom_minimum_size.x = 830
label.custom_minimum_size.x = 600
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text = "session artwork · %d layers · %d painted pixels" % [
counts.x, counts.y,
]
@ -534,7 +546,7 @@ func _build_session_artwork_controls() -> void:
_service.reset_session_artwork,
)
)
UtilityPageStyle.apply_ocean_button(reset)
UtilityPageStyle.apply_compact_ocean_button(reset)
row.add_child(reset)
@ -546,7 +558,8 @@ func _build_relationship_rows() -> void:
for record: Dictionary in records:
var row := _make_row()
var label := Label.new()
label.custom_minimum_size.x = 690
label.custom_minimum_size.x = 560
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var fingerprint := str(record["fingerprint"])
label.text = "%s · %s %s" % [
str(record.get("last_known_display_name", "Player")),
@ -564,7 +577,7 @@ func _build_relationship_rows() -> void:
unblock.pressed.connect(func() -> void:
_service.set_blocked(fingerprint, str(record["last_known_display_name"]), false)
)
UtilityPageStyle.apply_ocean_button(unblock)
UtilityPageStyle.apply_compact_ocean_button(unblock)
row.add_child(unblock)
var unmute := Button.new()
unmute.text = "unmute"
@ -572,7 +585,7 @@ func _build_relationship_rows() -> void:
unmute.pressed.connect(func() -> void:
_service.set_muted(fingerprint, str(record["last_known_display_name"]), false)
)
UtilityPageStyle.apply_ocean_button(unmute)
UtilityPageStyle.apply_compact_ocean_button(unmute)
row.add_child(unmute)
@ -585,7 +598,8 @@ func _build_ban_rows() -> void:
var row := _make_row()
var fingerprint := str(record["target_fingerprint"])
var label := Label.new()
label.custom_minimum_size.x = 830
label.custom_minimum_size.x = 600
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.text = "%s · %s banned %s" % [
str(record.get("last_known_display_name", "Player")),
NetworkIdentityCrypto.compact_suffix(fingerprint),
@ -599,7 +613,7 @@ func _build_ban_rows() -> void:
var unban := Button.new()
unban.text = "unban"
unban.pressed.connect(_confirm_unban.bind(fingerprint))
UtilityPageStyle.apply_ocean_button(unban)
UtilityPageStyle.apply_compact_ocean_button(unban)
row.add_child(unban)

View file

@ -3,13 +3,16 @@ extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4
const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08
const OPTION_GRID_COLUMNS: int = 6
const FUR_PALETTE_GRID_COLUMNS: int = 10
const FUR_PATTERN_GRID_COLUMNS: int = 4
const OPTION_GRID_COLUMNS: int = 3
const FUR_PALETTE_GRID_COLUMNS: int = 6
const FUR_PATTERN_GRID_COLUMNS: int = 2
const FUR_CHANNEL_GRID_COLUMNS: int = 2
const FUR_PALETTE_SWATCH_SIZE: float = 38.0
const FUR_CHANNEL_SWATCH_SIZE: float = 22.0
const FUR_PART_TAB_WIDTH: float = 58.0
const FUR_COLOR_PICKER_POPUP_SIZE: Vector2i = Vector2i(720, 560)
const FUR_COLOR_PICKER_SV_SIZE: Vector2i = Vector2i(520, 300)
const VOICE_OPTION_BUTTON_SIZE: Vector2 = Vector2(68.0, 32.0)
const FUR_SECTION_PATTERNS: String = "patterns"
const FUR_SECTION_COLORS: String = "colors"
const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16
@ -722,7 +725,7 @@ func _build_ui() -> void:
_category_list.add_theme_constant_override("separation", 5)
category_scroll.add_child(_category_list)
_option_list = VBoxContainer.new()
_option_list.custom_minimum_size = Vector2(360, 0)
_option_list.custom_minimum_size = Vector2(326, 0)
_option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_option_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
_option_list.add_theme_constant_override("separation", 5)
@ -732,13 +735,13 @@ func _build_ui() -> void:
body_spacer.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
body.add_child(body_spacer)
var preview_stack := VBoxContainer.new()
preview_stack.custom_minimum_size.x = 260.0
preview_stack.custom_minimum_size.x = 240.0
preview_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER
preview_stack.add_theme_constant_override("separation", 7)
body.add_child(preview_stack)
var preview_frame := PanelContainer.new()
preview_frame.custom_minimum_size = Vector2(260.0, 0.0)
preview_frame.custom_minimum_size = Vector2(240.0, 0.0)
preview_frame.size_flags_vertical = Control.SIZE_EXPAND_FILL
preview_frame.add_theme_stylebox_override(
"panel", UtilityPageStyle.rounded_style(
@ -752,7 +755,7 @@ func _build_ui() -> void:
preview_layer.mouse_filter = Control.MOUSE_FILTER_PASS
preview_frame.add_child(preview_layer)
_preview = preload("res://ui/profile_preview.tscn").instantiate()
_preview.custom_minimum_size = Vector2(260.0, 0.0)
_preview.custom_minimum_size = Vector2(240.0, 0.0)
_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL
preview_layer.add_child(_preview)
@ -911,7 +914,7 @@ func _build_voice_options() -> void:
var settings_grid := GridContainer.new()
settings_grid.name = "VoiceSettingsGrid"
settings_grid.columns = 2
settings_grid.add_theme_constant_override("h_separation", 10)
settings_grid.add_theme_constant_override("h_separation", 8)
settings_grid.add_theme_constant_override("v_separation", 8)
settings_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_option_list.add_child(settings_grid)
@ -943,7 +946,7 @@ func _build_voice_options() -> void:
sample_set_button.text = str(option.get("label", sample_set_id))
sample_set_button.toggle_mode = true
sample_set_button.button_group = sample_set_group
sample_set_button.custom_minimum_size = Vector2(108.0, 32.0)
sample_set_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE
sample_set_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sample_set_button.button_pressed = (
_draft_sample_set_id == sample_set_id
@ -951,7 +954,7 @@ func _build_voice_options() -> void:
sample_set_button.pressed.connect(
_select_sample_set_option.bind(sample_set_id)
)
UtilityPageStyle.apply_compact_ocean_button(sample_set_button)
_apply_voice_option_button(sample_set_button)
sample_set_grid.add_child(sample_set_button)
var pitch_title := Label.new()
pitch_title.text = "pitch"
@ -976,11 +979,11 @@ func _build_voice_options() -> void:
button.text = str(option.get("label", option_id))
button.toggle_mode = true
button.button_group = pitch_group
button.custom_minimum_size = Vector2(108.0, 32.0)
button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.button_pressed = _draft_voice_id == option_id
button.pressed.connect(_select_voice_option.bind(option_id))
UtilityPageStyle.apply_compact_ocean_button(button)
_apply_voice_option_button(button)
grid.add_child(button)
var speed_title := Label.new()
speed_title.text = "playback speed"
@ -1008,13 +1011,13 @@ func _build_voice_options() -> void:
)
speed_button.toggle_mode = true
speed_button.button_group = speed_group
speed_button.custom_minimum_size = Vector2(108.0, 32.0)
speed_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE
speed_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
speed_button.button_pressed = _draft_speech_speed_id == speed_id
speed_button.pressed.connect(
_select_speech_speed_option.bind(speed_id)
)
UtilityPageStyle.apply_compact_ocean_button(speed_button)
_apply_voice_option_button(speed_button)
speed_grid.add_child(speed_button)
var call_title := Label.new()
call_title.text = "call (G)"
@ -1039,14 +1042,29 @@ func _build_voice_options() -> void:
call_button.text = str(option.get("label", call_id))
call_button.toggle_mode = true
call_button.button_group = call_group
call_button.custom_minimum_size = Vector2(108.0, 32.0)
call_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE
call_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
call_button.button_pressed = _draft_call_id == call_id
call_button.pressed.connect(_select_call_option.bind(call_id))
UtilityPageStyle.apply_compact_ocean_button(call_button)
_apply_voice_option_button(call_button)
call_grid.add_child(call_button)
func _apply_voice_option_button(button: Button) -> void:
UtilityPageStyle.apply_compact_ocean_button(button)
button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE
button.add_theme_font_size_override("font_size", 12)
for state: StringName in [
&"normal", &"hover", &"pressed", &"focus", &"disabled",
]:
var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat
if style == null:
continue
style.content_margin_left = 6.0
style.content_margin_right = 6.0
button.add_theme_stylebox_override(state, style)
func _select_sample_set_option(sample_set_id: String) -> void:
if not VoiceProfilesType.is_valid_sample_set(sample_set_id):
return
@ -1473,7 +1491,7 @@ func _build_fur_pattern_options() -> void:
var part_tabs := HBoxContainer.new()
part_tabs.name = "FurPatternPartTabs"
part_tabs.custom_minimum_size.y = 38.0
part_tabs.add_theme_constant_override("separation", 4)
part_tabs.add_theme_constant_override("separation", 2)
part_tab_margin.add_child(part_tabs)
for style_index: int in range(
CharacterCustomizationCatalog.FUR_STYLE_IDS.size()
@ -1486,7 +1504,7 @@ func _build_fur_pattern_options() -> void:
style_field
)
part_tab.palette_index = mini(style_index, 2)
part_tab.custom_minimum_size = Vector2(90.0, 38.0)
part_tab.custom_minimum_size = Vector2(FUR_PART_TAB_WIDTH, 38.0)
part_tab.focus_mode = Control.FOCUS_ALL
part_tab.mouse_filter = Control.MOUSE_FILTER_STOP
part_tab.pressed.connect(_select_fur_pattern_part.bind(style_field))
@ -1586,7 +1604,7 @@ func _build_fur_color_channels(options: Array) -> void:
var channel_grid := GridContainer.new()
channel_grid.name = "FurColorChannelGrid"
channel_grid.columns = CharacterCustomizationCatalog.FUR_COLOR_IDS.size()
channel_grid.columns = FUR_CHANNEL_GRID_COLUMNS
channel_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
channel_grid.add_theme_constant_override("h_separation", 10)
channel_grid.add_theme_constant_override("v_separation", 8)

366
ui/shop_sell_inventory.gd Normal file
View file

@ -0,0 +1,366 @@
class_name ShopSellInventory
extends Control
const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop"
var _layout: PlayerInventoryLayout
var _bag: PlayerBag
var _fish_inventory: FishInventory
var _item_catalog: ItemCatalog
var _buyer: FishBuyerProfile
var _reservations: PlayerAssetReservationService
var _network_sale: NetworkSaleService
var _inventory_grid: GeneralInventoryGrid
var _tray_grid: GridContainer
var _total_label: Label
var _feedback: Label
var _sell_button: Button
var _staged: Dictionary[String, Dictionary] = {}
func _ready() -> void:
UtilityPageStyle.apply_page(self)
_build_ui()
func setup(
layout: PlayerInventoryLayout,
bag: PlayerBag,
fish_inventory: FishInventory,
hotbar: PlayerHotbar,
item_catalog: ItemCatalog,
buyer: FishBuyerProfile,
reservations: PlayerAssetReservationService,
network_sale: NetworkSaleService,
) -> void:
_layout = layout
_bag = bag
_fish_inventory = fish_inventory
_item_catalog = item_catalog
_buyer = buyer
_reservations = reservations
_network_sale = network_sale
_inventory_grid.setup(
layout,
bag,
fish_inventory,
hotbar,
item_catalog,
PlayerInventoryLayout.InventoryContainer.INVENTORY,
)
_inventory_grid.entry_selected.connect(_on_inventory_entry_selected)
if _layout != null:
_layout.layout_changed.connect(_refresh)
if _network_sale != null:
_network_sale.local_sale_pending.connect(_on_sale_pending)
_network_sale.local_sale_finished.connect(_on_sale_finished)
_refresh()
func activate() -> void:
_feedback.text = "select or drag items into the sell tray"
_refresh()
var slot := _inventory_grid.get_first_occupied_slot()
if slot != null:
slot.grab_focus()
func clear_staged() -> void:
_staged.clear()
_refresh_tray()
func _on_inventory_entry_selected(kind: int, identity: StringName) -> void:
_stage(kind, identity)
func _stage(kind: int, identity: StringName) -> void:
if identity.is_empty():
return
var key := (
PlayerInventoryLayout.catch_key(identity)
if kind == PlayerInventoryLayout.EntryKind.CATCH
else PlayerInventoryLayout.item_key(identity)
)
if _staged.has(key):
_staged.erase(key)
_refresh_tray()
return
if kind == PlayerInventoryLayout.EntryKind.CATCH:
var fish_catch := _fish_inventory.get_catch_by_id(identity)
if fish_catch == null:
_feedback.text = "that catch is no longer available"
return
if fish_catch.is_favorited:
_feedback.text = "favorite catches cannot be sold"
return
if _reservations != null and _reservations.is_fish_reserved(identity):
_feedback.text = "reserved in a letter"
return
_staged[key] = {"kind": kind, "identity": identity, "quantity": 1}
else:
var item := _item_catalog.get_item_by_id(identity)
if not ItemResalePolicy.is_sellable(item):
_feedback.text = "equipment cannot be sold"
return
var available := _bag.get_quantity(identity)
if _reservations != null:
available = _reservations.get_available_item_quantity(identity)
if available < 1:
_feedback.text = "that item is reserved"
return
_staged[key] = {
"kind": kind,
"identity": identity,
"quantity": available,
}
_feedback.text = ""
_refresh_tray()
func _on_drop_payload(payload: Dictionary) -> void:
var payload_kind := str(payload.get("kind", ""))
_stage(
PlayerInventoryLayout.EntryKind.CATCH
if payload_kind == "cooler_fish"
else PlayerInventoryLayout.EntryKind.ITEM,
StringName(str(payload.get(
"catch_id" if payload_kind == "cooler_fish" else "item_id", ""
))),
)
func _on_remove_requested(key: String) -> void:
_staged.erase(key)
_refresh_tray()
func _submit_sale() -> void:
if _staged.is_empty() or _network_sale == null:
return
var catch_ids: Array[StringName] = []
var items: Dictionary[StringName, int] = {}
for record: Dictionary in _staged.values():
var identity := StringName(str(record.get("identity", "")))
if int(record.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH:
catch_ids.append(identity)
else:
items[identity] = int(record.get("quantity", 0))
if _network_sale.request_local_mixed_sale(
catch_ids, items, MAIN_SHOP_BUYER_ID
).is_empty():
_feedback.text = "sale could not be started"
func _on_sale_pending(_request_id: String) -> void:
_sell_button.disabled = true
_feedback.text = "selling…"
func _on_sale_finished(
_request_id: String,
accepted: bool,
message: String,
_catch_ids: Array[StringName],
_payout: int,
) -> void:
if accepted:
_staged.clear()
_feedback.text = message.to_lower()
_refresh()
func _refresh() -> void:
_validate_staged()
_inventory_grid.refresh()
_refresh_tray()
func _validate_staged() -> void:
for key: String in _staged.keys():
var record: Dictionary = _staged[key]
var identity := StringName(str(record.get("identity", "")))
if int(record.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH:
if (
_fish_inventory.get_catch_by_id(identity) == null
or not _layout.is_catch_in_inventory(identity)
):
_staged.erase(key)
elif (
_bag.get_quantity(identity) < int(record.get("quantity", 0))
or not _layout.is_item_in_inventory(identity)
):
_staged.erase(key)
func _refresh_tray() -> void:
_refresh_inventory_staged_highlights()
for child: Node in _tray_grid.get_children():
child.queue_free()
var keys: Array[String] = []
keys.assign(_staged.keys())
keys.sort()
for key: String in keys:
var record: Dictionary = _staged[key]
var identity := StringName(str(record["identity"]))
var icon: Texture2D
var label: String
if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH:
var fish_catch := _fish_inventory.get_catch_by_id(identity)
if fish_catch == null:
continue
icon = fish_catch.fish.display_texture
label = fish_catch.fish.display_name
else:
var item := _item_catalog.get_item_by_id(identity)
if item == null:
continue
icon = item.icon
label = item.display_name
var slot := ShopSaleTraySlot.new()
_tray_grid.add_child(slot)
slot.configure(key, icon, label, int(record.get("quantity", 1)))
slot.remove_requested.connect(_on_remove_requested)
slot.drop_requested.connect(_on_drop_payload)
var drop_slot := ShopSaleTraySlot.new()
_tray_grid.add_child(drop_slot)
drop_slot.configure("", null, "drop an item here")
drop_slot.disabled = false
drop_slot.drop_requested.connect(_on_drop_payload)
var total := _calculate_total()
_total_label.text = str(total)
_sell_button.disabled = (
_staged.is_empty()
or total < 0
or _network_sale == null
or _network_sale.is_local_sale_pending()
)
call_deferred("_configure_focus")
func _refresh_inventory_staged_highlights() -> void:
if _inventory_grid == null:
return
for slot: GeneralInventorySlot in _inventory_grid.get_slots():
var key: String = ""
if not slot.entry_identity.is_empty():
key = (
PlayerInventoryLayout.catch_key(slot.entry_identity)
if slot.entry_kind == PlayerInventoryLayout.EntryKind.CATCH
else PlayerInventoryLayout.item_key(slot.entry_identity)
)
slot.set_staged(not key.is_empty() and _staged.has(key))
func _configure_focus() -> void:
var controls: Array[Control] = []
controls.assign(_inventory_grid.get_slots())
for child: Node in _tray_grid.get_children():
var control := child as Control
if control != null and control.focus_mode != Control.FOCUS_NONE:
controls.append(control)
controls.append(_sell_button)
ControllerFocusNavigation.configure_spatial_neighbors(controls)
func _calculate_total() -> int:
var total: int = 0
for record: Dictionary in _staged.values():
var identity := StringName(str(record["identity"]))
if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH:
var fish_catch := _fish_inventory.get_catch_by_id(identity)
if fish_catch == null:
return -1
total += _buyer.get_quality_offer(
fish_catch.fish.get_sale_value_for_weight(fish_catch.weight_lb),
fish_catch.quality,
)
else:
var item := _item_catalog.get_item_by_id(identity)
var unit_value := ItemResalePolicy.get_unit_value(item)
if unit_value < 0:
return -1
total += unit_value * int(record.get("quantity", 0))
return total
func _build_ui() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var outer := MarginContainer.new()
outer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
outer.add_theme_constant_override("margin_left", 68)
outer.add_theme_constant_override("margin_top", 92)
outer.add_theme_constant_override("margin_right", 68)
outer.add_theme_constant_override("margin_bottom", 44)
add_child(outer)
var columns := HBoxContainer.new()
columns.add_theme_constant_override("separation", 20)
outer.add_child(columns)
var source := _build_panel(columns, "inventory")
_inventory_grid = GeneralInventoryGrid.new()
(source as VBoxContainer).add_child(_inventory_grid)
var tray := _build_panel(columns, "sell tray")
var tray_scroll := ScrollContainer.new()
tray_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
tray_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
(tray as VBoxContainer).add_child(tray_scroll)
_tray_grid = GridContainer.new()
_tray_grid.columns = PlayerInventoryLayout.INVENTORY_COLUMNS
_tray_grid.add_theme_constant_override(
"h_separation", GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION
)
_tray_grid.add_theme_constant_override(
"v_separation", GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION
)
tray_scroll.add_child(_tray_grid)
_feedback = Label.new()
_feedback.text = "select or drag items into the sell tray"
_feedback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_feedback.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
(tray as VBoxContainer).add_child(_feedback)
var total_row := HBoxContainer.new()
(tray as VBoxContainer).add_child(total_row)
var total_title := Label.new()
total_title.text = "total"
total_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
total_row.add_child(total_title)
var coin := TextureRect.new()
coin.custom_minimum_size = Vector2(24.0, 24.0)
coin.texture = preload("res://items/icons/shop/32_currency.png")
coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
coin.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
total_row.add_child(coin)
_total_label = Label.new()
_total_label.text = "0"
total_row.add_child(_total_label)
_sell_button = Button.new()
_sell_button.text = "sell"
UtilityPageStyle.apply_ocean_button(_sell_button)
_sell_button.pressed.connect(_submit_sale)
(tray as VBoxContainer).add_child(_sell_button)
func _build_panel(parent: HBoxContainer, title_text: String) -> VBoxContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_MID, 16),
)
parent.add_child(panel)
var margin := MarginContainer.new()
for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
margin.add_theme_constant_override(side, 12)
panel.add_child(margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 8)
margin.add_child(column)
var title := Label.new()
title.text = title_text
title.add_theme_font_size_override("font_size", 22)
column.add_child(title)
return column

View file

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

View file

@ -26,7 +26,8 @@ const GREEN: Color = Color("31594d")
const LIGHT_TEXT: Color = Color("f5eed9")
const DISABLED_TEXT: Color = Color(0.33, 0.36, 0.35, 0.72)
const MOTION_TWEEN_META: StringName = &"utility_page_motion_tween"
const LAPTOP_RECT: Rect2 = Rect2(66.0, 132.0, 1148.0, 520.0)
const LAPTOP_RECT: Rect2 = Rect2(199.0, 132.0, 882.0, 520.0)
const LAPTOP_CONTENT_SIZE: Vector2 = Vector2(794.0, 442.0)
const SUPPLY_BADGE_MIN_WIDTH: float = 46.0
const SUPPLY_BADGE_HEIGHT: float = 24.0
const SUPPLY_BADGE_EDGE_MARGIN: float = 3.0

View file

@ -0,0 +1,114 @@
@tool
class_name DiggableArea3D
extends Node3D
@export var area_id: StringName
@export_node_path("Node3D") var terrain_source: NodePath
@export var surface_materials: Array[StringName] = []
@export var generation_bounds := Rect2(-50.0, -50.0, 100.0, 100.0)
@export_range(-100.0, 100.0, 0.01) var minimum_global_y: float = -100.0
@export_range(-100.0, 100.0, 0.01) var maximum_global_y: float = 100.0
@export_range(0.0, 1.0, 0.01) var minimum_up_dot: float = 0.6
func get_surface_triangles() -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
if area_id.is_empty() or surface_materials.is_empty():
return triangles
var terrain_root: Node = get_node_or_null(terrain_source)
if terrain_root == null:
return triangles
for mesh_instance: MeshInstance3D in _collect_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 surface_materials.has(StringName(material.resource_name))
):
continue
_append_surface_triangles(
triangles,
mesh_instance,
mesh.surface_get_arrays(surface_index),
)
return triangles
func _append_surface_triangles(
result: Array[PackedVector3Array],
mesh_instance: MeshInstance3D,
arrays: Array,
) -> void:
if arrays.size() <= Mesh.ARRAY_INDEX:
return
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if vertices.is_empty():
return
if indices.is_empty():
for vertex_index: int in range(0, vertices.size() - 2, 3):
_append_triangle(
result,
mesh_instance.to_global(vertices[vertex_index]),
mesh_instance.to_global(vertices[vertex_index + 1]),
mesh_instance.to_global(vertices[vertex_index + 2]),
)
return
for index_offset: int in range(0, indices.size() - 2, 3):
_append_triangle(
result,
mesh_instance.to_global(vertices[indices[index_offset]]),
mesh_instance.to_global(vertices[indices[index_offset + 1]]),
mesh_instance.to_global(vertices[indices[index_offset + 2]]),
)
func _append_triangle(
result: Array[PackedVector3Array],
a: Vector3,
b: Vector3,
c: Vector3,
) -> void:
if (
a.y < minimum_global_y
or b.y < minimum_global_y
or c.y < minimum_global_y
or a.y > maximum_global_y
or b.y > maximum_global_y
or c.y > maximum_global_y
):
return
var center := (a + b + c) / 3.0
if not generation_bounds.has_point(Vector2(center.x, center.z)):
return
var cross := (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 _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var meshes: Array[MeshInstance3D] = []
if root is MeshInstance3D:
meshes.append(root as MeshInstance3D)
for child: Node in root.get_children():
meshes.append_array(_collect_mesh_instances(child))
return meshes
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
if area_id.is_empty():
warnings.append("Diggable area ID is required.")
if get_node_or_null(terrain_source) == null:
warnings.append("Diggable area terrain source is unavailable.")
if surface_materials.is_empty():
warnings.append("At least one terrain material is required.")
if maximum_global_y < minimum_global_y:
warnings.append("Maximum height must not be below minimum height.")
return warnings

View file

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

View file

@ -26,6 +26,65 @@ uniform float moon_edge_softness : hint_range(0.001, 0.03) = 0.003;
uniform float moon_halo_radius : hint_range(0.02, 0.35) = 0.11;
uniform float moon_halo_strength : hint_range(0.0, 1.0) = 0.12;
uniform vec4 star_color : source_color = vec4(0.72, 0.82, 0.96, 1.0);
uniform float star_visibility : hint_range(0.0, 1.0) = 0.0;
uniform float star_strength : hint_range(0.0, 1.0) = 0.72;
float star_hash(vec2 point) {
vec3 value = fract(vec3(point.xyx) * vec3(0.1031, 0.1030, 0.0973));
value += dot(value, value.yzx + 33.33);
return fract((value.x + value.y) * value.z);
}
vec2 star_hash2(vec2 point) {
return vec2(
star_hash(point),
star_hash(point + vec2(19.19, 7.73))
);
}
float procedural_star_field(vec3 view_direction) {
// Map the upper hemisphere to one stable world-direction diamond. This
// avoids a longitude seam and keeps the stars fixed as the camera moves.
float direction_sum = max(
abs(view_direction.x)
+ abs(view_direction.y)
+ abs(view_direction.z),
0.0001
);
vec2 star_uv = view_direction.xz / direction_sum * 0.5 + 0.5;
vec2 star_point = star_uv * 190.0;
vec2 cell = floor(star_point);
vec2 local_point = fract(star_point);
float spawn_roll = star_hash(cell + vec2(3.17, 11.83));
vec2 point_center = mix(
vec2(0.18),
vec2(0.82),
star_hash2(cell + vec2(41.0, 73.0))
);
float point_radius = mix(
0.065,
0.13,
star_hash(cell + vec2(89.0, 17.0))
);
float point_shape = 1.0 - smoothstep(
point_radius,
point_radius + 0.045,
distance(local_point, point_center)
);
float point_exists = step(0.965, spawn_roll);
float point_brightness = mix(
0.42,
1.0,
smoothstep(0.965, 1.0, spawn_roll)
);
float upper_sky_fade = smoothstep(0.055, 0.22, view_direction.y);
return point_shape * point_exists * point_brightness * upper_sky_fade;
}
void sky() {
vec3 view_direction = normalize(EYEDIR);
@ -50,6 +109,12 @@ void sky() {
view_direction.y
)
);
float stars = procedural_star_field(view_direction);
base_color = mix(
base_color,
star_color.rgb,
stars * star_visibility * star_strength
);
float alignment = dot(view_direction, normalize(sun_direction));
float disc_outer = cos(sun_radius + sun_edge_softness);

Some files were not shown because too many files have changed in this diff Show more