Compare commits

...

14 commits

90 changed files with 8664 additions and 261 deletions

View file

@ -22,9 +22,9 @@ Discord: https://discord.gg/5gP22447kc
Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io Matrix: https://matrix.to/#/#straywild:matrix.makearmy.io
Repo: https://forge.makearmy.io/woofmeow/netfishing Repo: https://forge.makearmy.io/woofmeow/straywild
Releases: https://forge.makearmy.io/woofmeow/netfishing/releases Releases: https://forge.makearmy.io/woofmeow/straywild/releases
## Installing release builds ## Installing release builds

View file

@ -142,8 +142,10 @@ environment and UI artwork, item artwork, finished fish and creature artwork,
and character customization pattern and channel-map artwork. Voyager's and character customization pattern and channel-map artwork. Voyager's
incorporated original work includes 3D assets, the provisional fish incorporated original work includes 3D assets, the provisional fish
placeholder artwork, the title and dusk music identified above, and the robot placeholder artwork, the title and dusk music identified above, and the robot
animalese tones. Specific contributor exceptions are recorded below and in the animalese tones. The current starter motorcoach exterior was supplied by a
in-game credits. contributing artist who released its copyright to the project; the original
and cleaned source files are retained in the owners' source-work archive.
Specific contributor exceptions are recorded below and in the in-game credits.
These credits intentionally describe work by creator and contribution family These credits intentionally describe work by creator and contribution family
instead of maintaining a second file-by-file asset catalog. Runtime resources instead of maintaining a second file-by-file asset catalog. Runtime resources

View file

@ -1,8 +1,8 @@
straywild straywild
v0.19.0-alpha v0.20.2-alpha
Alpha 0.19.0 Alpha 0.20.2
Thank you for trying this early private playtest. Thank you for supporting this private testing release.
straywild is an early multiplayer fishing game. This alpha includes local straywild is an early multiplayer fishing game. This alpha includes local
progression, player-hosted and dedicated multiplayer sessions, public room progression, player-hosted and dedicated multiplayer sessions, public room
@ -83,6 +83,8 @@ FEATURES TO TRY
- Fishing, net gathering, and beach digging - Fishing, net gathering, and beach digging
- Equipment and consumable use from the hotbar - Equipment and consumable use from the hotbar
- Fishing Shop sell tray, wallet updates, and persistent upgrades - Fishing Shop sell tray, wallet updates, and persistent upgrades
- Portable RV homes, multiplayer visits, RV storage, and the early decor
purchasing and placement prototype
- Seeded generated worlds and the authored starter-island layout - Seeded generated worlds and the authored starter-island layout
- Generated beaches, ponds, lakes, complete rivers, denser forests, varied - Generated beaches, ponds, lakes, complete rivers, denser forests, varied
elevation, and closed coastal cliffs elevation, and closed coastal cliffs
@ -117,5 +119,6 @@ KNOWN ALPHA LIMITATIONS
networks may prevent some players from joining. networks may prevent some players from joining.
- Multiplayer, dedicated-server deployment, UI, and balance are still alpha. - Multiplayer, dedicated-server deployment, UI, and balance are still alpha.
- Some fish and character artwork remains temporary. - Some fish and character artwork remains temporary.
- RV exteriors and decor use temporary prototype geometry and presentation.
- There is no installer or automatic updater. - There is no installer or automatic updater.
- Windows builds are unsigned. - Windows builds are unsigned.

View file

@ -17,10 +17,10 @@ for local README files.
## Related repositories ## Related repositories
- [Discovery service API](https://forge.makearmy.io/woofmeow/netfishing-discovery-server/src/branch/main/docs/API.md) - [Discovery service API](https://forge.makearmy.io/woofmeow/straywild-discovery-server/src/branch/main/docs/API.md)
stays with the service implementation and defines its versioned HTTP stays with the service implementation and defines its versioned HTTP
contract. contract.
- [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/netfishing-dedicated-server/src/branch/main/README.md) - [Dedicated-server packaging](https://forge.makearmy.io/woofmeow/straywild-dedicated-server/src/branch/main/README.md)
stays with its installer and update scripts. It consumes this repository's stays with its installer and update scripts. It consumes this repository's
attribution and legal notices from the exact pinned game commit instead of attribution and legal notices from the exact pinned game commit instead of
maintaining copies. maintaining copies.

View file

@ -9,7 +9,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.19.0-alpha/windows-x86_64/straywild.exe" export_path="builds/v0.20.2-alpha/windows-x86_64/straywild.exe"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -35,11 +35,11 @@ application/modify_resources=true
application/icon="res://art/exported/system_icons/straywild.ico" application/icon="res://art/exported/system_icons/straywild.ico"
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=0 application/icon_interpolation=0
application/file_version="0.19.0.0" application/file_version="0.20.2.0"
application/product_version="0.19.0.0" application/product_version="0.20.2.0"
application/company_name="Woofmeow" application/company_name="Woofmeow"
application/product_name="straywild" application/product_name="straywild"
application/file_description="straywild v0.19.0-alpha" application/file_description="straywild v0.20.2-alpha"
application/copyright="Copyright © 2026 Woofmeow" application/copyright="Copyright © 2026 Woofmeow"
application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md" application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0 application/export_angle=0
@ -62,7 +62,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.19.0-alpha/linux-arm64/straywild.arm64" export_path="builds/v0.20.2-alpha/linux-arm64/straywild.arm64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -92,7 +92,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.19.0-alpha/macos/straywild.zip" export_path="builds/v0.20.2-alpha/macos/straywild.zip"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -108,8 +108,8 @@ custom_template/release=""
application/bundle_identifier="io.woofmeow.netfishing" application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon="res://art/exported/system_icons/straywild_1024.png"
application/icon_interpolation=0 application/icon_interpolation=0
application/short_version="0.19.0" application/short_version="0.20.2"
application/version="0.19.0" application/version="0.20.2"
application/architecture="universal" application/architecture="universal"
codesign/enable=false codesign/enable=false
notarization/enable=false notarization/enable=false
@ -125,7 +125,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*,tests/*" exclude_filter="builds/*,scripts/*,tests/*"
export_path="builds/v0.19.0-alpha/server-linux-x86_64/straywildServer.x86_64" export_path="builds/v0.20.2-alpha/server-linux-x86_64/straywildServer.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -155,7 +155,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.19.0-alpha/android/straywild.apk" export_path="builds/v0.20.2-alpha/android/straywild.apk"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true architectures/arm64-v8a=true
architectures/x86=false architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
version/code=190000 version/code=200200
version/name="v0.19.0-alpha" version/name="v0.20.2-alpha"
package/unique_name="io.woofmeow.netfishing" package/unique_name="io.woofmeow.netfishing"
package/name="straywild" package/name="straywild"
package/signed=true package/signed=true
@ -214,7 +214,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" exclude_filter="builds/*,scripts/*"
export_path="builds/v0.19.0-alpha/linux-x86_64/straywild.x86_64" export_path="builds/v0.20.2-alpha/linux-x86_64/straywild.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""

View file

@ -41,8 +41,6 @@ const FishingSurfaceSampleType = preload(
const FishingSurfaceResolverType = preload( const FishingSurfaceResolverType = preload(
"res://fishing/fishing_surface_resolver.gd" "res://fishing/fishing_surface_resolver.gd"
) )
const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload( const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd" "res://world/world_weather_service.gd"
@ -134,6 +132,7 @@ const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate"
var state: FishingState = FishingState.READY var state: FishingState = FishingState.READY
var _external_input_blocked: bool = false var _external_input_blocked: bool = false
var _gameplay_input_enabled: bool = false var _gameplay_input_enabled: bool = false
var _fishing_environment_enabled: bool = true
var _local_menu_input_owners: Dictionary[StringName, bool] = {} var _local_menu_input_owners: Dictionary[StringName, bool] = {}
var _local_player: PlayerType var _local_player: PlayerType
var _local_inventory: FishInventoryType var _local_inventory: FishInventoryType
@ -150,6 +149,7 @@ var _network_session: NetworkSessionType
var _network_fishing: NetworkFishingServiceType var _network_fishing: NetworkFishingServiceType
var _world_time: WorldTimeServiceType var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType var _world_weather: WorldWeatherServiceType
var _fishable_water_regions_resolver := Callable()
var _active_player: PlayerType var _active_player: PlayerType
var _state_time_remaining: float = 0.0 var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0 var _cast_charge: float = 0.0
@ -222,6 +222,7 @@ func setup(
network_fishing: NetworkFishingServiceType = null, network_fishing: NetworkFishingServiceType = null,
world_time: WorldTimeServiceType = null, world_time: WorldTimeServiceType = null,
world_weather: WorldWeatherServiceType = null, world_weather: WorldWeatherServiceType = null,
fishable_water_regions_resolver: Callable = Callable(),
) -> void: ) -> void:
_local_player = local_player _local_player = local_player
_local_inventory = local_inventory _local_inventory = local_inventory
@ -242,6 +243,7 @@ func setup(
_network_fishing = network_fishing _network_fishing = network_fishing
_world_time = world_time _world_time = world_time
_world_weather = world_weather _world_weather = world_weather
_fishable_water_regions_resolver = fishable_water_regions_resolver
if ( if (
_network_session != null _network_session != null
and not _network_session.state_changed.is_connected( and not _network_session.state_changed.is_connected(
@ -396,6 +398,22 @@ func set_gameplay_input_enabled(enabled: bool) -> void:
reset_transient_state() reset_transient_state()
func set_fishing_environment_enabled(enabled: bool) -> void:
if _fishing_environment_enabled == enabled:
return
_fishing_environment_enabled = enabled
if enabled:
_new_cast_press_armed = not Input.is_action_pressed("fish_primary")
return
if _network_fishing != null and _network_fishing.has_local_attempt():
_network_fishing.cancel_local_attempt("")
reset_transient_state()
func is_fishing_environment_enabled() -> bool:
return _fishing_environment_enabled
func reset_transient_state(restore_movement: bool = true) -> void: func reset_transient_state(restore_movement: bool = true) -> void:
var local_state_was_active: bool = ( var local_state_was_active: bool = (
state != FishingState.READY or _active_player != null state != FishingState.READY or _active_player != null
@ -530,6 +548,7 @@ func _process(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if ( if (
not _gameplay_input_enabled not _gameplay_input_enabled
or not _fishing_environment_enabled
or _external_input_blocked or _external_input_blocked
or not _local_menu_input_owners.is_empty() or not _local_menu_input_owners.is_empty()
): ):
@ -542,51 +561,23 @@ func _unhandled_input(event: InputEvent) -> void:
alternate_reel and state == FishingState.FIGHTING alternate_reel and state == FishingState.FIGHTING
): ):
return return
# Fishing owns the primary action only after a rod starts a cast, or while
# completing an already-active fishing sequence. An empty hand, decor,
# gathering tools, and other items must remain available to their own world
# interaction controllers.
if state == FishingState.READY and not has_active_fishing_rod():
return
# A held keyboard key emits echo events. Treat the alternate reel exactly # A held keyboard key emits echo events. Treat the alternate reel exactly
# like a mouse button: one barrier hit per physical press, while the normal # like a mouse button: one barrier hit per physical press, while the normal
# held-input path continues driving free reeling between barriers. # held-input path continues driving free reeling between barriers.
if event is InputEventKey and event.echo: if event is InputEventKey and event.echo:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
var selected_item: ItemDataType = _get_active_item()
if (
state == FishingState.READY
and selected_item != null
and FishingShopStockType.is_gathering_tool(selected_item.item_id)
):
return
if event.is_pressed(): if event.is_pressed():
match state: match state:
FishingState.READY: FishingState.READY:
if not _new_cast_press_armed: if not _new_cast_press_armed:
return return
var active_item: ItemDataType = _get_active_item()
if (
active_item != null
and (
active_item.category == ItemDataType.Category.CONSUMABLE
or (
active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID
)
)
):
if _network_item_use != null:
_network_item_use.request_use(active_item.item_id)
elif _item_effects.use_item(active_item, _local_bag):
status_changed.emit(_item_effects.get_feedback(
active_item.item_id
))
elif (
active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID
):
local_speech_requested.emit(
PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE
)
get_viewport().set_input_as_handled()
return
if not has_active_fishing_rod():
get_viewport().set_input_as_handled()
return
if ( if (
_is_inventory_full() _is_inventory_full()
): ):
@ -658,6 +649,34 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
func try_use_active_non_fishing_item() -> bool:
if (
not _gameplay_input_enabled
or _external_input_blocked
or not _local_menu_input_owners.is_empty()
or state != FishingState.READY
):
return false
var active_item: ItemDataType = _get_active_item()
if (
active_item == null
or (
active_item.category != ItemDataType.Category.CONSUMABLE
and active_item.item_id != PlayerItemEffectsType.FISH_FINDER_ID
)
):
return false
if _network_item_use != null:
_network_item_use.request_use(active_item.item_id)
elif _item_effects.use_item(active_item, _local_bag):
status_changed.emit(_item_effects.get_feedback(active_item.item_id))
elif active_item.item_id == PlayerItemEffectsType.FISH_FINDER_ID:
local_speech_requested.emit(
PlayerItemEffectsType.FISH_FINDER_DEAD_MESSAGE
)
return true
func _is_reel_input_pressed() -> bool: func _is_reel_input_pressed() -> bool:
return ( return (
Input.is_action_pressed("fish_primary") Input.is_action_pressed("fish_primary")
@ -1664,6 +1683,47 @@ func build_network_context(
return _build_fishing_context(region) return _build_fishing_context(region)
func is_species_available_now(fish: FishDataType) -> bool:
if fish == null or not fish.is_selectable():
return false
var season := (
_world_time.get_season()
if _world_time != null
else CalendarSeason.UNKNOWN
)
if not fish.is_available_in_season(season):
return false
if fish.collection_method != FishDataType.CollectionMethod.FISHING:
return true
for region: FishableWaterRegionType in _get_logbook_water_regions():
if (
region == null
or region.fish_pool == null
or region.fish_pool.get_fish_by_id(fish.id) == null
):
continue
var context := _build_fishing_context(region)
if not fish.is_allowed_in_water(context.water_type):
continue
if fish.availability == null or fish.availability.is_available(context):
return true
return false
func _get_logbook_water_regions() -> Array[FishableWaterRegionType]:
var regions: Array[FishableWaterRegionType] = []
if not _fishable_water_regions_resolver.is_valid():
return regions
var resolved: Variant = _fishable_water_regions_resolver.call()
if resolved is not Array:
return regions
for value: Variant in resolved:
var region := value as FishableWaterRegionType
if region != null and is_instance_valid(region):
regions.append(region)
return regions
func _get_active_bait_tags() -> Array[StringName]: func _get_active_bait_tags() -> Array[StringName]:
if _local_player == null or _item_catalog == null: if _local_player == null or _item_catalog == null:
return [] return []

View file

@ -29,12 +29,16 @@ func configure(
entity_id = configured_entity_id entity_id = configured_entity_id
data = configured_data data = configured_data
type_id = data.type_id if data != null else StringName() type_id = data.type_id if data != null else StringName()
# Establish the authoritative transform before constructing transient visuals.
# Effects emitted before this point would begin at the default world origin.
apply_network_state(position, yaw, true)
if data != null and data.is_stationary_hotspot(): if data != null and data.is_stationary_hotspot():
_ensure_water_spurt_visual() _ensure_water_spurt_visual()
_water_spurt_elapsed = ( _water_spurt_elapsed = (
float(abs(hash(entity_id)) % 1000) / 1000.0 float(abs(hash(entity_id)) % 1000) / 1000.0
* WATER_SPURT_INTERVAL_SECONDS * WATER_SPURT_INTERVAL_SECONDS
) )
_emit_water_spurt()
else: else:
_ensure_visual() _ensure_visual()
if data != null and data.catch_data != null and _sprite != null: if data != null and data.catch_data != null and _sprite != null:
@ -50,7 +54,6 @@ func configure(
) )
) )
_sprite.rotation_degrees.x = data.sprite_tilt_degrees _sprite.rotation_degrees.x = data.sprite_tilt_degrees
apply_network_state(position, yaw, true)
func apply_network_state( func apply_network_state(
@ -147,7 +150,6 @@ func _ensure_water_spurt_visual() -> void:
hole.mesh = hole_mesh hole.mesh = hole_mesh
hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_water_spurt_root.add_child(hole) _water_spurt_root.add_child(hole)
_emit_water_spurt()
func _emit_water_spurt() -> void: func _emit_water_spurt() -> void:
@ -159,6 +161,7 @@ func _emit_water_spurt() -> void:
particles.amount = 6 particles.amount = 6
particles.lifetime = 0.42 particles.lifetime = 0.42
particles.one_shot = true particles.one_shot = true
particles.local_coords = true
particles.explosiveness = 1.0 particles.explosiveness = 1.0
particles.direction = Vector3.UP particles.direction = Vector3.UP
particles.spread = 32.0 particles.spread = 32.0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bhph22caipcj6"
path="res://.godot/imported/starter_motorcoach.glb-153681ef1e622a556fba4df576dc6f84.scn"
[deps]
source_file="res://homes/assets/starter_motorcoach.glb"
dest_files=["res://.godot/imported/starter_motorcoach.glb-153681ef1e622a556fba4df576dc6f84.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

59
homes/decor_catalog.gd Normal file
View file

@ -0,0 +1,59 @@
class_name DecorCatalog
extends RefCounted
const BASIC_CUBE_ID: StringName = &"basic_cube"
const MAX_OWNED_PER_PRODUCT: int = 32
const _PRODUCTS: Dictionary = {
BASIC_CUBE_ID: {
"display_name": "basic cube",
"description": "a simple piece of decor for testing your RV layout.",
"price": 1,
"footprint": Vector2(1.0, 0.8),
"height": 0.8,
},
}
static func get_product_ids() -> Array[StringName]:
var ids: Array[StringName] = []
for value: Variant in _PRODUCTS.keys():
ids.append(StringName(str(value)))
ids.sort_custom(func(first: StringName, second: StringName) -> bool:
return String(first) < String(second)
)
return ids
static func has_product(product_id: StringName) -> bool:
return _PRODUCTS.has(product_id)
static func get_product(product_id: StringName) -> Dictionary:
var value: Variant = _PRODUCTS.get(product_id)
return (
(value as Dictionary).duplicate(true)
if typeof(value) == TYPE_DICTIONARY
else {}
)
static func get_display_name(product_id: StringName) -> String:
return str(get_product(product_id).get("display_name", product_id))
static func get_description(product_id: StringName) -> String:
return str(get_product(product_id).get("description", ""))
static func get_price(product_id: StringName) -> int:
return int(get_product(product_id).get("price", -1))
static func get_footprint(product_id: StringName) -> Vector2:
var value: Variant = get_product(product_id).get("footprint", Vector2.ZERO)
return value as Vector2 if value is Vector2 else Vector2.ZERO
static func get_height(product_id: StringName) -> float:
return float(get_product(product_id).get("height", 0.0))

View file

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

View file

@ -0,0 +1,499 @@
class_name HomeDecorController
extends Node
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const DRAG_RESPONSE: float = 22.0
const DRAG_POSITION_EPSILON: float = 0.005
var _player: Player
var _hotbar: PlayerHotbar
var _bag: PlayerBag
var _home_state: PlayerHomeState
var _network_home: NetworkHomeService
var _home_world: HomeWorldService
var _fishing_spot: FishingSpot
var _toolbar: HomeDecorToolbar
var _mode: PlayerHomeState.PlacementMode = PlayerHomeState.PlacementMode.GRID
var _selected_instance_id: String = ""
var _drag_instance_id: String = ""
var _drag_owner_fingerprint: String = ""
var _drag_pointer_offset: Vector3 = Vector3.ZERO
var _drag_original_position: Vector3 = Vector3.ZERO
var _drag_original_yaw: float = 0.0
var _drag_target_position: Vector3 = Vector3.ZERO
var _drag_target_yaw: float = 0.0
var _drag_target_valid: bool = false
func setup(
player: Player,
hotbar: PlayerHotbar,
bag: PlayerBag,
home_state: PlayerHomeState,
network_home: NetworkHomeService,
home_world: HomeWorldService,
fishing_spot: FishingSpot,
toolbar: HomeDecorToolbar,
) -> void:
_player = player
_hotbar = hotbar
_bag = bag
_home_state = home_state
_network_home = network_home
_home_world = home_world
_fishing_spot = fishing_spot
_toolbar = toolbar
if not _network_home.local_space_changed.is_connected(
_on_local_space_changed
):
_network_home.local_space_changed.connect(_on_local_space_changed)
if not _network_home.local_manifest_rejected.is_connected(
_on_local_manifest_rejected
):
_network_home.local_manifest_rejected.connect(
_on_local_manifest_rejected
)
if not _home_state.changed.is_connected(_on_home_changed):
_home_state.changed.connect(_on_home_changed)
if not _toolbar.placement_mode_requested.is_connected(
_on_placement_mode_requested
):
_toolbar.placement_mode_requested.connect(
_on_placement_mode_requested
)
_toolbar.rotate_requested.connect(_rotate_selected)
_toolbar.store_requested.connect(_store_selected)
_toolbar.cancel_requested.connect(_clear_selection)
_toolbar.set_mode(_mode)
_refresh_toolbar()
func begin_decorating() -> void:
_refresh_toolbar()
if _toolbar.visible:
_toolbar.report_status(
"use held decor to place it; click or drag placed decor"
)
func _input(event: InputEvent) -> void:
if _drag_instance_id.is_empty():
return
var pointer_motion := event as InputEventMouseMotion
if pointer_motion != null:
_update_drag(pointer_motion.position)
get_viewport().set_input_as_handled()
return
var pointer_button := event as InputEventMouseButton
if (
pointer_button != null
and pointer_button.button_index == MOUSE_BUTTON_LEFT
and not pointer_button.pressed
):
# A gameplay menu can take ownership between the press and release. Do
# not let that release finalize decor behind the menu; preserve the
# original transform instead.
if _can_decorate():
_finish_drag()
else:
_cancel_drag()
get_viewport().set_input_as_handled()
func _unhandled_input(event: InputEvent) -> void:
var pointer_button := event as InputEventMouseButton
if (
pointer_button != null
and pointer_button.button_index == MOUSE_BUTTON_LEFT
):
if not pointer_button.pressed or not _can_decorate():
return
if _begin_drag(pointer_button.position):
get_viewport().set_input_as_handled()
return
var pointer_decor_id: StringName = _get_selected_decor_id()
if (
not pointer_decor_id.is_empty()
and _bag.get_quantity(pointer_decor_id) > 0
):
_place_carried_decor(pointer_decor_id)
get_viewport().set_input_as_handled()
return
if not _can_decorate() or not event.is_action_pressed("fish_primary"):
return
if event is InputEventKey and event.echo:
return
var decor_id: StringName = _get_selected_decor_id()
if not decor_id.is_empty() and _bag.get_quantity(decor_id) > 0:
_place_carried_decor(decor_id)
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
if _drag_instance_id.is_empty():
return
# Menus and other gameplay input handoffs must cancel an active drag before
# evaluating a held/released pointer. This prevents a same-frame TAB press
# followed by left-button release from committing a move behind the UI.
if not _can_decorate():
_cancel_drag()
return
if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
_finish_drag()
return
if not _drag_target_valid:
return
var current: Dictionary = _home_world.get_decor_preview_transform(
_drag_owner_fingerprint, _drag_instance_id
)
if current.is_empty():
_cancel_drag()
return
var current_position: Vector3 = current["position"]
var current_yaw: float = float(current["yaw"])
var blend: float = 1.0 - exp(-DRAG_RESPONSE * delta)
_home_world.set_decor_preview_transform(
_drag_owner_fingerprint,
_drag_instance_id,
current_position.lerp(_drag_target_position, blend),
lerp_angle(current_yaw, _drag_target_yaw, blend),
)
func _begin_drag(screen_position: Vector2) -> bool:
if _network_home.has_pending_local_decor_mutation():
_toolbar.report_status("waiting for the previous decor update")
return false
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
var camera: Camera3D = _player.get_active_gameplay_camera()
var clicked_instance: String = _home_world.find_decor_instance_at_screen(
owner_fingerprint, camera, screen_position
)
if clicked_instance.is_empty():
return false
var placement: Dictionary = _home_state.get_placement(clicked_instance)
if placement.is_empty():
return false
var original_position: Vector3 = _placement_position(placement)
if not original_position.is_finite():
return false
if not _home_world.set_decor_manipulation_active(
owner_fingerprint, clicked_instance, true
):
return false
var pointer_position: Vector3 = _home_world.get_floor_position_from_screen(
owner_fingerprint, camera, screen_position
)
_drag_instance_id = clicked_instance
_drag_owner_fingerprint = owner_fingerprint
_drag_original_position = original_position
_drag_original_yaw = float(placement.get("yaw", 0.0))
_drag_target_position = original_position
_drag_target_yaw = _drag_original_yaw
_drag_target_valid = true
_drag_pointer_offset = (
original_position - pointer_position
if pointer_position.is_finite()
else Vector3.ZERO
)
_drag_pointer_offset.y = 0.0
_select_instance(clicked_instance)
_toolbar.report_status("drag decor, then release to place it")
return true
func _update_drag(screen_position: Vector2) -> void:
if not _can_decorate():
_cancel_drag()
return
var camera: Camera3D = _player.get_active_gameplay_camera()
var pointer_position: Vector3 = _home_world.get_floor_position_from_screen(
_drag_owner_fingerprint, camera, screen_position
)
if not pointer_position.is_finite():
# Keep the most recent valid target when the pointer briefly leaves the
# RV floor. Releasing there should place at that target, not throw the
# decor all the way back to where the drag began.
_toolbar.report_status("keep the pointer over the RV floor")
return
var placement: Dictionary = _home_state.get_placement(_drag_instance_id)
if placement.is_empty():
_clear_selection()
return
var candidate: Vector3 = pointer_position + _drag_pointer_offset
candidate.y = 0.0
var normalized: Dictionary = _network_home.get_valid_local_decor_transform(
_drag_instance_id,
candidate,
float(placement.get("yaw", _drag_original_yaw)),
_mode,
)
if normalized.is_empty():
# A densely furnished area may have no nearby solution. Preserve the
# last valid preview so the interaction remains stable while the player
# moves the pointer toward open space.
_toolbar.report_status("holding at the nearest available position")
return
_drag_target_position = normalized["position"]
_drag_target_yaw = float(normalized["yaw"])
_drag_target_valid = true
_toolbar.report_status("release to place decor")
func _finish_drag() -> void:
var instance_id: String = _drag_instance_id
var owner_fingerprint: String = _drag_owner_fingerprint
var original_position: Vector3 = _drag_original_position
var original_yaw: float = _drag_original_yaw
var target_position: Vector3 = _drag_target_position
var target_yaw: float = _drag_target_yaw
var target_valid: bool = _drag_target_valid
var placement: Dictionary = _home_state.get_placement(instance_id)
var decor_id := StringName(str(placement.get("decor_id", "")))
if (
not target_valid
or decor_id.is_empty()
or not _home_world.is_decor_placement_clear_of_players(
owner_fingerprint, decor_id, target_position, target_yaw
)
):
_home_world.set_decor_preview_transform(
owner_fingerprint,
instance_id,
original_position,
original_yaw,
)
_home_world.set_decor_manipulation_active(
owner_fingerprint, instance_id, false
)
_reset_drag_state()
_toolbar.report_status(
"a player is in the way"
if target_valid
else "that space is not available"
)
return
_home_world.set_decor_preview_transform(
owner_fingerprint, instance_id, target_position, target_yaw
)
if (
original_position.distance_to(target_position)
<= DRAG_POSITION_EPSILON
and is_zero_approx(angle_difference(original_yaw, target_yaw))
):
_home_world.set_decor_manipulation_active(
owner_fingerprint, instance_id, false
)
_reset_drag_state()
_select_instance(instance_id)
return
if _network_home.update_local_decor(
instance_id, target_position, target_yaw, _mode
):
_home_world.set_decor_manipulation_active(
owner_fingerprint, instance_id, false
)
_reset_drag_state()
_toolbar.report_status("decor moved")
return
_home_world.set_decor_preview_transform(
owner_fingerprint,
instance_id,
original_position,
original_yaw,
)
_home_world.set_decor_manipulation_active(
owner_fingerprint, instance_id, false
)
_reset_drag_state()
_toolbar.report_status("that space is not available")
func _cancel_drag() -> void:
if _drag_instance_id.is_empty():
return
_home_world.set_decor_preview_transform(
_drag_owner_fingerprint,
_drag_instance_id,
_drag_original_position,
_drag_original_yaw,
)
_home_world.set_decor_manipulation_active(
_drag_owner_fingerprint, _drag_instance_id, false
)
_reset_drag_state()
func _reset_drag_state() -> void:
_drag_instance_id = ""
_drag_owner_fingerprint = ""
_drag_pointer_offset = Vector3.ZERO
_drag_original_position = Vector3.ZERO
_drag_original_yaw = 0.0
_drag_target_position = Vector3.ZERO
_drag_target_yaw = 0.0
_drag_target_valid = false
static func _placement_position(placement: Dictionary) -> Vector3:
if placement.is_empty():
return Vector3.INF
var values: Array = placement.get("position", [])
if values.size() != 3:
return Vector3.INF
return Vector3(
float(values[0]), float(values[1]), float(values[2])
)
func _place_carried_decor(decor_id: StringName) -> void:
if _network_home.has_pending_local_decor_mutation():
_toolbar.report_status("waiting for the previous decor update")
return
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
# RV transitions keep the gameplay root axis-aligned so its transform can be
# synchronized independently from the presentation. Use the visual facing
# direction instead; otherwise carried decor is always placed along world -Z
# instead of in front of the character the player sees.
var forward: Vector3 = _player.get_facing_direction()
var global_position: Vector3 = _player.global_position + forward * 1.45
var local_position: Vector3 = _home_world.interior_global_to_local(
owner_fingerprint, global_position
)
var placement_yaw: float = atan2(-forward.x, -forward.z)
var instance_id: String = _network_home.place_local_decor(
decor_id, local_position, placement_yaw, _mode
)
if instance_id.is_empty():
_toolbar.report_status("make room in front of you to place this decor")
return
# A local host can reject its own optimistic manifest synchronously. Do not
# claim success after that rollback or leave the player looking for decor that
# was never accepted into the room.
if _home_state.get_placement(instance_id).is_empty():
_toolbar.report_status("decor placement was not accepted")
return
_select_instance(instance_id)
_toolbar.report_status("decor placed")
func _select_instance(instance_id: String) -> void:
var placement: Dictionary = _home_state.get_placement(instance_id)
if placement.is_empty():
_clear_selection()
return
_selected_instance_id = instance_id
var decor_id := StringName(str(placement.get("decor_id", "")))
_toolbar.set_selection(
instance_id, DecorCatalogType.get_display_name(decor_id)
)
func _rotate_selected() -> void:
if not _can_decorate() or _selected_instance_id.is_empty():
return
if _network_home.has_pending_local_decor_mutation():
_toolbar.report_status("waiting for the previous decor update")
return
var placement: Dictionary = _home_state.get_placement(
_selected_instance_id
)
if placement.is_empty():
_clear_selection()
return
var values: Array = placement.get("position", [])
if values.size() != 3:
return
var position := Vector3(
float(values[0]), float(values[1]), float(values[2])
)
if _network_home.update_local_decor(
_selected_instance_id,
position,
float(placement.get("yaw", 0.0)) + PI * 0.5,
_mode,
):
_toolbar.report_status("decor rotated")
else:
_toolbar.report_status("not enough room to rotate here")
func _store_selected() -> void:
if _selected_instance_id.is_empty():
return
if _network_home.has_pending_local_decor_mutation():
_toolbar.report_status("waiting for the previous decor update")
return
if _network_home.remove_local_decor(_selected_instance_id):
_clear_selection()
_toolbar.report_status("decor packed into storage")
else:
_toolbar.report_status("inventory and storage are full")
func _clear_selection() -> void:
_cancel_drag()
_selected_instance_id = ""
if _toolbar != null:
_toolbar.set_selection("")
func _on_placement_mode_requested(mode: int) -> void:
_mode = mode as PlayerHomeState.PlacementMode
func _on_local_space_changed(
_space_id: StringName,
_owner_fingerprint: String,
) -> void:
_clear_selection()
_refresh_toolbar()
func _on_local_manifest_rejected(message: String) -> void:
_clear_selection()
if _toolbar != null and _toolbar.visible:
_toolbar.report_status(message)
func _on_home_changed() -> void:
if (
not _selected_instance_id.is_empty()
and _home_state.get_placement(_selected_instance_id).is_empty()
):
_clear_selection()
_refresh_toolbar()
func _refresh_toolbar() -> void:
if _toolbar == null:
return
_toolbar.set_active(_can_decorate())
if not _toolbar.visible:
_clear_selection()
func _can_decorate() -> bool:
return (
_player != null
and _network_home != null
and _network_home.can_local_decorate()
and _fishing_spot != null
and _fishing_spot.can_open_fishing_shop()
and _player.is_movement_enabled()
)
func _get_selected_decor_id() -> StringName:
if (
_hotbar == null
or _hotbar.get_selected_assignment_kind()
!= PlayerHotbar.AssignmentKind.ITEM
):
return StringName()
var item_id: StringName = _hotbar.get_selected_item_id()
return item_id if DecorCatalogType.has_product(item_id) else StringName()

View file

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

1209
homes/home_world_service.gd Normal file

File diff suppressed because it is too large Load diff

View file

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

786
homes/player_home_state.gd Normal file
View file

@ -0,0 +1,786 @@
class_name PlayerHomeState
extends Node
signal changed
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const HOME_SCHEMA_VERSION: int = 1
const STARTER_TIER: int = 0
const STARTER_EXTERIOR_ID: StringName = &"starter_motorcoach"
const MAX_TIER: int = 8
const MAX_PLACEMENTS: int = 128
const MAX_INSTANCE_ID_LENGTH: int = 96
const MAX_REVISION: int = 2147483647
const GRID_SIZE: float = 0.5
const FREE_PLACEMENT_SEARCH_STEP: float = 0.1
const FREE_PLACEMENT_SEARCH_ANGLES: int = 24
const NEAREST_PLACEMENT_SEARCH_DISTANCE: float = 2.0
# The starter RV is intentionally cramped. Later RV tiers can replace these
# bounds with larger layouts when tiered interiors are introduced.
# The starter interior is 8.0 m by 4.5 m with 0.18 m walls. These bounds
# follow the walls' inner faces, with a small visual gap to avoid z-fighting.
const INTERIOR_HALF_EXTENTS := Vector2(3.91, 2.16)
const PLACEMENT_EDGE_PADDING: float = 0.02
enum Privacy {
OPEN,
CLOSED,
}
enum PlacementMode {
GRID,
FREE,
}
var _tier: int = STARTER_TIER
var _exterior_id: StringName = STARTER_EXTERIOR_ID
var _privacy: Privacy = Privacy.OPEN
var _owned_decor: Dictionary[StringName, int] = {}
var _placements: Array[Dictionary] = []
var _revision: int = 0
var _next_instance_sequence: int = 1
func get_tier() -> int:
return _tier
func get_exterior_id() -> StringName:
return _exterior_id
func get_privacy() -> Privacy:
return _privacy
func get_revision() -> int:
return _revision
func get_owned_count(decor_id: StringName) -> int:
return int(_owned_decor.get(decor_id, 0))
func get_placed_count(decor_id: StringName) -> int:
var count: int = 0
for placement: Dictionary in _placements:
if StringName(str(placement.get("decor_id", ""))) == decor_id:
count += 1
return count
func get_available_count(decor_id: StringName) -> int:
return maxi(
get_owned_count(decor_id) - get_placed_count(decor_id),
0,
)
func get_owned_decor() -> Dictionary[StringName, int]:
return _owned_decor.duplicate()
func get_placements() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for placement: Dictionary in _placements:
result.append(placement.duplicate(true))
return result
func get_placement(instance_id: String) -> Dictionary:
var index: int = _placement_index(instance_id)
return _placements[index].duplicate(true) if index >= 0 else {}
func can_place_decor(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
) -> bool:
if (
get_available_count(decor_id) <= 0
or _placements.size() >= MAX_PLACEMENTS
or not _valid_mode(mode)
):
return false
return not get_nearest_valid_placement_transform(
decor_id,
position,
yaw_radians,
mode,
"",
).is_empty()
func set_privacy(value: Privacy) -> bool:
if value < Privacy.OPEN or value > Privacy.CLOSED:
return false
if _privacy == value:
return true
_privacy = value
_touch()
return true
func add_owned_decor(
decor_id: StringName,
quantity: int = 1,
) -> bool:
if (
not DecorCatalogType.has_product(decor_id)
or quantity <= 0
):
return false
var current: int = get_owned_count(decor_id)
if current + quantity > DecorCatalogType.MAX_OWNED_PER_PRODUCT:
return false
_owned_decor[decor_id] = current + quantity
_touch()
return true
func remove_owned_decor(
decor_id: StringName,
quantity: int = 1,
) -> bool:
if quantity <= 0:
return false
var current: int = get_owned_count(decor_id)
if current - quantity < get_placed_count(decor_id):
return false
var remaining: int = current - quantity
if remaining < 0:
return false
if remaining == 0:
_owned_decor.erase(decor_id)
else:
_owned_decor[decor_id] = remaining
_touch()
return true
func place_decor(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
) -> String:
if (
get_available_count(decor_id) <= 0
or _placements.size() >= MAX_PLACEMENTS
or not _valid_mode(mode)
):
return ""
var normalized := get_nearest_valid_placement_transform(
decor_id,
position,
yaw_radians,
mode,
"",
)
if normalized.is_empty():
return ""
var instance_id := _make_instance_id()
var placement: Dictionary = {
"instance_id": instance_id,
"decor_id": String(decor_id),
"mode": int(mode),
"position": _vector3_to_array(normalized["position"]),
"yaw": float(normalized["yaw"]),
}
_placements.append(placement)
_touch()
return instance_id
func update_placement(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
) -> bool:
var index: int = _placement_index(instance_id)
var normalized: Dictionary = get_valid_update_transform(
instance_id, position, yaw_radians, mode
)
if index < 0 or normalized.is_empty():
return false
var placement: Dictionary = _placements[index].duplicate(true)
placement["mode"] = int(mode)
placement["position"] = _vector3_to_array(normalized["position"])
placement["yaw"] = float(normalized["yaw"])
_placements[index] = placement
_touch()
return true
func get_valid_update_transform(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
candidate_validator: Callable = Callable(),
) -> Dictionary:
var index: int = _placement_index(instance_id)
if index < 0 or not _valid_mode(mode):
return {}
var decor_id := StringName(
str(_placements[index].get("decor_id", ""))
)
return get_nearest_valid_placement_transform(
decor_id,
position,
yaw_radians,
mode,
instance_id,
candidate_validator,
)
func get_nearest_valid_placement_transform(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
ignored_instance_id: String = "",
candidate_validator: Callable = Callable(),
) -> Dictionary:
var normalized: Dictionary = normalized_placement_transform(
decor_id, position, yaw_radians, mode
)
if normalized.is_empty():
return {}
if (
not _overlaps_existing(
decor_id, normalized, ignored_instance_id
)
and _candidate_is_accepted(normalized, candidate_validator)
):
return normalized
return (
_find_nearest_grid_transform(
decor_id,
normalized,
ignored_instance_id,
candidate_validator,
)
if mode == PlacementMode.GRID
else _find_nearest_free_transform(
decor_id,
normalized,
ignored_instance_id,
candidate_validator,
)
)
func remove_placement(instance_id: String) -> bool:
var index: int = _placement_index(instance_id)
if index < 0:
return false
_placements.remove_at(index)
_touch()
return true
func reset_to_defaults() -> void:
_tier = STARTER_TIER
_exterior_id = STARTER_EXTERIOR_ID
_privacy = Privacy.OPEN
_owned_decor.clear()
_placements.clear()
_revision = 0
_next_instance_sequence = 1
changed.emit()
func to_save_data() -> Dictionary:
var owned: Dictionary = {}
var ids: Array[StringName] = []
ids.assign(_owned_decor.keys())
ids.sort_custom(func(first: StringName, second: StringName) -> bool:
return String(first) < String(second)
)
for decor_id: StringName in ids:
owned[String(decor_id)] = _owned_decor[decor_id]
return {
"schema_version": HOME_SCHEMA_VERSION,
"tier": _tier,
"exterior_id": String(_exterior_id),
"privacy": int(_privacy),
"owned_decor": owned,
"placements": get_placements(),
"revision": _revision,
"next_instance_sequence": _next_instance_sequence,
}
func restore_from_save_data(data: Dictionary) -> bool:
var sanitized: Dictionary = sanitize_save_data(data)
if sanitized.is_empty():
return false
_tier = int(sanitized["tier"])
_exterior_id = StringName(str(sanitized["exterior_id"]))
_privacy = int(sanitized["privacy"])
_owned_decor.clear()
for key: Variant in (sanitized["owned_decor"] as Dictionary):
_owned_decor[StringName(str(key))] = int(
(sanitized["owned_decor"] as Dictionary)[key]
)
_placements.clear()
for value: Variant in sanitized["placements"] as Array:
_placements.append((value as Dictionary).duplicate(true))
_revision = int(sanitized["revision"])
_next_instance_sequence = int(sanitized["next_instance_sequence"])
changed.emit()
return true
static func default_save_data() -> Dictionary:
return {
"schema_version": HOME_SCHEMA_VERSION,
"tier": STARTER_TIER,
"exterior_id": String(STARTER_EXTERIOR_ID),
"privacy": int(Privacy.OPEN),
"owned_decor": {},
"placements": [],
"revision": 0,
"next_instance_sequence": 1,
}
static func sanitize_save_data(data: Dictionary) -> Dictionary:
if (
not _is_integer_value(data.get("schema_version"))
or int(data.get("schema_version", -1)) != HOME_SCHEMA_VERSION
or not _is_integer_value(data.get("tier"))
or typeof(data.get("exterior_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or not _is_integer_value(data.get("privacy"))
or typeof(data.get("owned_decor")) != TYPE_DICTIONARY
or typeof(data.get("placements")) != TYPE_ARRAY
or not _is_integer_value(data.get("revision"))
or not _is_integer_value(data.get("next_instance_sequence"))
):
return {}
var tier: int = int(data["tier"])
var exterior_id: String = str(data["exterior_id"])
var privacy: int = int(data["privacy"])
var revision: int = int(data["revision"])
var next_sequence: int = int(data["next_instance_sequence"])
if (
tier < STARTER_TIER
or tier > MAX_TIER
or exterior_id.is_empty()
or exterior_id.length() > 96
or privacy < Privacy.OPEN
or privacy > Privacy.CLOSED
or revision < 0
or revision > MAX_REVISION
or next_sequence < 1
or next_sequence > MAX_REVISION
):
return {}
var sanitized_owned: Dictionary = {}
var owned: Dictionary = data["owned_decor"]
for key: Variant in owned:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {}
var decor_id := StringName(str(key))
var quantity_value: Variant = owned[key]
if (
not DecorCatalogType.has_product(decor_id)
or not _is_integer_value(quantity_value)
or int(quantity_value) <= 0
or int(quantity_value) > DecorCatalogType.MAX_OWNED_PER_PRODUCT
):
return {}
sanitized_owned[String(decor_id)] = int(quantity_value)
var values: Array = data["placements"]
if values.size() > MAX_PLACEMENTS:
return {}
var sanitized_placements: Array[Dictionary] = []
var seen_instance_ids: Dictionary[String, bool] = {}
var placed_counts: Dictionary[StringName, int] = {}
for value: Variant in values:
if typeof(value) != TYPE_DICTIONARY:
return {}
var sanitized: Dictionary = sanitize_placement(value)
if sanitized.is_empty():
return {}
var instance_id: String = str(sanitized["instance_id"])
var decor_id := StringName(str(sanitized["decor_id"]))
if seen_instance_ids.has(instance_id):
return {}
seen_instance_ids[instance_id] = true
placed_counts[decor_id] = int(
placed_counts.get(decor_id, 0)
) + 1
if (
int(placed_counts[decor_id])
> int(sanitized_owned.get(String(decor_id), 0))
):
return {}
for previous: Dictionary in sanitized_placements:
if placements_overlap(previous, sanitized):
return {}
sanitized_placements.append(sanitized)
return {
"schema_version": HOME_SCHEMA_VERSION,
"tier": tier,
"exterior_id": exterior_id,
"privacy": privacy,
"owned_decor": sanitized_owned,
"placements": sanitized_placements,
"revision": revision,
"next_instance_sequence": next_sequence,
}
static func sanitize_placement(value: Dictionary) -> Dictionary:
for key: String in [
"instance_id", "decor_id", "mode", "position", "yaw",
]:
if not value.has(key):
return {}
if (
typeof(value["instance_id"]) != TYPE_STRING
or typeof(value["decor_id"]) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or not _is_integer_value(value["mode"])
or typeof(value["position"]) != TYPE_ARRAY
or typeof(value["yaw"]) not in [TYPE_FLOAT, TYPE_INT]
):
return {}
var instance_id: String = value["instance_id"]
var decor_id := StringName(str(value["decor_id"]))
var mode: int = int(value["mode"])
var position_values: Array = value["position"]
var yaw: float = float(value["yaw"])
if (
instance_id.is_empty()
or instance_id.length() > MAX_INSTANCE_ID_LENGTH
or not DecorCatalogType.has_product(decor_id)
or not _valid_mode(mode)
or position_values.size() != 3
or not is_finite(yaw)
):
return {}
for component: Variant in position_values:
if typeof(component) not in [TYPE_FLOAT, TYPE_INT]:
return {}
var position := Vector3(
float(position_values[0]),
float(position_values[1]),
float(position_values[2]),
)
var normalized := normalized_placement_transform(
decor_id,
position,
yaw,
mode as PlacementMode,
)
if normalized.is_empty():
return {}
return {
"instance_id": instance_id,
"decor_id": String(decor_id),
"mode": mode,
"position": _vector3_to_array(normalized["position"]),
"yaw": float(normalized["yaw"]),
}
static func _is_integer_value(value: Variant) -> bool:
if typeof(value) == TYPE_INT:
return true
if typeof(value) != TYPE_FLOAT:
return false
var number: float = float(value)
return is_finite(number) and is_equal_approx(number, roundf(number))
static func normalized_placement_transform(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
) -> Dictionary:
if (
not DecorCatalogType.has_product(decor_id)
or not position.is_finite()
or not is_finite(yaw_radians)
or not _valid_mode(mode)
):
return {}
var normalized_position := Vector3(position.x, 0.0, position.z)
var normalized_yaw: float = wrapf(yaw_radians, -PI, PI)
if mode == PlacementMode.GRID:
normalized_position.x = snappedf(normalized_position.x, GRID_SIZE)
normalized_position.z = snappedf(normalized_position.z, GRID_SIZE)
normalized_yaw = snappedf(normalized_yaw, PI * 0.5)
var footprint: Vector2 = DecorCatalogType.get_footprint(decor_id)
var cosine: float = absf(cos(normalized_yaw))
var sine: float = absf(sin(normalized_yaw))
var half_size := Vector2(
(footprint.x * cosine + footprint.y * sine) * 0.5,
(footprint.x * sine + footprint.y * cosine) * 0.5,
)
var maximum_x: float = (
INTERIOR_HALF_EXTENTS.x - half_size.x - PLACEMENT_EDGE_PADDING
)
var maximum_z: float = (
INTERIOR_HALF_EXTENTS.y - half_size.y - PLACEMENT_EDGE_PADDING
)
if maximum_x < 0.0 or maximum_z < 0.0:
return {}
# Clamp after grid snapping so a grid-placed item can still sit flush at a
# wall even when the wall itself is not on an exact grid interval.
normalized_position.x = clampf(
normalized_position.x, -maximum_x, maximum_x
)
normalized_position.z = clampf(
normalized_position.z, -maximum_z, maximum_z
)
return {
"position": normalized_position,
"yaw": normalized_yaw,
}
func _find_nearest_grid_transform(
decor_id: StringName,
origin: Dictionary,
ignored_instance_id: String,
candidate_validator: Callable,
) -> Dictionary:
var origin_position: Vector3 = origin["position"]
var yaw: float = float(origin["yaw"])
var maximum_ring: int = ceili(
NEAREST_PLACEMENT_SEARCH_DISTANCE / GRID_SIZE
)
var seen: Dictionary[String, bool] = {}
for ring: int in range(1, maximum_ring + 1):
var nearest: Dictionary = {}
var nearest_distance_squared: float = INF
for x_offset: int in range(-ring, ring + 1):
for z_offset: int in range(-ring, ring + 1):
if maxi(absi(x_offset), absi(z_offset)) != ring:
continue
var candidate: Dictionary = normalized_placement_transform(
decor_id,
origin_position + Vector3(
float(x_offset) * GRID_SIZE,
0.0,
float(z_offset) * GRID_SIZE,
),
yaw,
PlacementMode.GRID,
)
if candidate.is_empty() or not _mark_candidate_unseen(
candidate, seen
):
continue
if _overlaps_existing(
decor_id, candidate, ignored_instance_id
) or not _candidate_is_accepted(
candidate, candidate_validator
):
continue
var candidate_position: Vector3 = candidate["position"]
var distance_squared: float = origin_position.distance_squared_to(
candidate_position
)
if distance_squared < nearest_distance_squared:
nearest = candidate
nearest_distance_squared = distance_squared
if not nearest.is_empty():
return nearest
return {}
func _find_nearest_free_transform(
decor_id: StringName,
origin: Dictionary,
ignored_instance_id: String,
candidate_validator: Callable,
) -> Dictionary:
var origin_position: Vector3 = origin["position"]
var yaw: float = float(origin["yaw"])
var maximum_ring: int = ceili(
NEAREST_PLACEMENT_SEARCH_DISTANCE / FREE_PLACEMENT_SEARCH_STEP
)
var seen: Dictionary[String, bool] = {}
for ring: int in range(1, maximum_ring + 1):
var radius: float = float(ring) * FREE_PLACEMENT_SEARCH_STEP
var nearest: Dictionary = {}
var nearest_distance_squared: float = INF
for angle_index: int in FREE_PLACEMENT_SEARCH_ANGLES:
var angle: float = (
TAU * float(angle_index) / float(FREE_PLACEMENT_SEARCH_ANGLES)
)
var candidate: Dictionary = normalized_placement_transform(
decor_id,
origin_position + Vector3(
cos(angle) * radius, 0.0, sin(angle) * radius
),
yaw,
PlacementMode.FREE,
)
if candidate.is_empty() or not _mark_candidate_unseen(
candidate, seen
):
continue
if _overlaps_existing(
decor_id, candidate, ignored_instance_id
) or not _candidate_is_accepted(
candidate, candidate_validator
):
continue
var candidate_position: Vector3 = candidate["position"]
var distance_squared: float = origin_position.distance_squared_to(
candidate_position
)
if distance_squared < nearest_distance_squared:
nearest = candidate
nearest_distance_squared = distance_squared
if not nearest.is_empty():
return nearest
return {}
static func _mark_candidate_unseen(
candidate: Dictionary,
seen: Dictionary[String, bool],
) -> bool:
var position: Vector3 = candidate["position"]
var key := "%d:%d" % [
roundi(position.x * 10000.0),
roundi(position.z * 10000.0),
]
if seen.has(key):
return false
seen[key] = true
return true
static func _candidate_is_accepted(
candidate: Dictionary,
validator: Callable,
) -> bool:
return not validator.is_valid() or bool(validator.call(candidate))
static func placements_overlap(first: Dictionary, second: Dictionary) -> bool:
var first_id := StringName(str(first.get("decor_id", "")))
var second_id := StringName(str(second.get("decor_id", "")))
if (
not DecorCatalogType.has_product(first_id)
or not DecorCatalogType.has_product(second_id)
):
return true
var first_position: Vector3 = _array_to_vector3(first.get("position", []))
var second_position: Vector3 = _array_to_vector3(second.get("position", []))
var first_yaw: float = float(first.get("yaw", 0.0))
var second_yaw: float = float(second.get("yaw", 0.0))
var first_half: Vector2 = (
DecorCatalogType.get_footprint(first_id) * 0.5
)
var second_half: Vector2 = (
DecorCatalogType.get_footprint(second_id) * 0.5
)
var first_axes: Array[Vector2] = _placement_axes(first_yaw)
var second_axes: Array[Vector2] = _placement_axes(second_yaw)
var delta := Vector2(
second_position.x - first_position.x,
second_position.z - first_position.z,
)
for axis: Vector2 in [
first_axes[0], first_axes[1], second_axes[0], second_axes[1],
]:
var first_radius: float = (
absf(axis.dot(first_axes[0])) * first_half.x
+ absf(axis.dot(first_axes[1])) * first_half.y
)
var second_radius: float = (
absf(axis.dot(second_axes[0])) * second_half.x
+ absf(axis.dot(second_axes[1])) * second_half.y
)
if absf(delta.dot(axis)) >= (
first_radius + second_radius - 0.001
):
return false
return true
func _make_instance_id() -> String:
var result := "decor-%d-%d" % [
Time.get_ticks_usec(),
_next_instance_sequence,
]
_next_instance_sequence = mini(
_next_instance_sequence + 1,
MAX_REVISION,
)
return result
func _placement_index(instance_id: String) -> int:
if instance_id.is_empty():
return -1
for index: int in _placements.size():
if str(_placements[index].get("instance_id", "")) == instance_id:
return index
return -1
func _overlaps_existing(
decor_id: StringName,
normalized: Dictionary,
ignored_instance_id: String,
) -> bool:
var candidate: Dictionary = {
"decor_id": String(decor_id),
"position": _vector3_to_array(normalized["position"]),
"yaw": float(normalized["yaw"]),
}
for placement: Dictionary in _placements:
if str(placement.get("instance_id", "")) == ignored_instance_id:
continue
if placements_overlap(candidate, placement):
return true
return false
func _touch() -> void:
_revision = mini(_revision + 1, MAX_REVISION)
changed.emit()
static func _valid_mode(value: int) -> bool:
return value >= PlacementMode.GRID and value <= PlacementMode.FREE
static func _vector3_to_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
static func _array_to_vector3(value: Variant) -> Vector3:
if typeof(value) != TYPE_ARRAY or (value as Array).size() != 3:
return Vector3.INF
var components: Array = value
return Vector3(
float(components[0]),
float(components[1]),
float(components[2]),
)
static func _placement_axes(yaw: float) -> Array[Vector2]:
var cosine: float = cos(yaw)
var sine: float = sin(yaw)
return [Vector2(cosine, sine), Vector2(-sine, cosine)]

View file

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

View file

@ -60,6 +60,28 @@ func add_item(item_id: StringName, quantity: int = 1) -> bool:
return true return true
func add_item_to_storage(item_id: StringName, quantity: int = 1) -> bool:
var existing: OwnedItemType = get_owned_item(item_id)
if existing != null:
return add_item(item_id, quantity)
var item: ItemDataType = _resolve_available_item(item_id)
if (
item == null
or quantity <= 0
or quantity > (item.max_stack if item.stackable else 1)
or _inventory_layout == null
or not _inventory_layout.can_accept_item_in_storage(item_id)
):
return false
var owned := OwnedItemType.new()
owned.item_id = item_id
owned.quantity = quantity
owned.storage_slot = _first_available_storage_slot(item, _items)
_items.append(owned)
contents_changed.emit()
return true
func can_add_item(item_id: StringName, quantity: int = 1) -> bool: func can_add_item(item_id: StringName, quantity: int = 1) -> bool:
var item: ItemDataType = _resolve_available_item(item_id) var item: ItemDataType = _resolve_available_item(item_id)
if item == null or quantity <= 0: if item == null or quantity <= 0:

View file

@ -314,6 +314,25 @@ func get_fish_slots() -> Array[StringName]:
return _fish_slots.duplicate() return _fish_slots.duplicate()
func find_item_slot(item_id: StringName) -> int:
return _slots.find(item_id)
func find_first_empty_slot() -> int:
for slot_index: int in SLOT_COUNT:
if get_assignment_kind(slot_index) != AssignmentKind.EMPTY:
continue
if (
_inventory_layout == null
or _inventory_layout.get_key_at(
PlayerInventoryLayout.InventoryContainer.HOTBAR,
slot_index,
).is_empty()
):
return slot_index
return -1
func replace_state( func replace_state(
slots: Array[StringName], slots: Array[StringName],
selected_slot: int, selected_slot: int,

View file

@ -37,6 +37,7 @@ var _fish_inventory: FishInventoryType
var _item_catalog: ItemCatalogType var _item_catalog: ItemCatalogType
var _storage_capacity: PlayerCoolerCapacityType var _storage_capacity: PlayerCoolerCapacityType
var _placements: Dictionary[String, Dictionary] = {} var _placements: Dictionary[String, Dictionary] = {}
var _prepared_item_placements: Dictionary[String, Dictionary] = {}
var _reconciling: bool = false var _reconciling: bool = false
var _backpack_level: int = 0 var _backpack_level: int = 0
@ -72,7 +73,44 @@ func can_accept_item(item_id: StringName) -> bool:
if not is_managed_item(item_id): if not is_managed_item(item_id):
return true return true
var key := item_key(item_id) var key := item_key(item_id)
return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0 return (
_placements.has(key)
or _is_prepared_item_placement_available(key)
or _first_free_slot(InventoryContainer.INVENTORY) >= 0
)
func prepare_new_item_placement(
item_id: StringName,
target_container: InventoryContainer,
target_slot: int,
) -> bool:
if not is_managed_item(item_id):
return false
var key := item_key(item_id)
if (
_placements.has(key)
or not _is_slot_valid(target_container, target_slot)
or not _key_at(target_container, target_slot).is_empty()
or _prepared_slot_is_occupied(target_container, target_slot)
):
return false
_prepared_item_placements[key] = {
"container": int(target_container),
"slot": target_slot,
}
return true
func cancel_prepared_item_placement(item_id: StringName) -> void:
_prepared_item_placements.erase(item_key(item_id))
func can_accept_item_in_storage(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.STORAGE) >= 0
func can_accept_catch(catch_id: StringName = StringName()) -> bool: func can_accept_catch(catch_id: StringName = StringName()) -> bool:
@ -332,6 +370,7 @@ func restore_from_save_data(data: Dictionary) -> bool:
"container": container, "container": container,
"slot": slot, "slot": slot,
} }
_prepared_item_placements.clear()
_placements = restored _placements = restored
_reconcile(true) _reconcile(true)
return true return true
@ -340,6 +379,7 @@ func restore_from_save_data(data: Dictionary) -> bool:
func reset_to_defaults() -> void: func reset_to_defaults() -> void:
var capacity_changed := _backpack_level != 0 var capacity_changed := _backpack_level != 0
_backpack_level = 0 _backpack_level = 0
_prepared_item_placements.clear()
_placements.clear() _placements.clear()
_reconcile(true) _reconcile(true)
if capacity_changed: if capacity_changed:
@ -402,8 +442,17 @@ func _reconcile(force_signal: bool) -> void:
for key: String in expected: for key: String in expected:
if _placements.has(key): if _placements.has(key):
continue continue
var prepared: Dictionary = _prepared_item_placements.get(key, {})
var target_container := InventoryContainer.INVENTORY var target_container := InventoryContainer.INVENTORY
var target_slot := _first_free_slot(target_container) var target_slot: int = -1
if _is_prepared_item_placement_available(key):
target_container = int(
prepared["container"]
) as InventoryContainer
target_slot = int(prepared["slot"])
_prepared_item_placements.erase(key)
if target_slot < 0:
target_slot = _first_free_slot(target_container)
if target_slot < 0: if target_slot < 0:
target_container = InventoryContainer.STORAGE target_container = InventoryContainer.STORAGE
target_slot = _first_free_slot(target_container) target_slot = _first_free_slot(target_container)
@ -484,6 +533,37 @@ func _first_free_slot(container: InventoryContainer) -> int:
return -1 return -1
func _is_prepared_item_placement_available(key: String) -> bool:
var placement: Dictionary = _prepared_item_placements.get(key, {})
if placement.is_empty():
return false
var container: int = int(placement.get("container", -1))
var slot: int = int(placement.get("slot", -1))
if (
container not in [
InventoryContainer.INVENTORY,
InventoryContainer.STORAGE,
InventoryContainer.HOTBAR,
]
or not _is_slot_valid(container as InventoryContainer, slot)
):
return false
return _key_at(container as InventoryContainer, slot).is_empty()
func _prepared_slot_is_occupied(
container: InventoryContainer,
slot: int,
) -> bool:
for placement: Dictionary in _prepared_item_placements.values():
if (
int(placement.get("container", -1)) == int(container)
and int(placement.get("slot", -1)) == slot
):
return true
return false
func _key_at(container: InventoryContainer, slot: int) -> String: func _key_at(container: InventoryContainer, slot: int) -> String:
for key: String in _placements: for key: String in _placements:
var placement: Dictionary = _placements[key] var placement: Dictionary = _placements[key]

View file

@ -0,0 +1,17 @@
[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 = &"basic_cube"
display_name = "basic cube"
description = "a simple piece of decor for testing your RV layout."
category = 6
icon = ExtResource("2_icon")
stackable = true
max_stack = 32
usable = false
equippable = true
hotbar_allowed = true

View file

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

View file

@ -37,6 +37,9 @@ const FishingShopType = preload("res://ui/fishing_shop.gd")
const FishingShopInteractionType = preload( const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd" "res://world/fishing_shop_interaction.gd"
) )
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PlayerStorageInteractionType = preload( const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd" "res://world/player_storage_interaction.gd"
) )
@ -70,6 +73,7 @@ const NetworkFishingServiceType = preload(
"res://network/network_fishing_service.gd" "res://network/network_fishing_service.gd"
) )
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const NetworkSaleServiceType = preload( const NetworkSaleServiceType = preload(
"res://network/network_sale_service.gd" "res://network/network_sale_service.gd"
) )
@ -123,6 +127,14 @@ const GatheringControllerType = preload(
const NetworkWorldSpawnServiceType = preload( const NetworkWorldSpawnServiceType = preload(
"res://network/network_world_spawn_service.gd" "res://network/network_world_spawn_service.gd"
) )
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
const HomeWorldServiceType = preload("res://homes/home_world_service.gd")
const NetworkHomeServiceType = preload(
"res://network/network_home_service.gd"
)
const HomeDecorControllerType = preload(
"res://homes/home_decor_controller.gd"
)
const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_SILENCE_DB: float = -80.0
const TITLE_MUSIC_PATH: String = ( const TITLE_MUSIC_PATH: String = (
@ -153,6 +165,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
@onready var _game_ui: GameUIType = %GameUI @onready var _game_ui: GameUIType = %GameUI
@onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery @onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery
@onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager @onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager
@onready var _home_state: PlayerHomeStateType = %PlayerHomeState
@onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager @onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager
@onready var _controller_mapping_manager: ControllerMappingManagerType = ( @onready var _controller_mapping_manager: ControllerMappingManagerType = (
%ControllerMappingManager %ControllerMappingManager
@ -212,6 +225,10 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
) )
@onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService @onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService
@onready var _network_shop: NetworkShopServiceType = %NetworkShopService @onready var _network_shop: NetworkShopServiceType = %NetworkShopService
@onready var _network_home: NetworkHomeServiceType = %NetworkHomeService
@onready var _home_decor_controller: HomeDecorControllerType = (
%HomeDecorController
)
@onready var _network_item_use: NetworkItemUseServiceType = ( @onready var _network_item_use: NetworkItemUseServiceType = (
%NetworkItemUseService %NetworkItemUseService
) )
@ -234,6 +251,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
@onready var _players_root: Node3D = $Players @onready var _players_root: Node3D = $Players
@onready var _surface_drawings_root: Node3D = $SurfaceDrawings @onready var _surface_drawings_root: Node3D = $SurfaceDrawings
@onready var _world_gatherables_root: Node3D = $WorldGatherables @onready var _world_gatherables_root: Node3D = $WorldGatherables
@onready var _home_world: HomeWorldServiceType = %HomeWorldService
@onready var _title_background: ColorRect = %TitleBackground @onready var _title_background: ColorRect = %TitleBackground
@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop @onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
@onready var _shop_backdrop: ColorRect = %ShopBackdrop @onready var _shop_backdrop: ColorRect = %ShopBackdrop
@ -241,6 +259,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
var _gameplay_started: bool = false var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new() var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType var _shop_interaction: FishingShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _storage_interaction: PlayerStorageInteractionType var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween var _title_music_tween: Tween
var _title_music_transition_generation: int = 0 var _title_music_transition_generation: int = 0
@ -740,6 +759,7 @@ func _initialize_application(dedicated: bool) -> void:
_player.inventory_layout, _player.inventory_layout,
) )
_shop_interaction = _test_world.get_fishing_shop() _shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_storage_interaction = _test_world.get_player_storage() _storage_interaction = _test_world.get_player_storage()
if not dedicated: if not dedicated:
_shop_interaction.setup_local_player(_player) _shop_interaction.setup_local_player(_player)
@ -749,6 +769,10 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.set_shop_npc_player_in_range( _game_ui.set_shop_npc_player_in_range(
_shop_interaction.is_local_player_in_range() _shop_interaction.is_local_player_in_range()
) )
_decor_shop_interaction.setup_local_player(_player)
_decor_shop_interaction.local_player_range_changed.connect(
_on_decor_shop_range_changed
)
_storage_interaction.setup_local_player(_player) _storage_interaction.setup_local_player(_player)
_storage_interaction.local_player_range_changed.connect( _storage_interaction.local_player_range_changed.connect(
_on_storage_range_changed _on_storage_range_changed
@ -770,6 +794,23 @@ func _initialize_application(dedicated: bool) -> void:
_world_weather, _world_weather,
_player_jobs, _player_jobs,
_player, _player,
_home_state,
)
_home_world.setup(
_test_world,
_player_spawn_service,
_network_session,
dedicated,
)
_network_home.setup(
_network_session,
_player_spawn_service,
_home_state,
_save_manager,
_home_world,
_player.bag,
_player.inventory_layout,
_player.hotbar,
) )
_player_jobs.set_save_manager(_save_manager) _player_jobs.set_save_manager(_save_manager)
_save_manager.set_autosave_enabled(false) _save_manager.set_autosave_enabled(false)
@ -903,6 +944,9 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager, _save_manager,
_asset_reservations, _asset_reservations,
_player.inventory_layout, _player.inventory_layout,
_home_state,
_network_home,
_decor_shop_interaction,
) )
_fishing_spot.setup( _fishing_spot.setup(
_player, _player,
@ -920,6 +964,7 @@ func _initialize_application(dedicated: bool) -> void:
_network_fishing, _network_fishing,
_world_time, _world_time,
_world_weather, _world_weather,
Callable(_test_world, "get_fishable_water_regions"),
) )
if dedicated: if dedicated:
return return
@ -941,11 +986,13 @@ func _initialize_application(dedicated: bool) -> void:
_player.fishing_upgrades, _player.fishing_upgrades,
_shop_interaction, _shop_interaction,
_storage_interaction, _storage_interaction,
_decor_shop_interaction,
_player.item_effects, _player.item_effects,
_player.cooler_capacity, _player.cooler_capacity,
_network_session, _network_session,
_network_sale, _network_sale,
_network_shop, _network_shop,
_home_state,
_network_chat, _network_chat,
_network_profile, _network_profile,
_player_spawn_service, _player_spawn_service,
@ -963,6 +1010,21 @@ func _initialize_application(dedicated: bool) -> void:
_test_world.get_world_environment(), _test_world.get_world_environment(),
_test_world.get_sun(), _test_world.get_sun(),
) )
_home_decor_controller.setup(
_player,
_player.hotbar,
_player.bag,
_home_state,
_network_home,
_home_world,
_fishing_spot,
_game_ui.get_home_decor_toolbar(),
)
_game_ui.get_player_storage().decorate_requested.connect(
_home_decor_controller.begin_decorating
)
_network_home.local_space_changed.connect(_on_local_home_space_changed)
_game_ui.rv_storage_requested.connect(_on_rv_storage_requested)
_game_ui.setup_data_and_identity( _game_ui.setup_data_and_identity(
_data_root, _data_root,
_identity_backups, _identity_backups,
@ -1006,7 +1068,8 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui, _game_ui,
_game_ui.get_screen_fade(), _game_ui.get_screen_fade(),
_test_world.get_player_water_triggers(), _test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points() _test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
) )
_ui_pixelation.effective_pixel_size_changed.connect( _ui_pixelation.effective_pixel_size_changed.connect(
_on_effective_ui_pixel_size_changed _on_effective_ui_pixel_size_changed
@ -1473,6 +1536,9 @@ func _input(event: InputEvent) -> void:
if _game_ui.get_player_storage().consume_escape(): if _game_ui.get_player_storage().consume_escape():
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if _game_ui.get_decor_shop().consume_escape():
get_viewport().set_input_as_handled()
return
if fishing_shop.consume_escape(): if fishing_shop.consume_escape():
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
@ -1571,19 +1637,50 @@ func _is_pause_open_request(event: InputEvent) -> bool:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if (
_gameplay_started
and event.is_action_pressed("fish_primary")
and not (event is InputEventKey and event.echo)
and _fishing_spot.try_use_active_non_fishing_item()
):
get_viewport().set_input_as_handled()
return
if ( if (
not _gameplay_started not _gameplay_started
or not event.is_action_pressed("interact") or not event.is_action_pressed("interact")
or (event is InputEventKey and event.echo) or (event is InputEventKey and event.echo)
): ):
return return
var home_owner: String = _network_home.get_local_home_owner_fingerprint()
if not home_owner.is_empty():
if _home_world.is_avatar_near_interior_exit(_player, home_owner):
if _network_home.request_exit():
_game_ui.set_storage_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_network_home.can_local_decorate()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_player_storage().open_storage_from_home(true)
):
_game_ui.set_storage_prompt_visible(false)
get_viewport().set_input_as_handled()
return
var nearby_home_owner: String = _home_world.find_nearby_exterior_owner(
_player
)
if not nearby_home_owner.is_empty():
if _network_home.request_enter(nearby_home_owner):
_game_ui.set_storage_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if ( if (
_storage_interaction != null _decor_shop_interaction != null
and _storage_interaction.is_local_player_in_range() and _decor_shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop() and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_player_storage().open_storage() and _game_ui.get_decor_shop().open_shop()
): ):
_game_ui.set_storage_prompt_visible(false) _game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if ( if (
@ -1606,37 +1703,42 @@ func _process(delta: float) -> void:
) )
_print_dedicated_metrics() _print_dedicated_metrics()
return return
var show_shop_prompt := _can_show_shop_prompt() var show_decor_shop_prompt := _can_show_decor_shop_prompt()
var show_shop_prompt := show_decor_shop_prompt or _can_show_shop_prompt()
var shop_prompt_anchor := ( var shop_prompt_anchor := (
_shop_interaction.get_prompt_anchor_position() _decor_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null if show_decor_shop_prompt
else Vector3.ZERO else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
)
) )
_game_ui.set_shop_prompt_visible( _game_ui.set_shop_prompt_visible(
show_shop_prompt, show_shop_prompt,
shop_prompt_anchor, shop_prompt_anchor,
) )
var show_storage_prompt := _can_show_storage_prompt() var home_prompt := _get_home_prompt()
var storage_prompt_anchor := (
_storage_interaction.get_prompt_anchor_position()
if _storage_interaction != null
else Vector3.ZERO
)
_game_ui.set_storage_prompt_visible( _game_ui.set_storage_prompt_visible(
show_storage_prompt, not home_prompt.is_empty(),
storage_prompt_anchor, home_prompt.get("anchor", Vector3.ZERO),
str(home_prompt.get("label", "enter RV")),
)
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
) )
func _apply_runtime_settings(settings: PlayerSettingsType) -> void: func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
if settings == null: if settings == null:
return return
var requested_window_mode: DisplayServer.WindowMode = ( var current_window_mode: DisplayServer.WindowMode = (
DisplayServer.WINDOW_MODE_FULLSCREEN DisplayServer.window_get_mode()
if settings.fullscreen_enabled
else DisplayServer.WINDOW_MODE_WINDOWED
) )
if DisplayServer.window_get_mode() != requested_window_mode: var requested_window_mode: DisplayServer.WindowMode = resolve_window_mode(
settings.fullscreen_enabled,
current_window_mode,
)
if current_window_mode != requested_window_mode:
DisplayServer.window_set_mode(requested_window_mode) DisplayServer.window_set_mode(requested_window_mode)
_apply_world_pixelation(settings.world_pixel_size) _apply_world_pixelation(settings.world_pixel_size)
_ui_pixelation.set_pixel_size(settings.ui_pixel_size) _ui_pixelation.set_pixel_size(settings.ui_pixel_size)
@ -1663,6 +1765,25 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
) )
static func resolve_window_mode(
fullscreen_enabled: bool,
current_mode: DisplayServer.WindowMode,
) -> DisplayServer.WindowMode:
var is_fullscreen: bool = current_mode in [
DisplayServer.WINDOW_MODE_FULLSCREEN,
DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN,
]
if fullscreen_enabled:
return (
current_mode
if is_fullscreen
else DisplayServer.WINDOW_MODE_FULLSCREEN
)
if is_fullscreen:
return DisplayServer.WINDOW_MODE_WINDOWED
return current_mode
func _apply_world_pixelation(pixel_size: int) -> void: func _apply_world_pixelation(pixel_size: int) -> void:
var root_viewport: Viewport = get_viewport() var root_viewport: Viewport = get_viewport()
root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST
@ -2344,17 +2465,9 @@ func _on_remote_recovery_requested(
peer_id, peer_id,
"Fishing attempt ended." "Fishing attempt ended."
) )
var target_position: Vector3 = _test_world.get_player_spawn_transform().origin var target_position := _test_world.get_water_recovery_position(
var nearest_distance: float = INF entry_position
for point: SafeRespawnPoint in _test_world.get_safe_respawn_points(): )
if point == null or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
target_position.y += _water_recovery.respawn_height_offset target_position.y += _water_recovery.respawn_height_offset
avatar.global_position = target_position avatar.global_position = target_position
avatar.velocity = Vector3.ZERO avatar.velocity = Vector3.ZERO
@ -2427,11 +2540,13 @@ func _apply_world(
if _application_initialized: if _application_initialized:
_refresh_active_world_bindings() _refresh_active_world_bindings()
_network_world_spawns.refresh_world_context() _network_world_spawns.refresh_world_context()
_network_home.refresh_world_presentations()
if _application_initialized and not _dedicated_runtime: if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context( _water_recovery.update_world_context(
spawn_transform, spawn_transform,
_test_world.get_player_water_triggers(), _test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points(), _test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
) )
_shoreline_ambience.configure( _shoreline_ambience.configure(
_player, _player,
@ -2455,10 +2570,19 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction.local_player_range_changed.disconnect( _storage_interaction.local_player_range_changed.disconnect(
_on_storage_range_changed _on_storage_range_changed
) )
if is_instance_valid(_decor_shop_interaction):
if _decor_shop_interaction.local_player_range_changed.is_connected(
_on_decor_shop_range_changed
):
_decor_shop_interaction.local_player_range_changed.disconnect(
_on_decor_shop_range_changed
)
_shop_interaction = _test_world.get_fishing_shop() _shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_storage_interaction = _test_world.get_player_storage() _storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction) _network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction) _network_shop.set_shop_interaction(_shop_interaction)
_network_shop.set_decor_shop_interaction(_decor_shop_interaction)
if _dedicated_runtime: if _dedicated_runtime:
return return
if _shop_interaction != null: if _shop_interaction != null:
@ -2471,9 +2595,15 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction.local_player_range_changed.connect( _storage_interaction.local_player_range_changed.connect(
_on_storage_range_changed _on_storage_range_changed
) )
if _decor_shop_interaction != null:
_decor_shop_interaction.setup_local_player(_player)
_decor_shop_interaction.local_player_range_changed.connect(
_on_decor_shop_range_changed
)
_game_ui.set_world_interactions( _game_ui.set_world_interactions(
_shop_interaction, _shop_interaction,
_storage_interaction, _storage_interaction,
_decor_shop_interaction,
) )
_on_shop_range_changed( _on_shop_range_changed(
_shop_interaction != null _shop_interaction != null
@ -2483,6 +2613,10 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction != null _storage_interaction != null
and _storage_interaction.is_local_player_in_range() and _storage_interaction.is_local_player_in_range()
) )
_on_decor_shop_range_changed(
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
func _on_water_recovery_starting() -> void: func _on_water_recovery_starting() -> void:
@ -2496,6 +2630,7 @@ func _on_water_recovery_starting() -> void:
_game_ui.close_player_menu_for_water_recovery() _game_ui.close_player_menu_for_water_recovery()
_game_ui.get_pause_menu().close_for_water_recovery() _game_ui.get_pause_menu().close_for_water_recovery()
_game_ui.get_fishing_shop().close_for_water_recovery() _game_ui.get_fishing_shop().close_for_water_recovery()
_game_ui.get_decor_shop().close_for_water_recovery()
_game_ui.get_player_storage().close_for_water_recovery() _game_ui.get_player_storage().close_for_water_recovery()
@ -2557,6 +2692,12 @@ func _on_active_hotbar_item_changed(
and item.is_available() and item.is_available()
and _player.bag.owns_item(item_id) and _player.bag.owns_item(item_id)
) )
var active_is_decor: bool = (
DecorCatalogType.has_product(item_id)
and item != null
and item.is_available()
and _player.bag.owns_item(item_id)
)
_player.set_active_fishing_rod( _player.set_active_fishing_rod(
item as FishingRodDataType if active_is_rod else null, item as FishingRodDataType if active_is_rod else null,
true, true,
@ -2567,6 +2708,7 @@ func _on_active_hotbar_item_changed(
) )
_player.set_active_catching_net(active_is_catching_net) _player.set_active_catching_net(active_is_catching_net)
_player.set_active_shovel(active_is_shovel) _player.set_active_shovel(active_is_shovel)
_player.set_active_decor_package(active_is_decor)
_game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit) _game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit)
_network_item_use.submit_local_equipped(item_id, active_is_rod or ( _network_item_use.submit_local_equipped(item_id, active_is_rod or (
item != null and _player.bag.owns_item(item_id) item != null and _player.bag.owns_item(item_id)
@ -2703,7 +2845,13 @@ func _exit_tree() -> void:
func _on_shop_range_changed(in_range: bool) -> void: func _on_shop_range_changed(in_range: bool) -> void:
_game_ui.set_shop_npc_player_in_range(in_range) _game_ui.set_shop_npc_player_in_range(
in_range
or (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
)
if not in_range: if not in_range:
_game_ui.get_fishing_shop().close_for_range_exit() _game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_shop_prompt()) _game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
@ -2712,7 +2860,50 @@ func _on_shop_range_changed(in_range: bool) -> void:
func _on_storage_range_changed(in_range: bool) -> void: func _on_storage_range_changed(in_range: bool) -> void:
if not in_range: if not in_range:
_game_ui.get_player_storage().close_for_range_exit() _game_ui.get_player_storage().close_for_range_exit()
_game_ui.set_storage_prompt_visible(_can_show_storage_prompt()) var prompt := _get_home_prompt()
_game_ui.set_storage_prompt_visible(
not prompt.is_empty(),
prompt.get("anchor", Vector3.ZERO),
str(prompt.get("label", "enter RV")),
)
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
)
func _on_decor_shop_range_changed(in_range: bool) -> void:
_game_ui.set_shop_npc_player_in_range(
in_range
or (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
)
if not in_range:
_game_ui.get_decor_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(
_can_show_shop_prompt() or _can_show_decor_shop_prompt()
)
func _on_local_home_space_changed(
space_id: StringName,
_owner_fingerprint: String,
) -> void:
var inside_rv: bool = String(space_id).begins_with("rv:")
_fishing_spot.set_fishing_environment_enabled(not inside_rv)
_game_ui.set_rv_interior_camera_mode(
inside_rv
)
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
)
func _on_rv_storage_requested() -> void:
if not _can_show_rv_storage_button():
return
_game_ui.get_player_storage().open_storage_from_home(true)
func _can_show_shop_prompt() -> bool: func _can_show_shop_prompt() -> bool:
@ -2721,18 +2912,66 @@ func _can_show_shop_prompt() -> bool:
and _shop_interaction != null and _shop_interaction != null
and _shop_interaction.is_local_player_in_range() and _shop_interaction.is_local_player_in_range()
and not _game_ui.get_fishing_shop().visible and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)
func _can_show_decor_shop_prompt() -> bool:
return (
_gameplay_started
and _decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _water_recovery.is_recovery_active() and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop() and _fishing_spot.can_open_fishing_shop()
) )
func _can_show_storage_prompt() -> bool: func _can_show_storage_prompt() -> bool:
return not _get_home_prompt().is_empty()
func _can_show_rv_storage_button() -> bool:
return ( return (
_gameplay_started _gameplay_started
and _storage_interaction != null and not _network_home.get_local_home_owner_fingerprint().is_empty()
and _storage_interaction.is_local_player_in_range() and _network_home.can_local_decorate()
and not _game_ui.get_player_storage().visible
and not _game_ui.get_fishing_shop().visible
and not _water_recovery.is_recovery_active() and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop() and _fishing_spot.can_open_fishing_shop()
) )
func _get_home_prompt() -> Dictionary:
if (
not _gameplay_started
or _game_ui.get_player_storage().visible
or _game_ui.get_fishing_shop().visible
or _game_ui.get_decor_shop().visible
or _water_recovery.is_recovery_active()
or not _fishing_spot.can_open_fishing_shop()
):
return {}
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
if not owner_fingerprint.is_empty():
if _home_world.is_avatar_near_interior_exit(
_player, owner_fingerprint
):
return {
"label": "leave RV",
"anchor": _home_world.get_interior_prompt_position(
owner_fingerprint
),
}
return {}
var nearby_owner: String = _home_world.find_nearby_exterior_owner(_player)
if nearby_owner.is_empty():
return {}
return {
"label": "enter RV",
"anchor": _home_world.get_exterior_prompt_position(nearby_owner),
}

View file

@ -58,6 +58,10 @@
[ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"] [ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"]
[ext_resource type="Script" path="res://network/network_world_spawn_service.gd" id="59_world_spawns"] [ext_resource type="Script" path="res://network/network_world_spawn_service.gd" id="59_world_spawns"]
[ext_resource type="Script" path="res://gathering/gathering_controller.gd" id="60_gathering_controller"] [ext_resource type="Script" path="res://gathering/gathering_controller.gd" id="60_gathering_controller"]
[ext_resource type="Script" path="res://homes/player_home_state.gd" id="61_home_state"]
[ext_resource type="Script" path="res://homes/home_world_service.gd" id="62_home_world"]
[ext_resource type="Script" path="res://network/network_home_service.gd" id="63_network_home"]
[ext_resource type="Script" path="res://homes/home_decor_controller.gd" id="64_home_decor_controller"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_grass_native"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_grass_native"]
shader = ExtResource("42_menu_pattern") shader = ExtResource("42_menu_pattern")
@ -259,6 +263,14 @@ script = ExtResource("22_network_sale")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("23_network_shop") script = ExtResource("23_network_shop")
[node name="NetworkHomeService" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("63_network_home")
[node name="HomeDecorController" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("64_home_decor_controller")
[node name="NetworkItemUseService" type="Node" parent="." unique_id=255154670] [node name="NetworkItemUseService" type="Node" parent="." unique_id=255154670]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("24_network_item") script = ExtResource("24_network_item")
@ -322,6 +334,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081)
[node name="WorldGatherables" type="Node3D" parent="."] [node name="WorldGatherables" type="Node3D" parent="."]
[node name="HomeWorldService" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("62_home_world")
[node name="GatheringController" type="Node3D" parent="."] [node name="GatheringController" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("60_gathering_controller") script = ExtResource("60_gathering_controller")
@ -338,6 +354,10 @@ script = ExtResource("8_recovery")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("9_save") script = ExtResource("9_save")
[node name="PlayerHomeState" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("61_home_state")
[node name="PlayerSettingsManager" type="Node" parent="." unique_id=906991991] [node name="PlayerSettingsManager" type="Node" parent="." unique_id=906991991]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("10_settings") script = ExtResource("10_settings")

View file

@ -15,8 +15,12 @@ func _enter_tree() -> void:
func _on_node_added(node: Node) -> void: func _on_node_added(node: Node) -> void:
enforce_node(node) enforce_node(node)
# Some runtime presenters assign their mesh or material immediately after # Some runtime presenters assign their mesh or material immediately after
# add_child(). Recheck once deferred so those resources are covered too. # add_child(). Recheck those 3D resource owners once deferred so the newly
call_deferred("_enforce_deferred_node", weakref(node)) # assigned resources are covered too. CanvasItems keep their filter setting
# when their texture changes, so deferring every dynamic UI node only floods
# the message queue during large inventory and catalog refreshes.
if _needs_deferred_resource_check(node):
call_deferred("_enforce_deferred_node", weakref(node))
func _enforce_existing_tree() -> void: func _enforce_existing_tree() -> void:
@ -29,6 +33,15 @@ func _enforce_deferred_node(node_ref: WeakRef) -> void:
enforce_node(node) enforce_node(node)
static func _needs_deferred_resource_check(node: Node) -> bool:
return (
node is GeometryInstance3D
or node is MultiMeshInstance3D
or node is GPUParticles3D
or node is CPUParticles3D
)
static func enforce_subtree(node: Node) -> void: static func enforce_subtree(node: Node) -> void:
enforce_node(node) enforce_node(node)
for child: Node in node.get_children(): for child: Node in node.get_children():

View file

@ -0,0 +1,188 @@
class_name NetworkHomeProtocol
extends RefCounted
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
const CAPABILITY: StringName = &"portable_home_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
const MAX_TRANSITION_COORDINATE: float = 10000.0
const WORLD_SPACE: StringName = &"world"
enum TransitionAction {
ENTER,
EXIT,
}
static func make_space_id(owner_fingerprint: String) -> StringName:
return StringName("rv:%s" % owner_fingerprint)
static func manifest_digest(home_data: Dictionary) -> String:
var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data(home_data)
if sanitized.is_empty():
return ""
var canonical_owned: Array[Dictionary] = []
var owned: Dictionary = sanitized["owned_decor"]
var owned_ids: Array[String] = []
for value: Variant in owned.keys():
owned_ids.append(str(value))
owned_ids.sort()
for decor_id: String in owned_ids:
canonical_owned.append({
"id": decor_id,
"quantity": int(owned[decor_id]),
})
var placements: Array[Dictionary] = []
for value: Variant in sanitized["placements"]:
placements.append((value as Dictionary).duplicate(true))
placements.sort_custom(func(first: Dictionary, second: Dictionary) -> bool:
return str(first["instance_id"]) < str(second["instance_id"])
)
var canonical: Dictionary = {
"schema_version": int(sanitized["schema_version"]),
"tier": int(sanitized["tier"]),
"exterior_id": str(sanitized["exterior_id"]),
"privacy": int(sanitized["privacy"]),
"owned": canonical_owned,
"placements": placements,
"revision": int(sanitized["revision"]),
"next_instance_sequence": int(sanitized["next_instance_sequence"]),
}
return JSON.stringify(canonical).sha256_text()
static func manifest_signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("owner_fingerprint", "")),
int((data.get("home", {}) as Dictionary).get("revision", -1)),
str(data.get("digest", "")),
]
static func validate_manifest_request(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
if (
not _valid_id(data.get("request_id"))
or not _valid_id(data.get("session_id"))
or not NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
or typeof(data.get("home")) != TYPE_DICTIONARY
or typeof(data.get("digest")) != TYPE_STRING
or str(data["digest"]).length() != 64
or typeof(data.get("sender_signature")) != TYPE_PACKED_BYTE_ARRAY
):
return false
var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
)
return (
not sanitized.is_empty()
and manifest_digest(sanitized) == str(data["digest"])
)
static func validate_manifest_broadcast(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
return (
typeof(data.get("owner_peer_id")) == TYPE_INT
and int(data["owner_peer_id"]) > 0
and NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
and typeof(data.get("home")) == TYPE_DICTIONARY
and not PlayerHomeStateType.sanitize_save_data(data["home"]).is_empty()
and _valid_transform(data.get("exterior_transform"))
and typeof(data.get("interior_index")) == TYPE_INT
and int(data["interior_index"]) >= 0
and int(data["interior_index"]) < 128
)
static func transition_signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
int(data.get("action", -1)),
str(data.get("owner_fingerprint", "")),
]
static func validate_transition_request(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
return (
_valid_id(data.get("request_id"))
and _valid_id(data.get("session_id"))
and typeof(data.get("action")) == TYPE_INT
and int(data["action"]) >= TransitionAction.ENTER
and int(data["action"]) <= TransitionAction.EXIT
and NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
)
static func transform_to_array(value: Transform3D) -> Array[float]:
return [
value.basis.x.x, value.basis.x.y, value.basis.x.z,
value.basis.y.x, value.basis.y.y, value.basis.y.z,
value.basis.z.x, value.basis.z.y, value.basis.z.z,
value.origin.x, value.origin.y, value.origin.z,
]
static func array_to_transform(value: Variant) -> Transform3D:
if not validate_transition_transform(value):
return Transform3D()
var fields: Array = value
return Transform3D(
Basis(
Vector3(float(fields[0]), float(fields[1]), float(fields[2])),
Vector3(float(fields[3]), float(fields[4]), float(fields[5])),
Vector3(float(fields[6]), float(fields[7]), float(fields[8])),
),
Vector3(float(fields[9]), float(fields[10]), float(fields[11])),
)
static func validate_transition_transform(value: Variant) -> bool:
if not _valid_transform(value):
return false
var fields: Array = value
return (
absf(float(fields[9])) <= MAX_TRANSITION_COORDINATE
and absf(float(fields[10])) <= MAX_TRANSITION_COORDINATE
and absf(float(fields[11])) <= MAX_TRANSITION_COORDINATE
)
static func _valid_transform(value: Variant) -> bool:
if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12:
return false
for component: Variant in value:
if (
typeof(component) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(component))
):
return false
return true
static func _valid_id(value: Variant) -> bool:
return (
typeof(value) == TYPE_STRING
and not str(value).is_empty()
and str(value).length() <= MAX_ID_LENGTH
)

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -5,6 +5,7 @@ const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const MAX_LEDGER_ENTRIES: int = 64 const MAX_LEDGER_ENTRIES: int = 64
@ -394,6 +395,10 @@ func _apply_equipped(data: Dictionary) -> void:
item_id == FishingShopStockType.STANDARD_SHOVEL_ID item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and bool(data["owns_item"]), and bool(data["owns_item"]),
) )
avatar.set_active_decor_package(
DecorCatalogType.has_product(item_id)
and bool(data["owns_item"]),
)
equipped_state_changed.emit( equipped_state_changed.emit(
peer_id, StringName(str(data["item_id"])), int(data["category"]) peer_id, StringName(str(data["item_id"])), int(data["category"])
) )

View file

@ -3,7 +3,7 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd") const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 11 const PROTOCOL_VERSION: int = 12
const GAME_BUILD: String = "prealpha" const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -17,9 +17,9 @@ const MAX_SIGNATURE_LENGTH: int = 2048
# 8 reliable ordered session chat, 9 reliable private session mail, # 8 reliable ordered session chat, 9 reliable private session mail,
# 10 observer-only fishing summaries, 11 reliable movement animation state, # 10 observer-only fishing summaries, 11 reliable movement animation state,
# 12 reliable fish showcase state, 13 reliable world-spawn events, # 12 reliable fish showcase state, 13 reliable world-spawn events,
# 14 reliable artwork state, 15 world-spawn snapshots, and 16 reliable # 14 reliable artwork state, 15 world-spawn snapshots, 16 reliable fishing
# fishing lifecycle. Bulk world/art payloads must not head-of-line block held # lifecycle, and 17 reliable portable-home state. Bulk world/art payloads must
# items, fishing, or animation presentation. # not head-of-line block held items, fishing, animation, or home transitions.
const SALE_RELIABLE_CHANNEL: int = 5 const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6 const SHOP_RELIABLE_CHANNEL: int = 6
const ITEM_RELIABLE_CHANNEL: int = 7 const ITEM_RELIABLE_CHANNEL: int = 7
@ -30,7 +30,8 @@ const SHOWCASE_RELIABLE_CHANNEL: int = 12
const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13 const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13
const DRAWING_RELIABLE_CHANNEL: int = 14 const DRAWING_RELIABLE_CHANNEL: int = 14
const FISHING_RELIABLE_CHANNEL: int = 16 const FISHING_RELIABLE_CHANNEL: int = 16
const ENET_CHANNEL_COUNT: int = 17 const HOME_RELIABLE_CHANNEL: int = 17
const ENET_CHANNEL_COUNT: int = 18
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1" const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1" const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1"
@ -45,6 +46,7 @@ const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1"
const FRIENDS_CAPABILITY: String = "friends_v1" const FRIENDS_CAPABILITY: String = "friends_v1"
const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2" const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2"
const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2" const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2"
const HOME_CAPABILITY: String = "portable_home_v1"
const DEFAULT_WORLD_SEED: int = 13001 const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646 const MAX_WORLD_SEED: int = 2147483646
@ -202,6 +204,7 @@ static func make_client_hello(
FRIENDS_CAPABILITY, FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY, MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY, FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
]), ]),
"cosmetic_snapshot": cosmetic_snapshot, "cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint, "identity_fingerprint": identity_fingerprint,
@ -342,6 +345,7 @@ static func make_server_hello(
FRIENDS_CAPABILITY, FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY, MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY, FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",

View file

@ -28,6 +28,7 @@ const MAX_PENDING_MOVEMENT_INPUTS: int = 96
const ANIMATION_REFRESH_INTERVAL: float = 1.0 const ANIMATION_REFRESH_INTERVAL: float = 1.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647 const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25 const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25
const SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE: float = 12.0
const MOVEMENT_FLAG_JUMP: int = 1 << 0 const MOVEMENT_FLAG_JUMP: int = 1 << 0
const MOVEMENT_FLAG_SPRINT: int = 1 << 1 const MOVEMENT_FLAG_SPRINT: int = 1 << 1
@ -124,6 +125,9 @@ var _pending_movement_inputs: Array[Dictionary] = []
var _snapshot_accumulator: float = 0.0 var _snapshot_accumulator: float = 0.0
var _movement_snapshot_tick: int = 0 var _movement_snapshot_tick: int = 0
var _last_local_snapshot_received_msec: int = 0 var _last_local_snapshot_received_msec: int = 0
var _local_space_transition_guard_active: bool = false
var _local_space_transition_guard_position: Vector3 = Vector3.ZERO
var _local_space_transition_minimum_ack: int = 0
var _animation_refresh_accumulator: float = 0.0 var _animation_refresh_accumulator: float = 0.0
var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {}
var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {}
@ -141,6 +145,7 @@ var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray() var _server_capabilities: PackedStringArray = PackedStringArray()
var _peer_space_ids: Dictionary[int, StringName] = {}
var _profile_ready: bool = false var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore var _host_identity: HostIdentityStore
@ -334,6 +339,7 @@ func _register_player_host() -> void:
NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]), ]),
) )
_registry.update_appearance(1, _local_appearance_snapshot) _registry.update_appearance(1, _local_appearance_snapshot)
@ -661,6 +667,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",
@ -685,6 +692,55 @@ func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids() return _registry.get_peer_ids()
func set_peer_space(peer_id: int, space_id: StringName) -> bool:
if not _registry.has_peer(peer_id) or space_id.is_empty():
return false
_peer_space_ids[peer_id] = space_id
return true
func get_peer_space(peer_id: int) -> StringName:
return _peer_space_ids.get(peer_id, &"world")
func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool:
return get_peer_space(first_peer_id) == get_peer_space(second_peer_id)
func apply_authoritative_space_transition(
peer_id: int,
target_transform: Transform3D,
acknowledged_input: int,
) -> bool:
if not target_transform.origin.is_finite():
return false
var local_peer_id: int = get_local_peer_id()
var local_transition: bool = peer_id == local_peer_id
var avatar: Player = _spawn_service.get_avatar(peer_id)
# Remote home transitions can arrive before their player-spawn message. Keep
# the new space assignment so the later spawn inherits correct visibility;
# only a missing local avatar is a transition failure.
if avatar == null:
return not local_transition
avatar.apply_network_space_transition(target_transform, local_transition)
if (
local_transition
and state == State.JOINED_CLIENT
):
# Movement snapshots use another ENet channel and a pre-transition audit
# can therefore arrive after the reliable home transition. Clear local
# prediction and reject those old-world snapshots until the host publishes
# its first audit from the new space.
_pending_movement_inputs.clear()
_last_local_snapshot_received_msec = 0
_last_input_state_hash = 0
_idle_input_accumulator = IDLE_INPUT_INTERVAL
_local_space_transition_guard_active = true
_local_space_transition_guard_position = target_transform.origin
_local_space_transition_minimum_ack = maxi(acknowledged_input, 0)
return true
func get_peer_rtt_ms(peer_id: int) -> int: func get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1: if peer_id == 1:
return 0 return 0
@ -1030,6 +1086,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_identity_challenges.erase(peer_id) _pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id) _authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id) _operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id) _last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id) _pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "") var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
@ -1313,6 +1370,7 @@ func submit_client_hello(data: Dictionary) -> void:
for required_capability: String in [ for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]: ]:
if required_capability not in client_capabilities: if required_capability not in client_capabilities:
_reject_peer( _reject_peer(
@ -1546,6 +1604,7 @@ func receive_server_hello(data: Dictionary) -> void:
for required_capability: String in [ for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]: ]:
if required_capability not in _server_capabilities: if required_capability not in _server_capabilities:
_teardown_peer() _teardown_peer()
@ -1578,6 +1637,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.FRIENDS_CAPABILITY, NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY, NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]), ]),
) )
_registry.update_appearance(local_peer_id, _local_appearance_snapshot) _registry.update_appearance(local_peer_id, _local_appearance_snapshot)
@ -1623,6 +1683,7 @@ func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT: if state != State.JOINED_CLIENT:
return return
_operator_peer_ids.erase(peer_id) _operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id) _last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id) _pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id) _registry.remove_peer(peer_id)
@ -2247,6 +2308,8 @@ func _broadcast_movement_snapshots() -> void:
continue continue
var snapshots: Array = [] var snapshots: Array = []
for subject_id: int in peer_ids: for subject_id: int in peer_ids:
if not peers_share_space(recipient_id, subject_id):
continue
var subject_avatar: Player = _spawn_service.get_avatar(subject_id) var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
if subject_avatar == null: if subject_avatar == null:
continue continue
@ -2315,7 +2378,7 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_animation_refresh_accumulator, _animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL, ANIMATION_REFRESH_INTERVAL,
) )
var updates: Array = [] var updates_by_subject: Dictionary[int, Array] = {}
for subject_id: int in peer_ids: for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id) var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null: if avatar == null:
@ -2333,12 +2396,18 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_last_animation_state_by_peer[subject_id] = state.duplicate(true) _last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state) var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty(): if not encoded.is_empty():
updates.append(encoded) updates_by_subject[subject_id] = encoded
if updates.is_empty(): if updates_by_subject.is_empty():
return return
for recipient_id: int in peer_ids: for recipient_id: int in peer_ids:
if recipient_id == 1: if recipient_id == 1:
continue continue
var updates: Array = []
for subject_id: int in updates_by_subject:
if peers_share_space(recipient_id, subject_id):
updates.append(updates_by_subject[subject_id])
if updates.is_empty():
continue
receive_movement_animations.rpc_id(recipient_id, updates) receive_movement_animations.rpc_id(recipient_id, updates)
_movement_animation_packets_sent += 1 _movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size() _movement_animation_states_sent += updates.size()
@ -2538,6 +2607,8 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if avatar == null: if avatar == null:
continue continue
if peer_id == local_peer_id: if peer_id == local_peer_id:
if _reject_stale_space_transition_snapshot(snapshot):
continue
_discard_acknowledged_movement_inputs( _discard_acknowledged_movement_inputs(
int(snapshot.get("acknowledged_input", 0)) int(snapshot.get("acknowledged_input", 0))
) )
@ -2555,6 +2626,33 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
) )
func _reject_stale_space_transition_snapshot(snapshot: Dictionary) -> bool:
if not _local_space_transition_guard_active:
return false
var position_values: Array = snapshot.get("position", [])
if position_values.size() != 3:
return true
var position := Vector3(
float(position_values[0]),
float(position_values[1]),
float(position_values[2]),
)
var acknowledged_input: int = int(
snapshot.get("acknowledged_input", 0)
)
if acknowledged_input < _local_space_transition_minimum_ack:
return true
if acknowledged_input > _local_space_transition_minimum_ack:
_local_space_transition_guard_active = false
return false
if position.distance_to(_local_space_transition_guard_position) > (
SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE
):
return true
_local_space_transition_guard_active = false
return false
func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
while ( while (
not _pending_movement_inputs.is_empty() not _pending_movement_inputs.is_empty()
@ -2883,11 +2981,15 @@ func _teardown_peer() -> void:
_snapshot_accumulator = 0.0 _snapshot_accumulator = 0.0
_movement_snapshot_tick = 0 _movement_snapshot_tick = 0
_last_local_snapshot_received_msec = 0 _last_local_snapshot_received_msec = 0
_local_space_transition_guard_active = false
_local_space_transition_guard_position = Vector3.ZERO
_local_space_transition_minimum_ack = 0
_animation_refresh_accumulator = 0.0 _animation_refresh_accumulator = 0.0
_last_animation_state_by_peer.clear() _last_animation_state_by_peer.clear()
_pending_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear() _last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray() _server_capabilities = PackedStringArray()
_peer_space_ids.clear()
_server_identity_fingerprint = "" _server_identity_fingerprint = ""
_server_identity_public_key = "" _server_identity_public_key = ""
_session_identity_keys.clear() _session_identity_keys.clear()

View file

@ -4,6 +4,7 @@ extends RefCounted
const CAPABILITY: StringName = &"shop_v1" const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_shop_v1" const ART_CAPABILITY: StringName = &"art_shop_v1"
const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1" const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1"
const DECOR_CAPABILITY: StringName = &"portable_home_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96 const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160 const MAX_MESSAGE_LENGTH: int = 160
@ -18,6 +19,7 @@ enum ProductCategory {
ART_KIT, ART_KIT,
ART_UPGRADE, ART_UPGRADE,
BACKPACK_CAPACITY_UPGRADE, BACKPACK_CAPACITY_UPGRADE,
DECOR,
} }

View file

@ -8,8 +8,10 @@ const ItemDataType = preload("res://items/item_data.gd")
const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const OwnedItemType = preload("res://items/owned_item.gd") const OwnedItemType = preload("res://items/owned_item.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const SHOP_ID: StringName = &"main_fishing_shop" const FISHING_SHOP_ID: StringName = &"main_fishing_shop"
const DECOR_SHOP_ID: StringName = &"rv_decor_shop"
const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade" const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade"
const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade" const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade" const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
@ -31,6 +33,7 @@ var _session: NetworkSession
var _spawn_service: PlayerSpawnService var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService var _network_fishing: NetworkFishingService
var _interaction: FishingShopInteraction var _interaction: FishingShopInteraction
var _decor_interaction: DecorShopInteraction
var _wallet: PlayerWallet var _wallet: PlayerWallet
var _bag: PlayerBag var _bag: PlayerBag
var _item_catalog: ItemCatalog var _item_catalog: ItemCatalog
@ -47,6 +50,8 @@ var _received_results: Dictionary[String, bool] = {}
var _applied_results: Dictionary[String, bool] = {} var _applied_results: Dictionary[String, bool] = {}
var _pending_local_request: Dictionary = {} var _pending_local_request: Dictionary = {}
var _reservations: PlayerAssetReservationService var _reservations: PlayerAssetReservationService
var _home_state: PlayerHomeState
var _network_home: NetworkHomeService
func setup( func setup(
@ -63,6 +68,9 @@ func setup(
save_manager: PlayerSaveManager, save_manager: PlayerSaveManager,
reservations: PlayerAssetReservationService, reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null, inventory_layout: PlayerInventoryLayout = null,
home_state: PlayerHomeState = null,
network_home: NetworkHomeService = null,
decor_interaction: DecorShopInteraction = null,
) -> void: ) -> void:
_session = session _session = session
_spawn_service = spawn_service _spawn_service = spawn_service
@ -77,6 +85,9 @@ func setup(
_save_manager = save_manager _save_manager = save_manager
_reservations = reservations _reservations = reservations
_inventory_layout = inventory_layout _inventory_layout = inventory_layout
_home_state = home_state
_network_home = network_home
_decor_interaction = decor_interaction
if not _session.peer_removed.is_connected(_on_peer_removed): if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed) _session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed): if not _session.state_changed.is_connected(_on_session_state_changed):
@ -87,6 +98,12 @@ func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_interaction = interaction _interaction = interaction
func set_decor_shop_interaction(
interaction: DecorShopInteraction,
) -> void:
_decor_interaction = interaction
func can_request_purchase() -> bool: func can_request_purchase() -> bool:
return ( return (
_session != null _session != null
@ -124,6 +141,19 @@ func can_request_backpack_purchase() -> bool:
) )
func can_request_decor_purchase() -> bool:
return (
can_request_purchase()
and _home_state != null
and (
_session.is_host()
or _session.supports_server_capability(
NetworkShopProtocol.DECOR_CAPABILITY
)
)
)
func is_local_purchase_pending() -> bool: func is_local_purchase_pending() -> bool:
return not _pending_local_request.is_empty() return not _pending_local_request.is_empty()
@ -193,6 +223,15 @@ func request_backpack_capacity_upgrade() -> String:
) )
func request_decor(product_id: StringName) -> String:
return _request_purchase(
product_id,
NetworkShopProtocol.ProductCategory.DECOR,
1,
_home_state.get_owned_count(product_id) if _home_state != null else 0,
)
func request_art_kit() -> String: func request_art_kit() -> String:
return _request_purchase( return _request_purchase(
ArtShopStockType.ART_KIT_ITEM_ID, ArtShopStockType.ART_KIT_ITEM_ID,
@ -269,11 +308,25 @@ func _request_purchase(
product_id, category, 0, 0, product_id, category, 0, 0,
) )
return "" return ""
if (
category == NetworkShopProtocol.ProductCategory.DECOR
and not can_request_decor_purchase()
):
local_purchase_finished.emit(
"", false, "Decor require a newer server.",
product_id, category, 0, 0,
)
return ""
var request_id: String = _new_id("shop") var request_id: String = _new_id("shop")
var shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
var request: Dictionary = { var request: Dictionary = {
"request_id": request_id, "request_id": request_id,
"session_id": _session.get_session_id(), "session_id": _session.get_session_id(),
"shop_id": str(SHOP_ID), "shop_id": str(shop_id),
"product_id": str(product_id), "product_id": str(product_id),
"category": category, "category": category,
"quantity": quantity, "quantity": quantity,
@ -345,16 +398,34 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
_rejected_result(data, "A purchase is already pending.") _rejected_result(data, "A purchase is already pending.")
) )
return return
if not _is_shop_available_for_peer(peer_id): var requested_shop_id := StringName(str(data["shop_id"]))
if not _is_shop_available_for_peer(peer_id, requested_shop_id):
var avatar: Player = _spawn_service.get_avatar(peer_id) var avatar: Player = _spawn_service.get_avatar(peer_id)
var requested_interaction: FishingShopInteraction = (
_decor_interaction
if requested_shop_id == DECOR_SHOP_ID
else _interaction
)
var message: String = ( var message: String = (
"The shop is unavailable." "The shop is unavailable."
if avatar == null or _interaction == null if avatar == null or requested_interaction == null
else "Move closer to the shop." else "Move closer to the shop."
) )
_record_and_send(peer_id, _rejected_result(data, message)) _record_and_send(peer_id, _rejected_result(data, message))
return return
var result: Dictionary = _build_authoritative_result(peer_id, data) var result: Dictionary = _build_authoritative_result(peer_id, data)
if (
bool(result.get("accepted", false))
and int(result.get("category", -1))
== NetworkShopProtocol.ProductCategory.DECOR
and _network_home != null
):
_network_home.authorize_decor_purchase(
peer_id,
StringName(str(result["product_id"])),
int(result["resulting_state"]),
str(result["result_id"]),
)
_pending_by_peer[peer_id] = request_id _pending_by_peer[peer_id] = request_id
_record_and_send(peer_id, result) _record_and_send(peer_id, result)
@ -368,10 +439,24 @@ func _build_authoritative_result(
var quantity: int = request["quantity"] var quantity: int = request["quantity"]
var wallet_balance: int = request["wallet_balance"] var wallet_balance: int = request["wallet_balance"]
var current_state: int = request["current_state"] var current_state: int = request["current_state"]
if (
category == NetworkShopProtocol.ProductCategory.DECOR
and _network_home != null
):
var authoritative_count: int = (
_network_home.get_authoritative_owned_count(peer_id, product_id)
)
if authoritative_count >= 0:
current_state = authoritative_count
var cost: int = -1 var cost: int = -1
var resulting_state: int = current_state var resulting_state: int = current_state
var rejection: String = "" var rejection: String = ""
if StringName(str(request["shop_id"])) != SHOP_ID: var expected_shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
if StringName(str(request["shop_id"])) != expected_shop_id:
rejection = "The shop is unavailable." rejection = "The shop is unavailable."
elif quantity < 1: elif quantity < 1:
rejection = "Purchase could not be completed." rejection = "Purchase could not be completed."
@ -545,6 +630,19 @@ func _build_authoritative_result(
resulting_state = PlayerArtUnlocks.resulting_mask( resulting_state = PlayerArtUnlocks.resulting_mask(
current_state, product_id current_state, product_id
) )
NetworkShopProtocol.ProductCategory.DECOR:
cost = DecorCatalogType.get_price(product_id)
if (
not DecorCatalogType.has_product(product_id)
or quantity != 1
or current_state < 0
or current_state
>= DecorCatalogType.MAX_OWNED_PER_PRODUCT
or cost < 0
):
rejection = "Decor is unavailable."
else:
resulting_state = current_state + 1
if rejection.is_empty() and (cost < 0 or wallet_balance < cost): if rejection.is_empty() and (cost < 0 or wallet_balance < cost):
rejection = "Insufficient funds." rejection = "Insufficient funds."
if not rejection.is_empty(): if not rejection.is_empty():
@ -667,6 +765,9 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null else 0 if _inventory_layout != null else 0
) )
var art_snapshot: int = _art_unlocks.get_unlock_mask() var art_snapshot: int = _art_unlocks.get_unlock_mask()
var home_snapshot: Dictionary = (
_home_state.to_save_data() if _home_state != null else {}
)
var applied: bool = _apply_local_product(data) var applied: bool = _apply_local_product(data)
if not applied or not _save_manager.save_if_dirty(): if not applied or not _save_manager.save_if_dirty():
_bag.replace_all_items(bag_snapshot) _bag.replace_all_items(bag_snapshot)
@ -676,6 +777,8 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null: if _inventory_layout != null:
_inventory_layout.restore_backpack_level(backpack_snapshot) _inventory_layout.restore_backpack_level(backpack_snapshot)
_art_unlocks.restore_mask(art_snapshot) _art_unlocks.restore_mask(art_snapshot)
if _home_state != null and not home_snapshot.is_empty():
_home_state.restore_from_save_data(home_snapshot)
_wallet.restore_balance(wallet_snapshot) _wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty() _save_manager.save_if_dirty()
_fail_local_apply(data, "Purchase could not be completed.") _fail_local_apply(data, "Purchase could not be completed.")
@ -696,6 +799,7 @@ func _validate_local_result(data: Dictionary) -> String:
or _cooler_capacity == null or _cooler_capacity == null
or _art_unlocks == null or _art_unlocks == null
or _save_manager == null or _save_manager == null
or _home_state == null
or _wallet.get_balance() != int(data["expected_wallet"]) or _wallet.get_balance() != int(data["expected_wallet"])
or str(data["product_id"]) or str(data["product_id"])
!= str(_pending_local_request.get("product_id", "")) != str(_pending_local_request.get("product_id", ""))
@ -806,6 +910,20 @@ func _validate_local_result(data: Dictionary) -> String:
or cost != ArtShopStockType.UPGRADE_PRICE or cost != ArtShopStockType.UPGRADE_PRICE
): ):
return "Purchase could not be completed." return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.DECOR:
if (
not DecorCatalogType.has_product(product_id)
or _home_state.get_owned_count(product_id) != expected_state
or resulting_state != expected_state + 1
or cost != DecorCatalogType.get_price(product_id)
or expected_state >= DecorCatalogType.MAX_OWNED_PER_PRODUCT
or not _bag.can_add_item(product_id, 1)
):
return (
"Your inventory is full."
if not _bag.can_add_item(product_id, 1)
else "Purchase could not be completed."
)
_: _:
return "Purchase could not be completed." return "Purchase could not be completed."
return "" return ""
@ -845,6 +963,12 @@ func _apply_local_product(data: Dictionary) -> bool:
return _art_unlocks.purchase_product( return _art_unlocks.purchase_product(
product_id, _wallet, int(data["total_cost"]) product_id, _wallet, int(data["total_cost"])
) )
NetworkShopProtocol.ProductCategory.DECOR:
return (
_wallet.debit(int(data["total_cost"]))
and _bag.add_item(product_id, 1)
and _home_state.add_owned_decor(product_id, 1)
)
return false return false
@ -921,7 +1045,7 @@ func _handle_acknowledgement(
peer_id: int, peer_id: int,
result_id: String, result_id: String,
request_id: String, request_id: String,
_applied: bool, applied: bool,
message: String, message: String,
) -> void: ) -> void:
if ( if (
@ -933,20 +1057,32 @@ func _handle_acknowledgement(
or _result_owners.get(result_id, 0) != peer_id or _result_owners.get(result_id, 0) != peer_id
): ):
return return
if _network_home != null:
_network_home.finalize_decor_purchase(
peer_id, result_id, applied
)
_acknowledged_results[result_id] = true _acknowledged_results[result_id] = true
_bound_dictionary(_acknowledged_results) _bound_dictionary(_acknowledged_results)
if _pending_by_peer.get(peer_id, "") == request_id: if _pending_by_peer.get(peer_id, "") == request_id:
_pending_by_peer.erase(peer_id) _pending_by_peer.erase(peer_id)
func _is_shop_available_for_peer(peer_id: int) -> bool: func _is_shop_available_for_peer(
peer_id: int,
shop_id: StringName,
) -> bool:
var interaction: FishingShopInteraction = (
_decor_interaction
if shop_id == DECOR_SHOP_ID
else _interaction
)
if ( if (
_session == null _session == null
or not _session.is_host() or not _session.is_host()
or not _session.is_gameplay_session_active() or not _session.is_gameplay_session_active()
or _spawn_service == null or _spawn_service == null
or _interaction == null or interaction == null
or not is_instance_valid(_interaction) or not is_instance_valid(interaction)
): ):
return false return false
var avatar: Player = _spawn_service.get_avatar(peer_id) var avatar: Player = _spawn_service.get_avatar(peer_id)
@ -957,7 +1093,7 @@ func _is_shop_available_for_peer(peer_id: int) -> bool:
_network_fishing == null _network_fishing == null
or not _network_fishing.has_peer_attempt(peer_id) or not _network_fishing.has_peer_attempt(peer_id)
) )
and _interaction.is_avatar_in_range(avatar) and interaction.is_avatar_in_range(avatar)
) )

View file

@ -250,7 +250,11 @@ func activate(
_pointer_screen_position = _clamped_pointer_position( _pointer_screen_position = _clamped_pointer_position(
initial_pointer_position initial_pointer_position
) )
_prior_mouse_mode = Input.mouse_mode # Art-kit activation can occur while the prior right-click camera drag is
# still held. Stop that drag before recording a return mode, then restore
# normal camera availability so right-click continues to work while drawing.
_stop_camera_drag_for_art_kit()
_prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_update_aim() _update_aim()
_refresh_stencil_visibility() _refresh_stencil_visibility()
@ -266,6 +270,10 @@ func deactivate() -> void:
_eraser_mode = false _eraser_mode = false
_clear_armed_guide_action(false) _clear_armed_guide_action(false)
_camera_look_active = false _camera_look_active = false
# Do not leave a drag captured when drawing is closed by a menu, hotbar
# change, or session transition. The next camera drag must start on a fresh
# physical right-click press.
_stop_camera_drag_for_art_kit()
_reset_stroke() _reset_stroke()
_selected_canvas_id = "" _selected_canvas_id = ""
_hovered_canvas_id = "" _hovered_canvas_id = ""
@ -273,10 +281,24 @@ func deactivate() -> void:
_aim_hit.clear() _aim_hit.clear()
_hide_previews() _hide_previews()
_refresh_stencil_visibility() _refresh_stencil_visibility()
Input.mouse_mode = _prior_mouse_mode Input.mouse_mode = _get_restorable_mouse_mode(_prior_mouse_mode)
_emit_hud_state("") _emit_hud_state("")
func _stop_camera_drag_for_art_kit() -> void:
if _local_player == null or not is_instance_valid(_local_player):
return
var camera_input_enabled: bool = _local_player.is_camera_input_enabled()
_local_player.set_camera_input_enabled(false)
_local_player.set_camera_input_enabled(camera_input_enabled)
func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
if mouse_mode == Input.MOUSE_MODE_CAPTURED:
return Input.MOUSE_MODE_VISIBLE
return mouse_mode
func is_active() -> bool: func is_active() -> bool:
return _active return _active

View file

@ -427,6 +427,9 @@ class ShowcaseCameraSnapshot:
@export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2 @export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2
@export var invert_camera_y: bool = false @export var invert_camera_y: bool = false
var _swap_hotbar_camera_scroll: bool = false var _swap_hotbar_camera_scroll: bool = false
var _rv_interior_camera_mode: bool = false
var _rv_interior_environment: Environment
var _rv_previous_camera_environment: Environment
@export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0 @export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0
@export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0 @export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0
@export var minimum_zoom: float = 2.0 @export var minimum_zoom: float = 2.0
@ -471,6 +474,8 @@ var _held_fish_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04)
var _held_fish_sprite: Sprite3D var _held_fish_sprite: Sprite3D
var _held_art_kit_display: Node3D var _held_art_kit_display: Node3D
var _held_art_kit_sprite: Sprite3D var _held_art_kit_sprite: Sprite3D
var _held_decor_package_display: Node3D
var _held_decor_package_visible: bool = false
var _catch_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04) var _catch_attachment_offset: Vector3 = Vector3(0.0, 0.08, 0.04)
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity"))
@ -992,6 +997,7 @@ func _initialize_held_item_attachment() -> void:
_held_art_kit_sprite = attachment.get_node( _held_art_kit_sprite = attachment.get_node(
"HeldArtKitDisplay/HeldArtKitSprite" "HeldArtKitDisplay/HeldArtKitSprite"
) as Sprite3D ) as Sprite3D
_held_decor_package_display = _build_decor_package_display(attachment)
_catch_display = attachment.get_node("CatchDisplay") as Node3D _catch_display = attachment.get_node("CatchDisplay") as Node3D
_catch_attachment_offset = _catch_display.position _catch_attachment_offset = _catch_display.position
_catch_sprite = attachment.get_node("CatchDisplay/CatchSprite") as Sprite3D _catch_sprite = attachment.get_node("CatchDisplay/CatchSprite") as Sprite3D
@ -1322,9 +1328,7 @@ func _update_character_animation() -> void:
and supports_network_animation_action(animation_action_id) and supports_network_animation_action(animation_action_id)
and _character_animation_player.has_animation(animation_action_id) and _character_animation_player.has_animation(animation_action_id)
) )
var held_show_item_visible: bool = ( var held_show_item_visible: bool = _has_held_show_item()
_held_fish_visible or _held_art_kit_visible
)
var requested_animation: Array[StringName] = [] var requested_animation: Array[StringName] = []
if _pocket_visual_active: if _pocket_visual_active:
requested_animation = [_pocket_visual_animation] requested_animation = [_pocket_visual_animation]
@ -1766,7 +1770,11 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_zoom_in: bool = ( var mouse_zoom_in: bool = (
event is InputEventMouseButton event is InputEventMouseButton
and event.shift_pressed != _swap_hotbar_camera_scroll and (
(not event.shift_pressed)
if _rv_interior_camera_mode
else event.shift_pressed != _swap_hotbar_camera_scroll
)
and event.is_action_pressed("camera_zoom_in") and event.is_action_pressed("camera_zoom_in")
) )
var controller_zoom_in: bool = ( var controller_zoom_in: bool = (
@ -1775,7 +1783,11 @@ func _unhandled_input(event: InputEvent) -> void:
) )
var mouse_zoom_out: bool = ( var mouse_zoom_out: bool = (
event is InputEventMouseButton event is InputEventMouseButton
and event.shift_pressed != _swap_hotbar_camera_scroll and (
(not event.shift_pressed)
if _rv_interior_camera_mode
else event.shift_pressed != _swap_hotbar_camera_scroll
)
and event.is_action_pressed("camera_zoom_out") and event.is_action_pressed("camera_zoom_out")
) )
var controller_zoom_out: bool = ( var controller_zoom_out: bool = (
@ -1963,7 +1975,11 @@ func get_active_gameplay_camera() -> Camera3D:
func _set_target_zoom(value: float) -> void: func _set_target_zoom(value: float) -> void:
_target_zoom = clampf(value, minimum_zoom, maximum_zoom) _target_zoom = clampf(
value,
1.25 if _rv_interior_camera_mode else minimum_zoom,
16.0 if _rv_interior_camera_mode else maximum_zoom,
)
func _apply_axis_deadzone(value: float) -> float: func _apply_axis_deadzone(value: float) -> float:
@ -2077,6 +2093,37 @@ func reset_network_movement_state() -> void:
_local_prediction_largest_error = 0.0 _local_prediction_largest_error = 0.0
func apply_network_space_transition(
target_transform: Transform3D,
align_local_camera: bool,
) -> void:
if not target_transform.origin.is_finite():
return
# A room transition is a discontinuity, not ordinary movement. Preserve the
# host's input acknowledgement while clearing every motion, animation, and
# reconciliation state that could pull the avatar toward the previous space.
var acknowledged_input: int = _last_network_input_sequence
reset_network_movement_state()
_last_network_input_sequence = acknowledged_input
_apply_network_casting(false)
velocity = Vector3.ZERO
global_transform = Transform3D(Basis.IDENTITY, target_transform.origin)
var target_yaw: float = target_transform.basis.get_euler().y
_visuals.rotation.y = target_yaw
_network_camera_yaw = target_yaw
_network_target_position = global_position
_network_target_velocity = Vector3.ZERO
_network_target_visual_yaw = target_yaw
_network_snapshot_age = 0.0
_network_snapshot_ready = true
_authoritative_movement_history.clear()
if align_local_camera:
_set_camera_dragging(false)
if _free_camera_active:
_set_free_camera_active(false)
_camera_yaw.rotation.y = target_yaw
func capture_network_input(sequence: int) -> Dictionary: func capture_network_input(sequence: int) -> Dictionary:
if _sitting_intent_pending and _sitting_intent_sequence < 0: if _sitting_intent_pending and _sitting_intent_sequence < 0:
_sitting_intent_sequence = sequence _sitting_intent_sequence = sequence
@ -3021,6 +3068,49 @@ func apply_camera_settings(
_swap_hotbar_camera_scroll = swap_hotbar_camera_scroll _swap_hotbar_camera_scroll = swap_hotbar_camera_scroll
func set_rv_interior_camera_mode(
enabled: bool,
excluded_wall_rids: Array[RID] = [],
) -> void:
if enabled != _rv_interior_camera_mode:
if enabled:
_rv_previous_camera_environment = _camera.environment
_camera.environment = _get_rv_interior_environment()
if _free_camera != null:
_free_camera.environment = _camera.environment
else:
_camera.environment = _rv_previous_camera_environment
if _free_camera != null:
_free_camera.environment = _camera.environment
_rv_previous_camera_environment = null
_rv_interior_camera_mode = enabled
if _spring_arm == null:
return
_spring_arm.clear_excluded_objects()
if enabled:
for collider_rid: RID in excluded_wall_rids:
if collider_rid.is_valid():
_spring_arm.add_excluded_object(collider_rid)
_set_target_zoom(_target_zoom)
func _get_rv_interior_environment() -> Environment:
if _rv_interior_environment != null:
return _rv_interior_environment
_rv_interior_environment = Environment.new()
_rv_interior_environment.background_mode = Environment.BG_COLOR
_rv_interior_environment.background_color = Color("020506")
_rv_interior_environment.ambient_light_source = (
Environment.AMBIENT_SOURCE_COLOR
)
_rv_interior_environment.ambient_light_color = Color("d6caaa")
_rv_interior_environment.ambient_light_energy = 1.0
_rv_interior_environment.reflected_light_source = (
Environment.REFLECTION_SOURCE_DISABLED
)
return _rv_interior_environment
func is_camera_input_enabled() -> bool: func is_camera_input_enabled() -> bool:
return _camera_input_enabled return _camera_input_enabled
@ -3147,6 +3237,23 @@ func set_active_shovel(should_show: bool) -> void:
) )
func set_active_decor_package(should_show: bool) -> void:
var changed: bool = _held_decor_package_visible != should_show
_held_decor_package_visible = should_show
if _held_decor_package_display != null:
_held_decor_package_display.visible = should_show
if should_show:
_fishing_rod.visible = false
if _catching_net != null:
_catching_net.visible = false
if _shovel != null:
_shovel.visible = false
elif changed:
_apply_active_rod_visibility()
if changed:
_update_character_animation()
func _apply_fishing_rod_model(rod: FishingRodDataType) -> void: func _apply_fishing_rod_model(rod: FishingRodDataType) -> void:
var next_id: StringName = rod.item_id if rod != null else StringName() var next_id: StringName = rod.item_id if rod != null else StringName()
if next_id == _active_fishing_rod_id: if next_id == _active_fishing_rod_id:
@ -3319,7 +3426,52 @@ func set_held_fish(
func _has_held_show_item() -> bool: func _has_held_show_item() -> bool:
return _held_fish_visible or _held_art_kit_visible return (
_held_fish_visible
or _held_art_kit_visible
or _held_decor_package_visible
)
func _build_decor_package_display(parent: Node3D) -> Node3D:
var display := Node3D.new()
display.name = "HeldDecorPackageDisplay"
display.position = Vector3(0.0, 0.1, 0.04)
display.rotation = Vector3(0.15, 0.0, -0.55)
display.visible = false
parent.add_child(display)
var sack := MeshInstance3D.new()
sack.name = "Sack"
var sack_mesh := SphereMesh.new()
sack_mesh.radius = 0.22
sack_mesh.height = 0.48
sack_mesh.radial_segments = 8
sack_mesh.rings = 4
sack_mesh.material = _decor_package_material(Color("a87843"))
sack.mesh = sack_mesh
display.add_child(sack)
var tie := MeshInstance3D.new()
tie.name = "Tie"
var tie_mesh := CylinderMesh.new()
tie_mesh.top_radius = 0.1
tie_mesh.bottom_radius = 0.12
tie_mesh.height = 0.08
tie_mesh.radial_segments = 8
tie_mesh.material = _decor_package_material(Color("513829"))
tie.mesh = tie_mesh
tie.position = Vector3(0.0, 0.22, 0.0)
display.add_child(tie)
return display
func _decor_package_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 1.0
material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX
return material
func _clear_held_fish() -> void: func _clear_held_fish() -> void:

View file

@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="straywild" config/name="straywild"
config/version="0.19.0-alpha" config/version="0.20.2-alpha"
run/main_scene="res://main/main.tscn" run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/straywild_256.png" config/icon="res://art/exported/system_icons/straywild_256.png"
@ -260,7 +260,7 @@ alternate_fishing={
[network] [network]
discovery/base_url="https://discovery.straywild.io" discovery/base_url="https://discovery.straywild.io/patron-test"
[physics] [physics]

View file

@ -34,8 +34,9 @@ const WorldWeatherServiceType = preload(
const WorldLayoutType = preload("res://world/world_layout.gd") const WorldLayoutType = preload("res://world/world_layout.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const PlayerType = preload("res://player/player.gd") const PlayerType = preload("res://player/player.gd")
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
const SAVE_VERSION: int = 10 const SAVE_VERSION: int = 11
const LEGACY_SAVE_FILENAME := "player_save.json" const LEGACY_SAVE_FILENAME := "player_save.json"
const ARCHIVE_EXTENSION := ".nfsave" const ARCHIVE_EXTENSION := ".nfsave"
const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
@ -76,6 +77,7 @@ class LoadSnapshot:
WorldWeatherServiceType.SUNNY_DURATION_RANGE.x WorldWeatherServiceType.SUNNY_DURATION_RANGE.x
) )
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data() var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
var home_data: Dictionary = PlayerHomeStateType.default_save_data()
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
@ -96,6 +98,7 @@ var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType var _jobs: PlayerJobServiceType
var _player: PlayerType var _player: PlayerType
var _home_state: PlayerHomeStateType
var _autosave_timer: Timer var _autosave_timer: Timer
var _is_configured: bool = false var _is_configured: bool = false
var _is_restoring: bool = false var _is_restoring: bool = false
@ -175,6 +178,7 @@ func setup(
world_weather: WorldWeatherServiceType, world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType, jobs: PlayerJobServiceType,
player: PlayerType, player: PlayerType,
home_state: PlayerHomeStateType,
) -> void: ) -> void:
_inventory = inventory _inventory = inventory
_collection_log = collection_log _collection_log = collection_log
@ -192,6 +196,7 @@ func setup(
_world_weather = world_weather _world_weather = world_weather
_jobs = jobs _jobs = jobs
_player = player _player = player
_home_state = home_state
_is_configured = ( _is_configured = (
_inventory != null _inventory != null
and _collection_log != null and _collection_log != null
@ -209,6 +214,7 @@ func setup(
and _world_weather != null and _world_weather != null
and _jobs != null and _jobs != null
and _player != null and _player != null
and _home_state != null
) )
if not _is_configured: if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.") push_error("PlayerSaveManager setup is missing required references.")
@ -255,6 +261,8 @@ func setup(
_player.active_bait_changed.connect(_on_active_tackle_changed) _player.active_bait_changed.connect(_on_active_tackle_changed)
if not _player.active_lure_changed.is_connected(_on_active_tackle_changed): if not _player.active_lure_changed.is_connected(_on_active_tackle_changed):
_player.active_lure_changed.connect(_on_active_tackle_changed) _player.active_lure_changed.connect(_on_active_tackle_changed)
if not _home_state.changed.is_connected(_mark_dirty):
_home_state.changed.connect(_mark_dirty)
func load_player_data() -> bool: func load_player_data() -> bool:
@ -360,6 +368,9 @@ func load_player_data() -> bool:
snapshot.world_weather_seconds_remaining, snapshot.world_weather_seconds_remaining,
) )
var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data) var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data)
var home_restored: bool = _home_state.restore_from_save_data(
snapshot.home_data
)
_is_restoring = false _is_restoring = false
if ( if (
not inventory_restored not inventory_restored
@ -376,13 +387,14 @@ func load_player_data() -> bool:
or not world_time_restored or not world_time_restored
or not world_weather_restored or not world_weather_restored
or not jobs_restored or not jobs_restored
or not home_restored
): ):
push_error( push_error(
( (
"Validated player save could not be restored: " "Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s " + "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s " + "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s" + "time=%s weather=%s jobs=%s home=%s"
) )
% [ % [
inventory_restored, inventory_restored,
@ -399,6 +411,7 @@ func load_player_data() -> bool:
world_time_restored, world_time_restored,
world_weather_restored, world_weather_restored,
jobs_restored, jobs_restored,
home_restored,
] ]
) )
return false return false
@ -899,6 +912,7 @@ func _build_save_dictionary() -> Dictionary:
), ),
}, },
"jobs": _jobs.to_save_data(), "jobs": _jobs.to_save_data(),
"home": _home_state.to_save_data(),
} }
@ -913,6 +927,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY
or typeof(save_data.get("jobs")) != TYPE_DICTIONARY or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
or typeof(save_data.get("home")) != TYPE_DICTIONARY
): ):
return null return null
var wallet_data: Dictionary = save_data["wallet"] var wallet_data: Dictionary = save_data["wallet"]
@ -924,6 +939,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var inventory_layout_data: Dictionary = save_data["inventory_layout"] var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"] var experience_data: Dictionary = save_data["experience"]
var jobs_data: Dictionary = save_data["jobs"] var jobs_data: Dictionary = save_data["jobs"]
var home_data: Dictionary = save_data["home"]
var world_data: Dictionary = {} var world_data: Dictionary = {}
if typeof(save_data.get("world")) == TYPE_DICTIONARY: if typeof(save_data.get("world")) == TYPE_DICTIONARY:
world_data = save_data["world"] world_data = save_data["world"]
@ -1006,6 +1022,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
if not PlayerJobServiceType.validate_save_data(jobs_data): if not PlayerJobServiceType.validate_save_data(jobs_data):
return null return null
snapshot.jobs_data = jobs_data.duplicate(true) snapshot.jobs_data = jobs_data.duplicate(true)
var sanitized_home: Dictionary = PlayerHomeStateType.sanitize_save_data(
home_data
)
if sanitized_home.is_empty():
return null
snapshot.home_data = sanitized_home
if world_data.has("time_hours"): if world_data.has("time_hours"):
snapshot.world_time_hours = _read_world_time_hours( snapshot.world_time_hours = _read_world_time_hours(
world_data["time_hours"] world_data["time_hours"]
@ -1340,6 +1362,8 @@ func _migrate_save(
migrated = _migrate_version_8_to_9(migrated) migrated = _migrate_version_8_to_9(migrated)
9: 9:
migrated = _migrate_version_9_to_10(migrated) migrated = _migrate_version_9_to_10(migrated)
10:
migrated = _migrate_version_10_to_11(migrated)
_: _:
return {} return {}
if migrated.is_empty(): if migrated.is_empty():
@ -1597,6 +1621,13 @@ func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
return migrated return migrated
func _migrate_version_10_to_11(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
migrated["home"] = PlayerHomeStateType.default_save_data()
migrated["save_version"] = 11
return migrated
func _mark_dirty() -> void: func _mark_dirty() -> void:
if ( if (
_is_restoring _is_restoring
@ -1866,6 +1897,7 @@ func _restore_defaults() -> void:
_world_layout = WorldLayoutType.GENERATED _world_layout = WorldLayoutType.GENERATED
_world_seed = DEFAULT_WORLD_SEED _world_seed = DEFAULT_WORLD_SEED
_jobs.reset_to_defaults() _jobs.reset_to_defaults()
_home_state.reset_to_defaults()
_is_restoring = false _is_restoring = false
_is_dirty = false _is_dirty = false

View file

@ -4,14 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.19.0-alpha" readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.20.2-alpha"
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild"
readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-windows-x86_64.zip" readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-windows-x86_64.zip"
readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.19.0-alpha-linux-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then
@ -77,6 +77,9 @@ straywild code is licensed under GPL-3.0-or-later.
Source repository: ${SOURCE_URL} Source repository: ${SOURCE_URL}
Source revision at build time: ${SOURCE_COMMIT} Source revision at build time: ${SOURCE_COMMIT}
This private testing release is distributed with an exact corresponding
source archive named straywild-v0.20.2-alpha-source.tar.gz.
If this package includes uncommitted development changes, it must not be If this package includes uncommitted development changes, it must not be
publicly redistributed until the exact corresponding source is also made publicly redistributed until the exact corresponding source is also made
available. available.

View file

@ -193,7 +193,7 @@ cat >"${GAME_ROOT}/licenses/SOURCE-CODE.md" <<EOF
straywild code is licensed under GPL-3.0-or-later. straywild code is licensed under GPL-3.0-or-later.
Source repository: https://forge.makearmy.io/woofmeow/netfishing Source repository: https://forge.makearmy.io/woofmeow/straywild
Exact source revision: ${TAG_COMMIT} Exact source revision: ${TAG_COMMIT}
Release tag: ${RELEASE_TAG} Release tag: ${RELEASE_TAG}
PortMaster packaging revision: ${HEAD_COMMIT} PortMaster packaging revision: ${HEAD_COMMIT}
@ -236,7 +236,7 @@ unzip -tq "${ARCHIVE}" >/dev/null
readonly TOP_LEVELS="$( readonly TOP_LEVELS="$(
unzip -Z1 "${ARCHIVE}" | cut -d/ -f1 | sort -u unzip -Z1 "${ARCHIVE}" | cut -d/ -f1 | sort -u
)" )"
readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' straywild.sh straywild)" readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' straywild straywild.sh)"
if [[ "${TOP_LEVELS}" != "${EXPECTED_TOP_LEVELS}" ]]; then if [[ "${TOP_LEVELS}" != "${EXPECTED_TOP_LEVELS}" ]]; then
echo "Unexpected PortMaster archive roots:" >&2 echo "Unexpected PortMaster archive roots:" >&2
printf '%s\n' "${TOP_LEVELS}" >&2 printf '%s\n' "${TOP_LEVELS}" >&2

View file

@ -87,6 +87,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd" "tests/fish_showcase_multiplayer_validation.gd"
"tests/friend_multiplayer_validation.gd" "tests/friend_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd"
"tests/home_spawn_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd" "tests/movement_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd"

View file

@ -19,9 +19,15 @@ func _run() -> void:
for _frame: int in 8: for _frame: int in 8:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(bool(main.call(
"_prepare_host_world", WorldLayout.STARTER_ISLAND, 1
)))
assert(bool(main.call("_prepare_private_host"))) assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
main.call("_enter_gameplay") main.call("_enter_gameplay")
await physics_frame await physics_frame
await physics_frame await physics_frame
@ -94,11 +100,7 @@ func _run() -> void:
assert(keyboard_mapping.reset_mapping()) assert(keyboard_mapping.reset_mapping())
await process_frame await process_frame
var shop_prompt_key := game_ui.get_node("%ShopPromptKey") as Label var shop_prompt_key := game_ui.get_node("%ShopPromptKey") as Label
var storage_prompt_message := game_ui.get_node(
"%StoragePromptMessage"
) as Label
assert(shop_prompt_key.text == "E") assert(shop_prompt_key.text == "E")
assert(storage_prompt_message.text == "E open storage")
var remapped_interact := InputEventKey.new() var remapped_interact := InputEventKey.new()
remapped_interact.physical_keycode = KEY_F remapped_interact.physical_keycode = KEY_F
remapped_interact.pressed = true remapped_interact.pressed = true
@ -108,7 +110,6 @@ func _run() -> void:
)) ))
await process_frame await process_frame
assert(shop_prompt_key.text == "F") assert(shop_prompt_key.text == "F")
assert(storage_prompt_message.text == "F open storage")
assert(keyboard_mapping.reset_mapping()) assert(keyboard_mapping.reset_mapping())
await process_frame await process_frame
assert(toolbar.get_parent() == chat_ui.get_parent()) assert(toolbar.get_parent() == chat_ui.get_parent())
@ -416,6 +417,12 @@ func _run() -> void:
) )
assert(normal_camera.is_position_behind(chat_anchor)) assert(normal_camera.is_position_behind(chat_anchor))
assert(not free_camera.is_position_behind(chat_anchor)) assert(not free_camera.is_position_behind(chat_anchor))
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var storage_prompt := game_ui.get_node("%StoragePrompt") as Control
game_ui.set_shop_prompt_visible(true, chat_anchor)
game_ui.set_storage_prompt_visible(true, chat_anchor)
assert(shop_prompt.visible)
assert(storage_prompt.visible)
chat_ui.show_local_speech("hello there friend") chat_ui.show_local_speech("hello there friend")
await process_frame await process_frame
assert(bool(player.get("_speech_mouth_active"))) assert(bool(player.get("_speech_mouth_active")))

View file

@ -1356,6 +1356,24 @@ func _validate_player_menu_nested_controller_back() -> void:
== LogbookPageType.ControllerZone.ENTRIES, == LogbookPageType.ControllerZone.ENTRIES,
"Global player-menu Back did not return Logbook to creature selection.", "Global player-menu Back did not return Logbook to creature selection.",
) )
logbook_page.call("_set_index_open", true, false)
logbook_page.set(
"_controller_zone",
LogbookPageType.ControllerZone.INDEX,
)
_expect(
bool(menu.call("consume_escape")),
"Global player-menu Back did not consume the Logbook index zone.",
)
_expect(
menu.visible and not bool(logbook_page.get("_index_open")),
"Global player-menu Back did not return from the Logbook index page.",
)
_expect(
int(logbook_page.get("_controller_zone"))
== LogbookPageType.ControllerZone.TABS,
"Global player-menu Back did not return Logbook index focus to tabs.",
)
var net_page := menu.get("_the_net_page") as Control var net_page := menu.get("_the_net_page") as Control
net_page.set("_active", true) net_page.set("_active", true)

View file

@ -31,9 +31,14 @@ func _run() -> void:
for _frame: int in 8: for _frame: int in 8:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(bool(main.call("_prepare_private_host"))) assert(bool(main.call("_prepare_private_host")))
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
main.call("_enter_gameplay") main.call("_enter_gameplay")
for _frame: int in 8: for _frame: int in 8:
await process_frame await process_frame
@ -42,7 +47,6 @@ func _run() -> void:
var catalog := main.get("fish_catalog") as FishPool var catalog := main.get("fish_catalog") as FishPool
var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService
var shop_service := main.get_node("%NetworkShopService") as NetworkShopService var shop_service := main.get_node("%NetworkShopService") as NetworkShopService
var session := main.get_node("%NetworkSession") as NetworkSession
var reservations := ( var reservations := (
main.get_node("%PlayerAssetReservationService") main.get_node("%PlayerAssetReservationService")
as PlayerAssetReservationService as PlayerAssetReservationService
@ -554,7 +558,10 @@ func _test_host_backpack_purchase(
for expected_capacity: int in [18, 27, 36]: for expected_capacity: int in [18, 27, 36]:
_shop_result.clear() _shop_result.clear()
assert(not shop_service.request_backpack_capacity_upgrade().is_empty()) assert(not shop_service.request_backpack_capacity_upgrade().is_empty())
assert(not _shop_result.is_empty() and bool(_shop_result[1])) assert(
not _shop_result.is_empty() and bool(_shop_result[1]),
"Backpack purchase failed: %s" % [_shop_result],
)
assert(layout.get_inventory_capacity() == expected_capacity) assert(layout.get_inventory_capacity() == expected_capacity)
assert(player.wallet.get_balance() == balance_before - total_cost) assert(player.wallet.get_balance() == balance_before - total_cost)
assert(layout.get_next_backpack_cost() == -1) assert(layout.get_next_backpack_cost() == -1)
@ -575,7 +582,10 @@ func _test_host_storage_purchase(
for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]: for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]:
_shop_result.clear() _shop_result.clear()
assert(not shop_service.request_cooler_capacity_upgrade().is_empty()) assert(not shop_service.request_cooler_capacity_upgrade().is_empty())
assert(not _shop_result.is_empty() and bool(_shop_result[1])) assert(
not _shop_result.is_empty() and bool(_shop_result[1]),
"Cooler purchase failed: %s" % [_shop_result],
)
assert(capacity.get_capacity() == expected_capacity) assert(capacity.get_capacity() == expected_capacity)
assert(player.wallet.get_balance() == balance_before - total_cost) assert(player.wallet.get_balance() == balance_before - total_cost)
assert(capacity.get_next_cost() == -1) assert(capacity.get_next_cost() == -1)

View file

@ -104,7 +104,10 @@ func _run() -> void:
var hotbar_data: Dictionary = parsed["hotbar"] var hotbar_data: Dictionary = parsed["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int(parsed["save_version"]) == 10) assert(int(parsed["save_version"]) == 11)
assert(not PlayerHomeState.sanitize_save_data(
parsed.get("home", {})
).is_empty())
assert( assert(
int(parsed["experience"]["total_experience"]) int(parsed["experience"]["total_experience"])
== 125 == 125
@ -197,8 +200,8 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true) var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0 invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 11) assert(NetworkProtocol.PROTOCOL_VERSION == 12)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18)
assert( assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY NetworkProtocol.FISH_QUALITY_CAPABILITY
== "fish_quality_v1" == "fish_quality_v1"

View file

@ -26,8 +26,8 @@ func _run() -> void:
_validate_collection_mastery() _validate_collection_mastery()
_validate_version_four_migration() _validate_version_four_migration()
_validate_fishing_protocol_v2() _validate_fishing_protocol_v2()
assert(NetworkProtocol.PROTOCOL_VERSION == 11) assert(NetworkProtocol.PROTOCOL_VERSION == 12)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18)
assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11) assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11)
assert( assert(
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY
@ -485,7 +485,7 @@ func _validate_version_four_migration() -> void:
version_four, version_four,
4, 4,
) )
assert(int(migrated.get("save_version", -1)) == 10) assert(int(migrated.get("save_version", -1)) == 11)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert( assert(
is_equal_approx( is_equal_approx(
@ -509,4 +509,7 @@ func _validate_version_four_migration() -> void:
assert( assert(
PlayerJobService.validate_save_data(migrated.get("jobs", {})) PlayerJobService.validate_save_data(migrated.get("jobs", {}))
) )
assert(not PlayerHomeState.sanitize_save_data(
migrated.get("home", {})
).is_empty())
manager.queue_free() manager.queue_free()

View file

@ -265,6 +265,15 @@ func _run() -> void:
assert(bool(water_recovery.get("_prior_movement_enabled"))) assert(bool(water_recovery.get("_prior_movement_enabled")))
assert(bool(water_recovery.get("_prior_camera_input_enabled"))) assert(bool(water_recovery.get("_prior_camera_input_enabled")))
assert(not pause_menu.visible) assert(not pause_menu.visible)
var test_world := main.get_node("TestWorld") as TestWorld
assert(test_world != null)
var recovery_entry := water_recovery.get("_entry_position") as Vector3
var resolved_recovery := water_recovery.call(
"_resolve_recovery_position"
) as Vector3
assert(resolved_recovery.is_equal_approx(
test_world.get_water_recovery_position(recovery_entry)
))
water_recovery.call("_finish_recovery") water_recovery.call("_finish_recovery")
assert(player.is_movement_enabled()) assert(player.is_movement_enabled())

View file

@ -63,12 +63,38 @@ func _run_host() -> void:
var spawn_service := main.get_node( var spawn_service := main.get_node(
"%PlayerSpawnService" "%PlayerSpawnService"
) as PlayerSpawnService ) as PlayerSpawnService
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
assert(remote_avatar != null) assert(remote_avatar != null)
assert(remote_avatar.bag.get_quantity(&"worms") == 0) assert(remote_avatar.bag.get_quantity(&"worms") == 0)
remote_avatar.global_position = Vector3(-0.5, 3.95, 2.1) var remote_record := session.get_peer_record(remote_peer_id)
var remote_visuals := remote_avatar.get_node("Visuals") as Node3D assert(remote_record != null)
remote_visuals.rotation.y = PI * 0.5 assert(await _wait_for_home(
network_home, remote_record.identity_fingerprint
))
var exterior_spawn: Transform3D = home_world.get_exterior_spawn_transform(
remote_record.identity_fingerprint
)
var exterior_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < exterior_deadline
and remote_avatar.global_position.distance_to(exterior_spawn.origin) > 0.25
):
await physics_frame
assert(remote_avatar.global_position.distance_to(exterior_spawn.origin) <= 0.25)
# Give the client enough local frames to observe the initial RV-door spawn
# before this test publishes its separate fishing-position teleport.
for _frame: int in 30:
await physics_frame
var cast_setup: Dictionary = await _find_clear_fishing_setup(
main, remote_avatar
)
assert(not cast_setup.is_empty(), "no clear fishing setup was found")
var expected_position: Vector3 = cast_setup["position"]
var expected_direction: Vector3 = cast_setup["direction"]
session.publish_authoritative_teleport(remote_peer_id) session.publish_authoritative_teleport(remote_peer_id)
var aiming_deadline: int = Time.get_ticks_msec() + 15000 var aiming_deadline: int = Time.get_ticks_msec() + 15000
while ( while (
@ -82,7 +108,7 @@ func _run_host() -> void:
== Player.FishingVisualPhase.CASTING == Player.FishingVisualPhase.CASTING
) )
var remote_position_error := ( var remote_position_error := (
remote_avatar.global_position - Vector3(-0.5, 3.95, 2.1) remote_avatar.global_position - expected_position
) )
remote_position_error.y = 0.0 remote_position_error.y = 0.0
assert( assert(
@ -90,7 +116,7 @@ func _run_host() -> void:
"remote casting position drifted: %s" % remote_avatar.global_position "remote casting position drifted: %s" % remote_avatar.global_position
) )
assert( assert(
remote_avatar.get_facing_direction().dot(Vector3.LEFT) > 0.99, remote_avatar.get_facing_direction().dot(expected_direction) > 0.99,
"remote casting facing drifted: %s" "remote casting facing drifted: %s"
% remote_avatar.get_facing_direction() % remote_avatar.get_facing_direction()
) )
@ -196,6 +222,23 @@ func _run_client() -> void:
joined = true joined = true
break break
assert(joined) assert(joined)
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var local_fingerprint: String = session.get_local_identity_fingerprint()
assert(await _wait_for_home(network_home, local_fingerprint))
var player := main.get("_player") as Player
var exterior_spawn: Transform3D = (
home_world.get_exterior_spawn_transform(local_fingerprint)
)
var home_spawn_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < home_spawn_deadline
and player.global_position.distance_to(exterior_spawn.origin) > 0.25
):
await physics_frame
assert(player.global_position.distance_to(exterior_spawn.origin) <= 0.25)
var service := main.get_node( var service := main.get_node(
"%NetworkFishingService" "%NetworkFishingService"
@ -206,7 +249,6 @@ func _run_client() -> void:
cast_rejections.append(message) cast_rejections.append(message)
) )
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
var player := main.get("_player") as Player
var item_catalog := main.get("item_catalog") as ItemCatalog var item_catalog := main.get("item_catalog") as ItemCatalog
var worms: ItemData = item_catalog.get_item_by_id(&"worms") var worms: ItemData = item_catalog.get_item_by_id(&"worms")
if player.bag.get_quantity(&"worms") == 0: if player.bag.get_quantity(&"worms") == 0:
@ -217,15 +259,20 @@ func _run_client() -> void:
"Visuals/CharacterRig/AnimationPlayer" "Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer ) as AnimationPlayer
assert(character_animation_player.has_animation(&"retract_sit")) assert(character_animation_player.has_animation(&"retract_sit"))
var placement_deadline: int = Time.get_ticks_msec() + 5000 # The host chooses and publishes a clear target after both RV manifests have
# settled. Waiting for that authoritative teleport also coordinates the
# animation-observation window between these two independent processes.
var placement_deadline: int = Time.get_ticks_msec() + 10000
while ( while (
Time.get_ticks_msec() < placement_deadline Time.get_ticks_msec() < placement_deadline
and player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) and player.global_position.distance_to(exterior_spawn.origin) <= 1.0
> 0.25
): ):
await physics_frame await physics_frame
assert(player.global_position.distance_to(Vector3(-0.5, 3.95, 2.1)) <= 0.25) assert(player.global_position.distance_to(exterior_spawn.origin) > 1.0)
assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) for _frame: int in 2:
await physics_frame
var expected_position: Vector3 = player.global_position
var expected_direction: Vector3 = player.get_facing_direction()
player.set_casting_visual() player.set_casting_visual()
assert( assert(
@ -241,7 +288,10 @@ func _run_client() -> void:
assert(player.is_sitting()) assert(player.is_sitting())
for _frame: int in 15: for _frame: int in 15:
await physics_frame await physics_frame
assert(fishing_spot.state == FishingSpotType.FishingState.READY)
assert(fishing_spot.get("_active_player") == null)
fishing_spot.call("_begin_aiming", player) fishing_spot.call("_begin_aiming", player)
assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST)
assert(player.is_movement_enabled()) assert(player.is_movement_enabled())
assert( assert(
int(player.get("_fishing_visual_phase")) int(player.get("_fishing_visual_phase"))
@ -250,6 +300,7 @@ func _run_client() -> void:
var cast_charge: float = 0.0 var cast_charge: float = 0.0
var target: Vector3 = Vector3.ZERO var target: Vector3 = Vector3.ZERO
var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position") var cast_origin: Vector3 = fishing_spot.get("_cast_origin_position")
var target_diagnostics: Array[String] = []
for charge_step: int in range(1, 21): for charge_step: int in range(1, 21):
var candidate_charge := float(charge_step) / 20.0 var candidate_charge := float(charge_step) / 20.0
fishing_spot.set("_cast_charge", candidate_charge) fishing_spot.set("_cast_charge", candidate_charge)
@ -258,14 +309,33 @@ func _run_client() -> void:
var candidate_surface := fishing_spot.get( var candidate_surface := fishing_spot.get(
"_aim_surface_sample" "_aim_surface_sample"
) as FishingSurfaceSample ) as FishingSurfaceSample
target_diagnostics.append(
"charge=%.2f surface=%s path=%s target=%s" % [
candidate_charge,
candidate_surface.is_fishable(),
fishing_spot.get("_cast_path_is_clear"),
candidate_target,
]
)
if ( if (
candidate_surface.is_fishable() candidate_surface.is_fishable()
and fishing_spot.is_cast_path_clear(cast_origin, candidate_target) and bool(fishing_spot.get("_cast_path_is_clear"))
): ):
cast_charge = candidate_charge cast_charge = candidate_charge
target = candidate_target target = candidate_target
break break
assert(cast_charge > 0.0, "no clear fishable test target was found") assert(
cast_charge > 0.0,
(
"no clear fishable test target was found; position=%s facing=%s "
+ "expected=%s diagnostics=%s"
) % [
player.global_position,
player.get_facing_direction(),
expected_direction,
target_diagnostics,
],
)
var evidence: Dictionary = fishing_spot.call("_build_network_evidence") var evidence: Dictionary = fishing_spot.call("_build_network_evidence")
fishing_spot.set("state", FishingSpotType.FishingState.CASTING) fishing_spot.set("state", FishingSpotType.FishingState.CASTING)
player.set_movement_enabled(false) player.set_movement_enabled(false)
@ -388,3 +458,79 @@ func _create_initialized_main() -> Node:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
return main return main
func _wait_for_home(
network_home: NetworkHomeService,
fingerprint: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var manifests: Dictionary = network_home.get(
"_manifests_by_fingerprint"
)
if manifests.has(fingerprint):
return true
return false
func _find_clear_fishing_setup(main: Node, player: Player) -> Dictionary:
var world := main.get_node("TestWorld") as TestWorld
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
var regions: Array[FishableWaterRegion] = world.get_fishable_water_regions()
for region: FishableWaterRegion in regions:
var body := region.get_parent() as WaterBodyAuthoring
if body == null or body.surface_size.x <= 0.0 or body.surface_size.y <= 0.0:
continue
var center: Vector3 = region.global_position
var half_size: Vector2 = body.surface_size * 0.5
var candidate_offsets: Array[Vector3] = [
Vector3(half_size.x + 0.8, 0.0, 0.0),
Vector3(-half_size.x - 0.8, 0.0, 0.0),
Vector3(0.0, 0.0, half_size.y + 0.8),
Vector3(0.0, 0.0, -half_size.y - 0.8),
]
for offset: Vector3 in candidate_offsets:
var position := Vector3(
center.x + offset.x,
region.get_surface_height() + 1.44,
center.z + offset.z,
)
var direction: Vector3 = center - position
direction.y = 0.0
direction = direction.normalized()
player.global_position = position
var visuals := player.get_node("Visuals") as Node3D
visuals.global_rotation.y = atan2(-direction.x, -direction.z)
for _frame: int in 2:
await physics_frame
var cast_origin: Vector3 = player.get_cast_origin_position()
var launch_position: Vector3 = player.get_fishing_rod_tip().global_position
for charge_step: int in range(1, 21):
var cast_charge := float(charge_step) / 20.0
var cast_distance := lerpf(
fishing_spot.minimum_cast_distance,
fishing_spot.maximum_cast_distance,
cast_charge,
)
var query_position := cast_origin + direction * cast_distance
var sample: FishingSurfaceSample = (
fishing_spot.resolve_fishing_surface(
query_position, cast_origin.y
)
)
var target: Vector3 = sample.get_bobber_position(
fishing_spot.solid_bobber_clearance
)
if (
sample.is_fishable()
and fishing_spot.is_cast_path_clear(
launch_position, target
)
):
return {
"position": player.global_position,
"direction": direction,
}
return {}

View file

@ -21,9 +21,13 @@ func _run() -> void:
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT)) assert(session.start_private_host(TEST_PORT))
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
main.call("_enter_gameplay") main.call("_enter_gameplay")
for _frame: int in 8: for _frame: int in 8:
await process_frame await process_frame
@ -38,7 +42,7 @@ func _run() -> void:
and game_ui != null and game_ui != null
and catalog != null and catalog != null
) )
fishing_spot.minimum_showcase_duration = 0.15 fishing_spot.minimum_showcase_duration = 1.0
assert(player.bag.add_item(&"crab_net")) assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net"))
assert(player.hotbar.select_slot(0)) assert(player.hotbar.select_slot(0))
@ -81,7 +85,7 @@ func _run() -> void:
await process_frame await process_frame
assert(fishing_spot.get("_pending_catch") == crab_catch) assert(fishing_spot.get("_pending_catch") == crab_catch)
Input.action_release("fish_primary") Input.action_release("fish_primary")
await create_timer(0.2).timeout fishing_spot.set("_showcase_input_lock_remaining", 0.0)
await process_frame await process_frame
assert(bool(fishing_spot.get("_put_away_press_armed"))) assert(bool(fishing_spot.get("_put_away_press_armed")))
Input.parse_input_event(pocket_event) Input.parse_input_event(pocket_event)

View file

@ -536,6 +536,11 @@ func _validate_generated_region(
-INF, -INF,
0.35, 0.35,
) )
_validate_water_recovery_positions(
region,
grass_prop_surface_triangles,
sand_prop_surface_triangles,
)
var anchors := region.get_node( var anchors := region.get_node(
"GatherableAnchors/ReachableTreeTrunks" "GatherableAnchors/ReachableTreeTrunks"
) as GatherableAnchorSet3D ) as GatherableAnchorSet3D
@ -1268,6 +1273,76 @@ func _triangle_surface_height_at(
return highest return highest
func _validate_water_recovery_positions(
region: GeneratedWorldRegion,
grass_triangles: Array[PackedVector3Array],
sand_triangles: Array[PackedVector3Array],
) -> void:
var fresh_water_root := region.get_node(
"WaterBodies/FreshWaterBodies"
) as Node3D
assert(fresh_water_root != null and fresh_water_root.get_child_count() > 0)
var fresh_water := fresh_water_root.get_child(0) as WaterBodyAuthoring
assert(fresh_water != null)
var entry_position := fresh_water.global_position
var fallback_position := region.get_player_spawn_transform().origin
var recovery_position := region.get_water_recovery_position(
entry_position,
fallback_position,
)
assert(recovery_position.is_finite())
assert(
_horizontal_distance_squared(entry_position, recovery_position)
< _horizontal_distance_squared(entry_position, fallback_position),
"Fresh-water recovery returned the distant generated-world spawn.",
)
assert(
recovery_position.y
> GeneratedWorldRegion.WATER_HEIGHT
+ GeneratedWorldRegion.WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
)
var recovery_triangles: Array[PackedVector3Array] = []
recovery_triangles.append_array(grass_triangles)
recovery_triangles.append_array(sand_triangles)
var center_height := _triangle_surface_height_at(
recovery_position,
recovery_triangles,
)
assert(center_height > -INF)
assert(absf(center_height - recovery_position.y) <= 0.01)
for direction: Vector2 in GeneratedWorldRegion.PROP_SURFACE_SAMPLE_DIRECTIONS:
var footprint_position := recovery_position + Vector3(
direction.x * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
0.0,
direction.y * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
)
var footprint_height := _triangle_surface_height_at(
footprint_position,
recovery_triangles,
)
assert(footprint_height > -INF)
assert(
absf(footprint_height - recovery_position.y)
<= GeneratedWorldRegion.PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE
)
assert(
region.get_water_recovery_position(
entry_position,
fallback_position,
).is_equal_approx(recovery_position)
)
assert(
region.get_water_recovery_position(
Vector3(INF, 0.0, 0.0),
fallback_position,
).is_equal_approx(fallback_position)
)
func _horizontal_distance_squared(a: Vector3, b: Vector3) -> float:
return Vector2(a.x - b.x, a.z - b.z).length_squared()
func _validate_tree_gatherable_anchors( func _validate_tree_gatherable_anchors(
region: GeneratedWorldRegion, region: GeneratedWorldRegion,
decorations: Node3D, decorations: Node3D,

View file

@ -0,0 +1,178 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18222
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Home spawn validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(bool(main.call(
"_prepare_host_world", WorldLayout.STARTER_ISLAND, 1
)))
assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
assert(session.start_private_host(TEST_PORT))
main.call("_enter_gameplay")
assert(session.set_host_open(true))
var host_fingerprint: String = session.get_local_identity_fingerprint()
assert(await _wait_for_home(network_home, host_fingerprint))
var host_avatar: Player = spawn_service.get_avatar(1)
var host_spawn: Transform3D = home_world.get_exterior_spawn_transform(
host_fingerprint
)
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
assert(network_home.request_enter(host_fingerprint))
await physics_frame
host_avatar.global_position = home_world.get_interior_exit_position(
host_fingerprint
)
assert(network_home.request_exit())
await physics_frame
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
var remote_record := session.get_peer_record(remote_peer_id)
var remote_fingerprint: String = remote_record.identity_fingerprint
assert(await _wait_for_home(network_home, remote_fingerprint))
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
var remote_spawn: Transform3D = home_world.get_exterior_spawn_transform(
remote_fingerprint
)
assert(_same_door_position(
remote_avatar.global_position, remote_spawn.origin
))
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Home spawn multiplayer host validation: PASS")
await _cleanup(main, session)
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
var fingerprint: String = session.get_local_identity_fingerprint()
assert(await _wait_for_home(network_home, fingerprint))
var player := main.get("_player") as Player
var exterior_spawn: Transform3D = (
home_world.get_exterior_spawn_transform(fingerprint)
)
deadline = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < deadline
and not _same_door_position(
player.global_position, exterior_spawn.origin
)
):
await physics_frame
assert(_same_door_position(
player.global_position, exterior_spawn.origin
))
print("Home spawn multiplayer client validation: PASS")
await _cleanup(main, session)
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_home(
network_home: NetworkHomeService,
fingerprint: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var manifests: Dictionary = network_home.get(
"_manifests_by_fingerprint"
)
if manifests.has(fingerprint):
return true
return false
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0
func _same_door_position(first: Vector3, second: Vector3) -> bool:
return (
Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z))
< 0.08
and absf(first.y - second.y) < 0.3
)
func _cleanup(main: Node, session: NetworkSession) -> void:
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()

View file

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

View file

@ -21,13 +21,17 @@ func _run() -> void:
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
assert(session != null) assert(session != null)
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
var occupied_port := PacketPeerUDP.new() var occupied_port := PacketPeerUDP.new()
assert(occupied_port.bind(18137, "127.0.0.1") == OK) assert(occupied_port.bind(18137, "127.0.0.1") == OK)
assert(session.start_private_host(18137, 3)) assert(session.start_private_host(18137, 3))
assert(session.get_host_port() == 18138) assert(session.get_host_port() == 18138)
occupied_port.close() occupied_port.close()
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
main.call("_enter_gameplay") main.call("_enter_gameplay")
await physics_frame await physics_frame
await physics_frame await physics_frame
@ -205,8 +209,11 @@ func _run() -> void:
) )
assert(bool(decoded.get("ok", false))) assert(bool(decoded.get("ok", false)))
var save_data: Dictionary = decoded["data"] var save_data: Dictionary = decoded["data"]
assert(int(save_data.get("save_version", -1)) == 10) assert(int(save_data.get("save_version", -1)) == 11)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
assert(not PlayerHomeState.sanitize_save_data(
save_data.get("home", {})
).is_empty())
_validate_pause_session_switch(main, session) _validate_pause_session_switch(main, session)
main.queue_free() main.queue_free()

View file

@ -90,11 +90,12 @@ func _run_first_client() -> void:
player.toggle_sitting() player.toggle_sitting()
assert(player.is_sitting()) assert(player.is_sitting())
assert(player.bag.add_item(&"crab_net")) assert(player.bag.add_item(&"crab_net"))
assert(player.hotbar.assign_item(0, &"crab_net")) # Slot zero begins selected with the starter rod. Replacing that slot does
assert(player.hotbar.select_slot(0)) # not emit a selection change, so use the next slot to exercise the same
for _frame: int in 2: # equipped-item path a player uses in normal gameplay.
await process_frame assert(player.hotbar.assign_item(1, &"crab_net"))
assert(bool(player.get("_active_item_is_net"))) assert(player.hotbar.select_slot(1))
assert(await _wait_for_local_net(main, player))
assert(player.begin_animation_action(ACTIVE_ACTION)) assert(player.begin_animation_action(ACTIVE_ACTION))
await create_timer(0.5).timeout await create_timer(0.5).timeout
assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION) assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION)
@ -185,6 +186,29 @@ func _wait_for_registry_size(session: NetworkSession, expected: int) -> bool:
return false return false
func _wait_for_local_net(main: Node, player: Player) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
# A joined client can finish its world transition one frame after the
# hotbar selection signal. Refresh through the production path until the
# ready fishing state accepts the equipped item.
main.call("_refresh_active_hotbar_item")
await process_frame
if bool(player.get("_active_item_is_net")):
return true
var fishing_spot: Node = main.get_node("%FishingSpot")
print(
"net equip wait timed out: ",
{
"fishing_state": int(fishing_spot.get("state")),
"selected_slot": player.hotbar.get_selected_slot(),
"selected_item": String(player.hotbar.get_selected_item_id()),
"owns_net": player.bag.owns_item(&"crab_net"),
},
)
return false
func _history_contains(service: NetworkChatService, body: String) -> bool: func _history_contains(service: NetworkChatService, body: String) -> bool:
return service.get_history().any( return service.get_history().any(
func(message: Dictionary) -> bool: func(message: Dictionary) -> bool:

View file

@ -2,6 +2,7 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn") const MainScene = preload("res://main/main.tscn")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const MENU_TRANSITION_SETTLE_SECONDS := 3.0
func _initialize() -> void: func _initialize() -> void:
@ -36,12 +37,16 @@ func _run() -> void:
var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage var logbook := player_menu.get_node("%CatalogLogbook") as LogbookPage
var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect var backdrop := main.get_node("%PlayerMenuBackdrop") as ColorRect
var hotbar := game_ui.get_node("%Hotbar") as Control var hotbar := game_ui.get_node("%Hotbar") as Control
var fishing_spot := main.get("_fishing_spot") as FishingSpot
assert(logbook.get("_fishing_spot") == fishing_spot)
assert(logbook.get("_world_time") == main.get("_world_time"))
assert(logbook.get("_world_weather") == main.get("_world_weather"))
var shortcut := InputEventKey.new() var shortcut := InputEventKey.new()
shortcut.pressed = true shortcut.pressed = true
shortcut.physical_keycode = KEY_L shortcut.physical_keycode = KEY_L
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
assert(player_menu.visible) assert(player_menu.visible)
assert(logbook.visible) assert(logbook.visible)
assert(backdrop.visible) assert(backdrop.visible)
@ -51,6 +56,18 @@ func _run() -> void:
var entry_buttons: Dictionary = logbook.get("_entry_buttons") var entry_buttons: Dictionary = logbook.get("_entry_buttons")
assert(entry_buttons.size() == 108) assert(entry_buttons.size() == 108)
await _capture_if_requested("-unknown") await _capture_if_requested("-unknown")
logbook.call("_set_index_open", true, false)
await process_frame
assert(bool(logbook.get("_index_open")))
assert((logbook.get("_index_body") as Control).visible)
await _capture_if_requested("-index")
logbook.set_compact_presentation(true)
await process_frame
await _capture_if_requested("-index-compact")
logbook.set_compact_presentation(false)
await process_frame
logbook.call("_set_index_open", false, false)
await process_frame
logbook.call( logbook.call(
"_select_category", WaterType.Type.FRESH_WATER "_select_category", WaterType.Type.FRESH_WATER
) )
@ -68,13 +85,29 @@ func _run() -> void:
logbook.call("_select_entry", &"bluegill", &"bluegill") logbook.call("_select_entry", &"bluegill", &"bluegill")
await create_timer(0.2).timeout await create_timer(0.2).timeout
await _capture_if_requested("-known") await _capture_if_requested("-known")
var bluegill := (
main.get("fish_catalog") as FishPool
).get_fish_by_id(&"bluegill")
var bluegill_available_now := fishing_spot.is_species_available_now(bluegill)
var index_options := logbook.get("_index_options") as Dictionary
index_options["availability"] = (
LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
)
logbook.notify_availability_context_changed()
await process_frame
assert(
(logbook.get("_entry_buttons") as Dictionary).has(&"bluegill")
== bluegill_available_now
)
logbook.call("_reset_index")
await process_frame
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
assert(not player_menu.visible) assert(not player_menu.visible)
assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut))) assert(bool(player_menu.call("_handle_direct_page_shortcut", shortcut)))
await create_timer(2.2).timeout await create_timer(MENU_TRANSITION_SETTLE_SECONDS).timeout
player_menu.call( player_menu.call(
"_show_section_immediate", PlayerMenu.Section.PROFILE "_show_section_immediate", PlayerMenu.Section.PROFILE
) )

View file

@ -17,6 +17,8 @@ const CatalogResource: FishPoolType = preload(
"res://fish/pools/fish_catalog.tres" "res://fish/pools/fish_catalog.tres"
) )
var _query_available_id: StringName
func _initialize() -> void: func _initialize() -> void:
call_deferred("_run") call_deferred("_run")
@ -35,6 +37,7 @@ func _run() -> void:
quit() quit()
return return
_validate_catalog() _validate_catalog()
_validate_catalog_query()
await _validate_page() await _validate_page()
print("Logbook validation: PASS") print("Logbook validation: PASS")
quit() quit()
@ -68,6 +71,348 @@ func _validate_catalog() -> void:
) )
func _validate_catalog_query() -> void:
var collection := CollectionLogType.new()
var inventory := FishInventoryType.new()
var fresh_species: Array[FishDataType] = []
for fish: FishDataType in LogbookCatalog.ordered_species(
CatalogResource.candidates
):
if LogbookCatalog.category_for(fish) == LogbookCatalog.Category.FRESH_WATER:
fresh_species.append(fish)
assert(fresh_species.size() == 108)
var first := fresh_species[0]
var second := fresh_species[1]
var unknown := fresh_species[2]
for quality: int in FishQualityType.TIER_COUNT:
collection.record_catch(first.id, quality)
collection.record_catch(second.id, FishQualityType.Tier.BORING)
var held := _make_test_catch(first, true)
assert(inventory.add_catch(held))
var options := _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
var filtered := LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.size() == 2)
assert(first in filtered and second in filtered)
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.size() == fresh_species.size() - 2)
options["sort"] = LogbookCatalog.SortMode.CATALOG_NUMBER
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.front() == fresh_species.back())
assert(filtered.back() == unknown)
options["show"] = LogbookCatalog.DiscoveryFilter.INCOMPLETE
options["descending"] = false
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [second])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["sort"] = LogbookCatalog.SortMode.CATCH_COUNT
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first, second])
options = _default_query_options()
options["search"] = unknown.display_name
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered.is_empty())
options["search"] = str(unknown.catalog_number)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [unknown])
options = _default_query_options()
options["holding"] = LogbookCatalog.HoldingFilter.FAVORITED
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first])
options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["group"] = String(first.collection_group)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.collection_group == first.collection_group)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["rarity"] = (
int(first.rarity) + LogbookCatalog.RarityFilter.COMMON
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.rarity == first.rarity)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["method"] = (
int(first.collection_method) + LogbookCatalog.MethodFilter.FISHING
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.collection_method == first.collection_method)
var first_season := -1
for season: int in 4:
if first.is_available_in_season(season):
first_season = season
break
assert(first_season >= 0)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["season"] = (
first_season + LogbookCatalog.SeasonFilter.SPRING
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
for fish: FishDataType in filtered:
assert(fish.is_available_in_season(first_season))
var first_is_available_by_day := (
first.availability == null or first.availability.allow_day
)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["time"] = (
LogbookCatalog.TimeFilter.DAY
if first_is_available_by_day
else LogbookCatalog.TimeFilter.NIGHT
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(first in filtered)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.CAUGHT
options["sort"] = LogbookCatalog.SortMode.QUALITY_PROGRESS
options["descending"] = true
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered == [first, second])
for sort_mode: LogbookCatalog.SortMode in [
LogbookCatalog.SortMode.NAME,
LogbookCatalog.SortMode.RARITY,
LogbookCatalog.SortMode.VALUE,
LogbookCatalog.SortMode.WEIGHT,
]:
options = _default_query_options()
options["sort"] = sort_mode
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(filtered[0] in [first, second])
assert(filtered[1] in [first, second])
assert(filtered[0] != filtered[1])
assert(filtered[2] == unknown)
_query_available_id = second.id
options = _default_query_options()
options["availability"] = (
LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
Callable(self, "_query_species_available_now"),
)
assert(filtered == [second])
_query_available_id = unknown.id
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["rarity"] = (
int(unknown.rarity) + LogbookCatalog.RarityFilter.COMMON
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
Callable(self, "_query_species_available_now"),
)
assert(filtered == [unknown])
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["group"] = String(unknown.collection_group)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(unknown in filtered)
assert(
String(unknown.collection_group)
in LogbookCatalog.available_groups(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
)
)
var unknown_is_available_by_day := (
unknown.availability == null or unknown.availability.allow_day
)
options = _default_query_options()
options["show"] = LogbookCatalog.DiscoveryFilter.MISSING
options["time"] = (
LogbookCatalog.TimeFilter.DAY
if unknown_is_available_by_day
else LogbookCatalog.TimeFilter.NIGHT
)
filtered = LogbookCatalog.filtered_species(
CatalogResource.candidates,
LogbookCatalog.Category.FRESH_WATER,
collection,
inventory,
options,
)
assert(unknown in filtered)
inventory.free()
collection.free()
func _default_query_options() -> Dictionary:
return {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
func _make_test_catch(fish: FishDataType, favorited: bool) -> FishCatchType:
var fish_catch := FishCatchType.new()
fish_catch.fish = fish
fish_catch.fish_id = fish.id
fish_catch.weight_lb = fish.get_minimum_weight()
fish_catch.display_scale = fish.get_display_scale_for_weight(
fish_catch.weight_lb
)
fish_catch.quality = FishQualityType.Tier.BORING
fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb)
fish_catch.is_favorited = favorited
fish_catch.ensure_identity()
return fish_catch
func _query_species_available_now(fish: FishDataType) -> bool:
return fish != null and fish.id == _query_available_id
func _validate_page() -> void: func _validate_page() -> void:
var collection := CollectionLogType.new() var collection := CollectionLogType.new()
var inventory := FishInventoryType.new() var inventory := FishInventoryType.new()
@ -91,6 +436,10 @@ func _validate_page() -> void:
assert(outer_margin.get_theme_constant("margin_right") == 160) assert(outer_margin.get_theme_constant("margin_right") == 160)
assert(outer_margin.get_theme_constant("margin_bottom") == 106) assert(outer_margin.get_theme_constant("margin_bottom") == 106)
assert(is_equal_approx(book.offset_top, 65.0)) assert(is_equal_approx(book.offset_top, 65.0))
page.call("_set_index_open", true, false)
await process_frame
_validate_index_content_bounds(page)
page.call("_set_index_open", false, false)
page.set_compact_presentation(false) page.set_compact_presentation(false)
await process_frame await process_frame
assert(outer_margin.get_theme_constant("margin_left") == 52) assert(outer_margin.get_theme_constant("margin_left") == 52)
@ -158,6 +507,39 @@ func _validate_page() -> void:
) )
var initial_detail_body := page.get("_detail_body") as VBoxContainer var initial_detail_body := page.get("_detail_body") as VBoxContainer
assert(not initial_detail_body.get_parent() is ScrollContainer) assert(not initial_detail_body.get_parent() is ScrollContainer)
var index_tab := page.get("_index_tab") as Button
var index_body := page.get("_index_body") as VBoxContainer
assert(index_tab != null and index_body != null)
assert(index_tab.size == LogbookPage.INDEX_TAB_SIZE)
assert(
not index_tab.get_global_rect().intersects(
(category_tabs.back() as Button).get_global_rect()
)
)
page.call("_set_index_open", true, false)
await process_frame
assert(index_body.visible)
assert(not initial_detail_body.visible)
assert((page.get("_index_option_buttons") as Dictionary).size() == 10)
_validate_index_content_bounds(page)
page.call(
"_cycle_index_option",
&"show",
1,
)
await process_frame
assert((page.get("_entry_buttons") as Dictionary).is_empty())
assert(
(page.get("_empty_state") as Label).text
== "No entries match this index."
)
page.call("_reset_index")
await process_frame
assert((page.get("_entry_buttons") as Dictionary).size() == 108)
page.call("_set_index_open", false, false)
await process_frame
assert(not index_body.visible)
assert(initial_detail_body.visible)
assert( assert(
page.call("_entry_label_text", "flathead catfish") page.call("_entry_label_text", "flathead catfish")
== "flathead\ncatfish" == "flathead\ncatfish"
@ -458,8 +840,19 @@ func _validate_page() -> void:
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit() (page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame await process_frame
var live_index_options := page.get("_index_options") as Dictionary
live_index_options["holding"] = LogbookCatalog.HoldingFilter.IN_COOLER
page.call("_refresh_catalog")
await process_frame
assert(
(page.get("_entry_buttons") as Dictionary).keys()
== [&"bluegill"]
)
inventory.remove_catch_by_id(fish_catch.catch_id) inventory.remove_catch_by_id(fish_catch.catch_id)
await process_frame await process_frame
assert((page.get("_entry_buttons") as Dictionary).is_empty())
page.call("_reset_index")
await process_frame
assert(not _detail_text(page).contains("number owned")) assert(not _detail_text(page).contains("number owned"))
page.queue_free() page.queue_free()
collection.queue_free() collection.queue_free()
@ -474,6 +867,54 @@ func _detail_text(page: LogbookPage) -> String:
return "\n".join(values) return "\n".join(values)
func _validate_index_content_bounds(page: LogbookPage) -> void:
var body := page.get("_index_body") as VBoxContainer
assert(body != null and body.visible)
var right_page := body.get_parent() as Control
assert(right_page != null)
var page_rect := right_page.get_global_rect()
var body_rect := body.get_global_rect()
var protected_rect := Rect2(
page_rect.position + Vector2(
LogbookPage.INDEX_CONTENT_LEFT_INSET,
LogbookPage.INDEX_CONTENT_TOP_INSET,
),
page_rect.size - Vector2(
LogbookPage.INDEX_CONTENT_LEFT_INSET
+ LogbookPage.INDEX_CONTENT_RIGHT_INSET,
LogbookPage.INDEX_CONTENT_TOP_INSET
+ LogbookPage.INDEX_CONTENT_BOTTOM_INSET,
),
)
assert(
body_rect.position.x
>= page_rect.position.x + LogbookPage.INDEX_CONTENT_LEFT_INSET - 1.0
)
assert(
body_rect.end.x
<= page_rect.end.x - LogbookPage.INDEX_CONTENT_RIGHT_INSET + 1.0
)
assert(
body_rect.position.y >= page_rect.position.y,
"Index starts above the authored page: body=%s page=%s"
% [body_rect, page_rect],
)
assert(
body_rect.end.y <= page_rect.end.y,
"Index extends below the authored page: body=%s page=%s"
% [body_rect, page_rect],
)
var controls: Array = page.get("_index_controls") as Array
for value: Variant in controls:
var control := value as Control
if control != null and control.visible:
assert(
protected_rect.grow(2.0).encloses(control.get_global_rect()),
"Index control crossed the authored right-page boundary: %s"
% control.name,
)
func _descendant_label_text(parent: Node) -> String: func _descendant_label_text(parent: Node) -> String:
var values: PackedStringArray = [] var values: PackedStringArray = []
for node: Node in parent.find_children("*", "Label", true, false): for node: Node in parent.find_children("*", "Label", true, false):

View file

@ -593,15 +593,22 @@ func _run_host() -> void:
var main: Node = await _create_initialized_main() var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(session.set_host_world( assert(bool(main.call(
"_apply_world",
WorldLayout.STARTER_ISLAND, WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED, NetworkProtocol.DEFAULT_WORLD_SEED,
)) true,
assert(session.start_private_host(TEST_PORT)) )))
assert(bool(main.call(
"_prepare_host_world",
WorldLayout.STARTER_ISLAND,
NetworkProtocol.DEFAULT_WORLD_SEED,
)))
assert(save_manager.initialize_new_game( assert(save_manager.initialize_new_game(
NetworkProtocol.DEFAULT_WORLD_SEED, NetworkProtocol.DEFAULT_WORLD_SEED,
WorldLayout.STARTER_ISLAND, WorldLayout.STARTER_ISLAND,
)) ))
assert(session.start_private_host(TEST_PORT))
main.call("_enter_gameplay") main.call("_enter_gameplay")
for _frame: int in 4: for _frame: int in 4:
await physics_frame await physics_frame
@ -613,31 +620,40 @@ func _run_host() -> void:
# to settle before emitting the one-shot locomotion/action sequence. # to settle before emitting the one-shot locomotion/action sequence.
await create_timer(3.0).timeout await create_timer(3.0).timeout
var player := main.get("_player") as Player var player := main.get("_player") as Player
var world := main.get_node("TestWorld") as TestWorld
player.configure_network_remote(true) player.configure_network_remote(true)
player.apply_authoritative_network_input(_movement_input(1, true)) await _reset_locomotion_test_position(player, session, world)
await create_timer(2.5).timeout var input_sequence: int = 1
player.apply_authoritative_network_input(_movement_input(2, false)) input_sequence = await _sustain_locomotion_input(
await create_timer(2.5).timeout player, input_sequence, 1.5, true
player.apply_authoritative_network_input(
_movement_input(3, false, true, true)
) )
await create_timer(2.0).timeout await _reset_locomotion_test_position(player, session, world)
player.apply_authoritative_network_input( input_sequence = await _sustain_locomotion_input(
_movement_input(4, false, false, true) player, input_sequence, 1.5, false
) )
await create_timer(2.0).timeout await _reset_locomotion_test_position(player, session, world)
player.apply_authoritative_network_input( input_sequence = await _sustain_locomotion_input(
_movement_input(5, false, false, true, &"draw", 1) player, input_sequence, 1.5, false, true, true
) )
input_sequence = await _sustain_locomotion_input(
player, input_sequence, 1.5, false, false, true
)
player.apply_authoritative_network_input(
_movement_input(input_sequence, false, false, true, &"draw", 1)
)
input_sequence += 1
await create_timer(1.0).timeout await create_timer(1.0).timeout
player.apply_authoritative_network_input( player.apply_authoritative_network_input(
_movement_input(6, false, false, true, &"strike", 2, true) _movement_input(
input_sequence, false, false, true, &"strike", 2, true
)
) )
input_sequence += 1
await create_timer(2.0).timeout await create_timer(2.0).timeout
player.apply_authoritative_network_input( player.apply_authoritative_network_input(
_movement_input(7, false, false, false, &"", 3) _movement_input(input_sequence, false, false, false, &"", 3)
) )
var disconnect_deadline: int = Time.get_ticks_msec() + 8000 var disconnect_deadline: int = Time.get_ticks_msec() + 15000
while ( while (
Time.get_ticks_msec() < disconnect_deadline Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id) and session.is_authenticated_peer(remote_peer_id)
@ -686,12 +702,27 @@ func _run_client() -> void:
var saw_net_strike_paused: bool = false var saw_net_strike_paused: bool = false
var saw_animation_advance: bool = false var saw_animation_advance: bool = false
var saw_dust: bool = false var saw_dust: bool = false
var observed_animations: Dictionary = {}
var observed_locomotion_states: Dictionary = {}
var maximum_sneak_speed: float = 0.0
var previous_animation: StringName = &"" var previous_animation: StringName = &""
var previous_animation_position: float = -1.0 var previous_animation_position: float = -1.0
var observation_deadline: int = Time.get_ticks_msec() + 18000 var observation_deadline: int = Time.get_ticks_msec() + 22000
while Time.get_ticks_msec() < observation_deadline: while Time.get_ticks_msec() < observation_deadline:
await process_frame await process_frame
var current_animation: StringName = animation_player.current_animation var current_animation: StringName = animation_player.current_animation
observed_animations[current_animation] = true
var target_locomotion_state: int = int(host_avatar.get(
"_network_target_locomotion_state"
))
observed_locomotion_states[target_locomotion_state] = true
if target_locomotion_state == Player.LocomotionState.SNEAKING:
maximum_sneak_speed = maxf(
maximum_sneak_speed,
Vector2(
host_avatar.velocity.x, host_avatar.velocity.z
).length(),
)
saw_running = saw_running or current_animation.begins_with("running") saw_running = saw_running or current_animation.begins_with("running")
saw_walking = saw_walking or current_animation.begins_with("walking") saw_walking = saw_walking or current_animation.begins_with("walking")
saw_sneaking = saw_sneaking or current_animation == &"sneaking" saw_sneaking = saw_sneaking or current_animation == &"sneaking"
@ -732,15 +763,44 @@ func _run_client() -> void:
and saw_dust and saw_dust
): ):
break break
assert(saw_running) var missing_observations: Array[String] = []
assert(saw_walking) if not saw_running:
assert(saw_sneaking) missing_observations.append("running")
assert(saw_idle_sneak) if not saw_walking:
assert(saw_net_draw) missing_observations.append("walking")
assert(saw_net_strike) if not saw_sneaking:
assert(saw_net_strike_paused) missing_observations.append("sneaking")
assert(saw_animation_advance) if not saw_idle_sneak:
assert(saw_dust) missing_observations.append("idle_sneak")
if not saw_net_draw:
missing_observations.append("net_draw")
if not saw_net_strike:
missing_observations.append("net_strike")
if not saw_net_strike_paused:
missing_observations.append("net_strike_paused")
if not saw_animation_advance:
missing_observations.append("animation_advance")
if not saw_dust:
missing_observations.append("sprint_dust")
if not missing_observations.is_empty():
push_error(
(
"Missing movement observations %s; observed animations: %s; "
+ "locomotion states: %s; maximum sneak speed: %.3f"
) % [
missing_observations,
observed_animations.keys(),
observed_locomotion_states.keys(),
maximum_sneak_speed,
]
)
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit(1)
return
print("Movement multiplayer client validation: PASS") print("Movement multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
@ -778,6 +838,39 @@ func _movement_input(
} }
func _sustain_locomotion_input(
player: Player,
starting_sequence: int,
duration_seconds: float,
sprinting: bool,
moving: bool = true,
sneaking: bool = false,
) -> int:
var sequence: int = starting_sequence
var deadline: int = (
Time.get_ticks_msec() + roundi(duration_seconds * 1000.0)
)
while Time.get_ticks_msec() < deadline:
player.apply_authoritative_network_input(
_movement_input(sequence, sprinting, moving, sneaking)
)
sequence += 1
await create_timer(0.1).timeout
return sequence
func _reset_locomotion_test_position(
player: Player,
session: NetworkSession,
world: TestWorld,
) -> void:
player.velocity = Vector3.ZERO
player.global_transform = world.get_player_spawn_transform()
session.publish_authoritative_teleport(1)
for _frame: int in 4:
await physics_frame
func _create_initialized_main() -> Node: func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720) root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate() var main: Node = MainScene.instantiate()

View file

@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
version_five, version_five,
5, 5,
) )
assert(int(migrated.get("save_version", -1)) == 10) assert(int(migrated.get("save_version", -1)) == 11)
var experience_data: Dictionary = migrated.get("experience", {}) var experience_data: Dictionary = migrated.get("experience", {})
assert(int(experience_data.get("total_experience", -1)) == 0) assert(int(experience_data.get("total_experience", -1)) == 0)
var world_data: Dictionary = migrated.get("world", {}) var world_data: Dictionary = migrated.get("world", {})
@ -155,6 +155,9 @@ func _validate_save_migration() -> void:
assert( assert(
PlayerJobService.validate_save_data(migrated.get("jobs", {})) PlayerJobService.validate_save_data(migrated.get("jobs", {}))
) )
assert(not PlayerHomeState.sanitize_save_data(
migrated.get("home", {})
).is_empty())
manager.queue_free() manager.queue_free()

View file

@ -22,11 +22,24 @@ func _run() -> void:
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
var player := main.get("_player") as Player var player := main.get("_player") as Player
var data_root := main.get("_data_root") as PlayerDataRoot var data_root := main.get("_data_root") as PlayerDataRoot
assert(save_manager != null and player != null and data_root != null) var home_state := main.get("_home_state") as PlayerHomeState
assert(
save_manager != null
and player != null
and data_root != null
and home_state != null
)
assert(save_manager.initialize_new_game(864209)) assert(save_manager.initialize_new_game(864209))
save_manager.set_autosave_enabled(true) save_manager.set_autosave_enabled(true)
assert(player.wallet.restore_balance(4321)) assert(player.wallet.restore_balance(4321))
assert(player.bag.add_item(&"coffee")) assert(player.bag.add_item(&"coffee"))
assert(home_state.add_owned_decor(&"basic_cube"))
assert(not home_state.place_decor(
&"basic_cube",
Vector3.ZERO,
0.0,
PlayerHomeState.PlacementMode.GRID,
).is_empty())
assert(save_manager.save_now()) assert(save_manager.save_now())
var save_path: String = str(save_manager.get("_save_path")) var save_path: String = str(save_manager.get("_save_path"))
@ -38,7 +51,14 @@ func _run() -> void:
_assert_opaque(save_path) _assert_opaque(save_path)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false))) assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 10) assert(int((decoded["data"] as Dictionary)["save_version"]) == 11)
assert(not PlayerHomeState.sanitize_save_data(
(decoded["data"] as Dictionary).get("home", {})
).is_empty())
assert(
((decoded["data"] as Dictionary)["home"] as Dictionary)
.get("placements", []).size() == 1
)
var legacy_container_path: String = data_root.progression_backup_directory().path_join( var legacy_container_path: String = data_root.progression_backup_directory().path_join(
"netfishing-container.nfsave" "netfishing-container.nfsave"
) )
@ -74,6 +94,8 @@ func _run() -> void:
assert(save_manager.load_player_data()) assert(save_manager.load_player_data())
assert(player.wallet.get_balance() == 4321) assert(player.wallet.get_balance() == 4321)
assert(save_manager.get_world_seed() == 864209) assert(save_manager.get_world_seed() == 864209)
assert(home_state.get_owned_count(&"basic_cube") == 1)
assert(home_state.get_placed_count(&"basic_cube") == 1)
var legacy_data: Dictionary = ( var legacy_data: Dictionary = (
ProgressionSaveCodec.read_local_save(save_path)["data"] ProgressionSaveCodec.read_local_save(save_path)["data"]

View file

@ -27,10 +27,12 @@ func _run_host() -> void:
assert(bool(main.call( assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true "_apply_world", WorldLayout.STARTER_ISLAND, 1, true
))) )))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(bool(main.call(
assert(session.start_private_host(TEST_PORT)) "_prepare_host_world", WorldLayout.STARTER_ISLAND, 1
)))
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
assert(session.start_private_host(TEST_PORT))
main.call("_enter_gameplay") main.call("_enter_gameplay")
await physics_frame await physics_frame
await physics_frame await physics_frame
@ -62,6 +64,40 @@ func _run_host() -> void:
remote_peer_id remote_peer_id
) )
assert(remote_record != null) assert(remote_record != null)
var network_home := main.get_node(
"%NetworkHomeService"
) as NetworkHomeService
var home_world := main.get_node("%HomeWorldService") as HomeWorldService
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
assert(await _wait_for_home(
network_home, remote_record.identity_fingerprint
))
var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id)
assert(remote_avatar != null)
var exterior_spawn: Transform3D = home_world.get_exterior_spawn_transform(
remote_record.identity_fingerprint
)
var exterior_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < exterior_deadline
and remote_avatar.global_position.distance_to(exterior_spawn.origin) > 0.25
):
await physics_frame
assert(remote_avatar.global_position.distance_to(exterior_spawn.origin) <= 0.25)
var canvas_state: Dictionary = service.get_canvas_state(canvas_id)
var canvas_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
canvas_state["origin"]
)
remote_avatar.global_position = Vector3(
canvas_origin.x + 1.0,
player.global_position.y,
canvas_origin.z,
)
session.publish_authoritative_teleport(remote_peer_id)
for _frame: int in 4:
await physics_frame
assert(session.peer_supports_capability( assert(session.peer_supports_capability(
remote_peer_id, SurfaceDrawingProtocol.CAPABILITY remote_peer_id, SurfaceDrawingProtocol.CAPABILITY
)) ))
@ -152,6 +188,9 @@ func _run_host() -> void:
): ):
await process_frame await process_frame
assert(service.get_canvas_ids().size() == 1) assert(service.get_canvas_ids().size() == 1)
# Keep the second stamp observable on the client before the ban removes all
# artwork carrying that participant fingerprint.
await create_timer(1.0).timeout
var player_list := main.get_node( var player_list := main.get_node(
"%NetworkPlayerListService" "%NetworkPlayerListService"
) as NetworkPlayerListService ) as NetworkPlayerListService
@ -221,6 +260,20 @@ func _run_client() -> void:
var canvas_id: String = service.get_canvas_ids()[0] var canvas_id: String = service.get_canvas_ids()[0]
var snapshot: Dictionary = service.get_canvas_state(canvas_id) var snapshot: Dictionary = service.get_canvas_state(canvas_id)
assert(str(_find_cell(snapshot, 7, 7).get("color_id", "")) == "coral") assert(str(_find_cell(snapshot, 7, 7).get("color_id", "")) == "coral")
var canvas_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
snapshot["origin"]
)
var placement_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < placement_deadline
and player.global_position.distance_to(canvas_origin)
> NetworkSurfaceDrawingService.MAX_DRAW_DISTANCE + 2.0
):
await physics_frame
assert(
player.global_position.distance_to(canvas_origin)
<= NetworkSurfaceDrawingService.MAX_DRAW_DISTANCE + 2.0
)
assert(service.request_cell_edits(canvas_id, [{ assert(service.request_cell_edits(canvas_id, [{
"x": 7, "x": 7,
"y": 7, "y": 7,
@ -381,3 +434,18 @@ func _create_initialized_main() -> Node:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
return main return main
func _wait_for_home(
network_home: NetworkHomeService,
fingerprint: String,
) -> bool:
var deadline: int = Time.get_ticks_msec() + 10000
while Time.get_ticks_msec() < deadline:
await process_frame
var manifests: Dictionary = network_home.get(
"_manifests_by_fingerprint"
)
if manifests.has(fingerprint):
return true
return false

View file

@ -20,9 +20,13 @@ func _run() -> void:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
assert(bool(main.call(
"_apply_world", WorldLayout.STARTER_ISLAND, 1, true
)))
assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1))
assert(session.start_private_host(TEST_PORT)) assert(session.start_private_host(TEST_PORT))
var save_manager := main.get("_save_manager") as PlayerSaveManager var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(save_manager.initialize_new_game()) assert(save_manager.initialize_new_game(1, WorldLayout.STARTER_ISLAND))
main.call("_enter_gameplay") main.call("_enter_gameplay")
await physics_frame await physics_frame
await physics_frame await physics_frame

View file

@ -35,6 +35,41 @@ func _run() -> void:
hotbar.setup(bag, ItemCatalogResource, catches, layout) hotbar.setup(bag, ItemCatalogResource, catches, layout)
assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36]) assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36])
assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72]) assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72])
var basic_cube: ItemData = ItemCatalogResource.get_item_by_id(&"basic_cube")
assert(basic_cube != null)
assert(basic_cube.icon != null)
assert(basic_cube.hotbar_allowed)
assert(bag.add_item(&"basic_cube"))
assert(layout.is_item_in_inventory(&"basic_cube"))
assert(hotbar.assign_item(0, &"basic_cube"))
assert(hotbar.get_item_id(0) == &"basic_cube")
assert(
layout.get_container(
PlayerInventoryLayout.EntryKind.ITEM,
&"basic_cube",
) == PlayerInventoryLayout.InventoryContainer.HOTBAR
)
assert(hotbar.clear_slot(0))
assert(layout.is_item_in_inventory(&"basic_cube"))
assert(bag.remove_item(&"basic_cube"))
var packed_decor_slot: int = hotbar.find_first_empty_slot()
assert(packed_decor_slot == 0)
assert(layout.prepare_new_item_placement(
&"basic_cube",
PlayerInventoryLayout.InventoryContainer.HOTBAR,
packed_decor_slot,
))
assert(bag.add_item(&"basic_cube"))
assert(
layout.get_container(
PlayerInventoryLayout.EntryKind.ITEM,
&"basic_cube",
) == PlayerInventoryLayout.InventoryContainer.HOTBAR
)
assert(hotbar.assign_item(packed_decor_slot, &"basic_cube"))
assert(hotbar.select_slot(packed_decor_slot))
assert(hotbar.get_selected_item_id() == &"basic_cube")
assert(bag.remove_item(&"basic_cube"))
assert(bag.add_item(&"coffee", 3)) assert(bag.add_item(&"coffee", 3))
assert(layout.get_inventory_count() == 1) assert(layout.get_inventory_count() == 1)

View file

@ -100,6 +100,14 @@ func _validate_world_switching() -> void:
assert(world.get_fishing_shop() != null) assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null) assert(world.get_player_storage() != null)
assert(not world.get_fishable_water_regions().is_empty()) assert(not world.get_fishable_water_regions().is_empty())
var starter_safe_points := world.get_safe_respawn_points()
assert(not starter_safe_points.is_empty())
var authored_safe_point: SafeRespawnPoint = starter_safe_points.back()
assert(
world.get_water_recovery_position(
authored_safe_point.global_position
).is_equal_approx(authored_safe_point.global_position)
)
assert(world.activate_world(WorldLayout.GENERATED, 24680)) assert(world.activate_world(WorldLayout.GENERATED, 24680))
assert(world.get_world_layout() == WorldLayout.GENERATED) assert(world.get_world_layout() == WorldLayout.GENERATED)
assert(world.get_generation_seed() == 24680) assert(world.get_generation_seed() == 24680)

View file

@ -16,14 +16,18 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
func _initialize() -> void: func _initialize() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 11) call_deferred(&"_run")
func _run() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 12)
assert( assert(
NetworkWorldSpawnProtocol.CAPABILITY NetworkWorldSpawnProtocol.CAPABILITY
== NetworkProtocol.WORLD_SPAWN_CAPABILITY == NetworkProtocol.WORLD_SPAWN_CAPABILITY
) )
assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4) assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4)
_validate_catalog_statuses() _validate_catalog_statuses()
_validate_billboard_presentation() await _validate_billboard_presentation()
_validate_envelopes() _validate_envelopes()
print("World spawn protocol validation: PASS") print("World spawn protocol validation: PASS")
quit() quit()
@ -148,8 +152,27 @@ func _validate_billboard_presentation() -> void:
assert(not sprite.shaded) assert(not sprite.shaded)
gatherable.free() gatherable.free()
var unconfigured_hotspot := WorldGatherableType.new()
unconfigured_hotspot.call("_ensure_water_spurt_visual")
assert(
unconfigured_hotspot.get_node_or_null(
"WaterSpurt/WaterDroplets"
) == null
)
unconfigured_hotspot.free()
var clam: GatherableData = Gatherables.get_entry(&"clam_manila")
assert(clam != null)
var hotspot := WorldGatherableType.new() var hotspot := WorldGatherableType.new()
hotspot.call("_ensure_water_spurt_visual") root.add_child(hotspot)
var configured_position := Vector3(24.0, 0.3, -11.0)
hotspot.configure(
"world:clam-presentation-regression",
clam,
configured_position,
0.0,
)
assert(hotspot.global_position.is_equal_approx(configured_position))
var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D
assert(hole != null) assert(hole != null)
assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF) assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
@ -158,6 +181,18 @@ func _validate_billboard_presentation() -> void:
assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED) assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED)
assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED) assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED)
assert(is_equal_approx(hole_material.albedo_color.a, 1.0)) assert(is_equal_approx(hole_material.albedo_color.a, 1.0))
var particles := hotspot.get_node(
"WaterSpurt/WaterDroplets"
) as CPUParticles3D
assert(particles != null and particles.emitting)
assert(particles.local_coords)
assert(particles.global_position.is_equal_approx(
configured_position + Vector3.UP * 0.035
))
hotspot.set_process(false)
await create_timer(0.6).timeout
await process_frame
assert(hotspot.get_node_or_null("WaterSpurt/WaterDroplets") == null)
hotspot.queue_free() hotspot.queue_free()

331
ui/decor_shop.gd Normal file
View file

@ -0,0 +1,331 @@
class_name DecorShop
extends Control
const INPUT_OWNER: StringName = &"decor_shop"
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
signal menu_visibility_changed(is_open: bool)
signal menu_exit_started
var _player: Player
var _wallet: PlayerWallet
var _fishing_spot: FishingSpot
var _interaction: DecorShopInteraction
var _bag: PlayerBag
var _home_state: PlayerHomeState
var _network_shop: NetworkShopService
var _wallet_label: Label
var _owned_label: Label
var _feedback: Label
var _buy_button: Button
var _close_button: Button
var _transaction_pending: bool = false
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:
UtilityPageStyleType.apply_page(self)
_build_ui()
hide()
func setup(
player: Player,
wallet: PlayerWallet,
fishing_spot: FishingSpot,
interaction: DecorShopInteraction,
bag: PlayerBag,
home_state: PlayerHomeState,
network_shop: NetworkShopService,
) -> void:
_player = player
_wallet = wallet
_fishing_spot = fishing_spot
_interaction = interaction
_bag = bag
_home_state = home_state
_network_shop = network_shop
if not _wallet.balance_changed.is_connected(_on_wallet_changed):
_wallet.balance_changed.connect(_on_wallet_changed)
if not _bag.contents_changed.is_connected(_refresh):
_bag.contents_changed.connect(_refresh)
if not _home_state.changed.is_connected(_refresh):
_home_state.changed.connect(_refresh)
if (
_network_shop != null
and not _network_shop.local_purchase_pending.is_connected(
_on_purchase_pending
)
):
_network_shop.local_purchase_pending.connect(_on_purchase_pending)
_network_shop.local_purchase_finished.connect(_on_purchase_finished)
_refresh()
func set_world_interaction(interaction: DecorShopInteraction) -> void:
_interaction = interaction
func open_shop() -> bool:
if (
visible
or _player == null
or _wallet == null
or _fishing_spot == null
or _interaction == null
or not _interaction.is_local_player_in_range()
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()
_player.set_movement_enabled(false)
# Release any active right-click drag first. The shop must not remember the
# drag's transient captured mode because release may happen inside the UI.
_player.set_camera_input_enabled(false)
_prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_transaction_pending = (
_network_shop != null
and _network_shop.is_local_purchase_pending()
)
_feedback.text = ""
_refresh()
show()
_buy_button.call_deferred("grab_focus")
menu_visibility_changed.emit(true)
return true
func close_shop(restore_controls: bool = true) -> void:
if not visible:
return
menu_exit_started.emit()
get_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 consume_escape() -> bool:
if not visible:
return false
close_shop()
return true
func close_for_range_exit() -> void:
close_shop()
func close_for_water_recovery() -> void:
close_shop(false)
func close_for_session_end() -> void:
close_shop(false)
func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
if mouse_mode == Input.MOUSE_MODE_CAPTURED:
return Input.MOUSE_MODE_VISIBLE
return mouse_mode
func _unhandled_input(event: InputEvent) -> void:
if not visible or not event.is_action_pressed("ui_cancel"):
return
close_shop()
get_viewport().set_input_as_handled()
func _purchase_cube() -> void:
if _network_shop == null or _transaction_pending:
return
var request_id: String = _network_shop.request_decor(
DecorCatalogType.BASIC_CUBE_ID
)
if request_id.is_empty():
_refresh()
func _on_purchase_pending(_request_id: String) -> void:
_transaction_pending = true
_feedback.text = "purchasing…"
_refresh()
func _on_purchase_finished(
_request_id: String,
accepted: bool,
message: String,
product_id: StringName,
category: int,
_quantity: int,
total_cost: int,
) -> void:
if (
category != NetworkShopProtocol.ProductCategory.DECOR
or product_id != DecorCatalogType.BASIC_CUBE_ID
):
return
_transaction_pending = false
_feedback.text = (
"basic cube purchased · %d fishcoin spent" % total_cost
if accepted
else message.to_lower()
)
_refresh()
func _on_wallet_changed(_balance: int, _delta: int) -> void:
_refresh()
func _refresh() -> void:
if _wallet_label == null:
return
var balance: int = _wallet.get_balance() if _wallet != null else 0
var owned: int = (
_home_state.get_owned_count(DecorCatalogType.BASIC_CUBE_ID)
if _home_state != null else 0
)
var carried: int = (
_bag.get_quantity(DecorCatalogType.BASIC_CUBE_ID)
if _bag != null else 0
)
_wallet_label.text = "%d fishcoins" % balance
_owned_label.text = "owned %d · packed %d" % [owned, carried]
_buy_button.disabled = (
_transaction_pending
or _wallet == null
or not _wallet.can_afford(
DecorCatalogType.get_price(DecorCatalogType.BASIC_CUBE_ID)
)
or _bag == null
or not _bag.can_add_item(DecorCatalogType.BASIC_CUBE_ID, 1)
or owned >= DecorCatalogType.MAX_OWNED_PER_PRODUCT
)
func _build_ui() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
z_index = 83
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(-440.0, -250.0)
panel.size = Vector2(880.0, 500.0)
panel.add_theme_stylebox_override(
"panel",
UtilityPageStyleType.rounded_style(
UtilityPageStyleType.OCEAN_PANEL_DEEP, 24
),
)
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, 28)
panel.add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 18)
margin.add_child(layout)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 12)
layout.add_child(header)
var title := Label.new()
title.text = "decor"
title.add_theme_font_size_override("font_size", 38)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
_wallet_label = Label.new()
_wallet_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_wallet_label.add_theme_font_size_override("font_size", 22)
header.add_child(_wallet_label)
var item_panel := PanelContainer.new()
item_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
item_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyleType.rounded_style(
UtilityPageStyleType.OCEAN_FIELD, 18
),
)
layout.add_child(item_panel)
var item_margin := MarginContainer.new()
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom",
]:
item_margin.add_theme_constant_override(side, 24)
item_panel.add_child(item_margin)
var item_row := HBoxContainer.new()
item_row.add_theme_constant_override("separation", 28)
item_margin.add_child(item_row)
var preview := Panel.new()
preview.custom_minimum_size = Vector2(150.0, 150.0)
preview.add_theme_stylebox_override(
"panel",
UtilityPageStyleType.rounded_style(Color("b77a55"), 12),
)
item_row.add_child(preview)
var details := VBoxContainer.new()
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_theme_constant_override("separation", 10)
item_row.add_child(details)
var item_name := Label.new()
item_name.text = DecorCatalogType.get_display_name(
DecorCatalogType.BASIC_CUBE_ID
)
item_name.add_theme_font_size_override("font_size", 32)
details.add_child(item_name)
var description := Label.new()
description.text = DecorCatalogType.get_description(
DecorCatalogType.BASIC_CUBE_ID
)
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.add_theme_font_size_override("font_size", 20)
description.size_flags_vertical = Control.SIZE_EXPAND_FILL
details.add_child(description)
_owned_label = Label.new()
_owned_label.add_theme_font_size_override("font_size", 18)
details.add_child(_owned_label)
_buy_button = Button.new()
_buy_button.text = "buy · 1 fishcoin"
_buy_button.custom_minimum_size = Vector2(260.0, 58.0)
UtilityPageStyleType.apply_ocean_button(_buy_button)
_buy_button.pressed.connect(_purchase_cube)
details.add_child(_buy_button)
var footer := HBoxContainer.new()
footer.add_theme_constant_override("separation", 12)
layout.add_child(footer)
_feedback = Label.new()
_feedback.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_feedback.add_theme_font_size_override("font_size", 18)
footer.add_child(_feedback)
_close_button = Button.new()
_close_button.text = "close"
_close_button.custom_minimum_size = Vector2(150.0, 50.0)
UtilityPageStyleType.apply_ocean_button(_close_button)
_close_button.pressed.connect(close_shop)
footer.add_child(_close_button)
_buy_button.focus_neighbor_bottom = _buy_button.get_path_to(_close_button)
_close_button.focus_neighbor_top = _close_button.get_path_to(_buy_button)

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

@ -0,0 +1 @@
uid://810ck3mvbn5v

14
ui/decor_shop.tscn Normal file
View file

@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/decor_shop.gd" id="1_script"]
[node name="DecorShop" 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

@ -654,11 +654,14 @@ func open_shop() -> bool:
) )
_prior_movement_enabled = _player.is_movement_enabled() _prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled() _prior_camera_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode _player.set_movement_enabled(false)
# Releasing an active camera drag before the snapshot prevents the close
# path from restoring a captured pointer after its matching button release
# has been consumed by the shop.
_player.set_camera_input_enabled(false)
_prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode)
_snapshot_stored = true _snapshot_stored = true
_mouse_snapshot_stored = true _mouse_snapshot_stored = true
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_set_feedback("") _set_feedback("")
@ -1897,3 +1900,9 @@ func _apply_mouse_close_policy(reason: CloseReason) -> void:
CloseReason.TEARDOWN: CloseReason.TEARDOWN:
pass pass
_mouse_snapshot_stored = false _mouse_snapshot_stored = false
func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
if mouse_mode == Input.MOUSE_MODE_CAPTURED:
return Input.MOUSE_MODE_VISIBLE
return mouse_mode

View file

@ -20,6 +20,7 @@ const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.gd") const PauseMenuType = preload("res://ui/pause_menu.gd")
const NetworkSessionType = preload("res://network/network_session.gd") const NetworkSessionType = preload("res://network/network_session.gd")
const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopType = preload("res://ui/fishing_shop.gd")
const DecorShopType = preload("res://ui/decor_shop.gd")
const SettingsPanelType = preload("res://ui/settings_panel.gd") const SettingsPanelType = preload("res://ui/settings_panel.gd")
const PlayerFishingUpgradesType = preload( const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd" "res://progression/player_fishing_upgrades.gd"
@ -27,7 +28,11 @@ const PlayerFishingUpgradesType = preload(
const ShopInteractionType = preload( const ShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd" "res://world/fishing_shop_interaction.gd"
) )
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PlayerStorageType = preload("res://ui/player_storage.gd") const PlayerStorageType = preload("res://ui/player_storage.gd")
const HomeDecorToolbarType = preload("res://ui/home_decor_toolbar.gd")
const PlayerStorageInteractionType = preload( const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd" "res://world/player_storage_interaction.gd"
) )
@ -71,6 +76,9 @@ const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload(
const ACTIVE_BAIT_EMPTY_ICON: Texture2D = preload( const ACTIVE_BAIT_EMPTY_ICON: Texture2D = preload(
"res://ui/icons/pictograms/x_dark.png" "res://ui/icons/pictograms/x_dark.png"
) )
const RV_STORAGE_ICON: Texture2D = preload(
"res://ui/icons/tab_menu/inventory_simple.png"
)
signal pixelation_settings_visibility_changed(is_visible: bool) signal pixelation_settings_visibility_changed(is_visible: bool)
signal crisp_reset_focus_requested signal crisp_reset_focus_requested
@ -82,6 +90,7 @@ signal virtual_pointer_mode_changed(is_active: bool)
signal social_prompt_accepted signal social_prompt_accepted
signal social_prompt_declined signal social_prompt_declined
signal data_root_changed signal data_root_changed
signal rv_storage_requested
const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu"
@ -145,7 +154,10 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
@onready var _social_prompt: BubbleConfirmationPage = %SocialPrompt @onready var _social_prompt: BubbleConfirmationPage = %SocialPrompt
@onready var _hotbar_ui: HotbarUIType = %Hotbar @onready var _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop @onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _decor_shop: DecorShopType = %DecorShop
@onready var _player_storage: PlayerStorageType = %PlayerStorage @onready var _player_storage: PlayerStorageType = %PlayerStorage
@onready var _home_decor_toolbar: HomeDecorToolbarType = %HomeDecorToolbar
@onready var _rv_storage_button: Button = %RVStorageButton
@onready var _storage_prompt: PanelContainer = %StoragePrompt @onready var _storage_prompt: PanelContainer = %StoragePrompt
@onready var _storage_prompt_message: Label = %StoragePromptMessage @onready var _storage_prompt_message: Label = %StoragePromptMessage
@onready var _shop_prompt: Control = %ShopPrompt @onready var _shop_prompt: Control = %ShopPrompt
@ -195,6 +207,7 @@ var _chat_input_open: bool = false
var _player_menu_hotbar_visible: bool = false var _player_menu_hotbar_visible: bool = false
var _main_shop_buyer: FishBuyerProfileType var _main_shop_buyer: FishBuyerProfileType
var _shop_interaction: ShopInteractionType var _shop_interaction: ShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _storage_interaction: PlayerStorageInteractionType var _storage_interaction: PlayerStorageInteractionType
var _surface_drawing: NetworkSurfaceDrawingService var _surface_drawing: NetworkSurfaceDrawingService
var _surface_drawing_hotbar_selected: bool = false var _surface_drawing_hotbar_selected: bool = false
@ -221,14 +234,17 @@ var _controller_text_entry_request: Callable
var _controller_text_entry_is_open: Callable var _controller_text_entry_is_open: Callable
var _controller_text_entry_close: Callable var _controller_text_entry_close: Callable
var _restore_chat_keyboard_after_fishing: bool = false var _restore_chat_keyboard_after_fishing: bool = false
var _rv_storage_button_requested_visible: bool = false
func _ready() -> void: func _ready() -> void:
_prioritize_surface_drawing_pointer_input() _prioritize_surface_drawing_pointer_input()
_bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
_rv_storage_button.pressed.connect(_on_rv_storage_button_pressed)
_social_prompt.confirmed.connect(_accept_social_prompt) _social_prompt.confirmed.connect(_accept_social_prompt)
_social_prompt.cancelled.connect(_decline_social_prompt) _social_prompt.cancelled.connect(_decline_social_prompt)
_apply_active_bait_indicator_style() _apply_active_bait_indicator_style()
_apply_rv_storage_button_style()
_refresh_active_bait_indicator() _refresh_active_bait_indicator()
_refresh_interaction_prompt_bindings() _refresh_interaction_prompt_bindings()
# Reward feedback must remain above full-screen canonical menus. Keeping the # Reward feedback must remain above full-screen canonical menus. Keeping the
@ -260,6 +276,9 @@ func _ready() -> void:
_player_menu.controller_hotbar_management_ended.connect( _player_menu.controller_hotbar_management_ended.connect(
_on_controller_hotbar_management_ended _on_controller_hotbar_management_ended
) )
_player_menu.controller_text_entry_requested.connect(
request_controller_text_entry_for
)
_title_settings_panel.panel_visibility_changed.connect( _title_settings_panel.panel_visibility_changed.connect(
_on_settings_visibility_changed _on_settings_visibility_changed
) )
@ -343,11 +362,13 @@ func setup(
fishing_upgrades: PlayerFishingUpgradesType, fishing_upgrades: PlayerFishingUpgradesType,
shop_interaction: ShopInteractionType, shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType, storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
item_effects: PlayerItemEffectsType, item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType, network_session: NetworkSessionType,
network_sale_service: NetworkSaleService, network_sale_service: NetworkSaleService,
network_shop_service: NetworkShopService, network_shop_service: NetworkShopService,
home_state: PlayerHomeState,
network_chat_service: NetworkChatService, network_chat_service: NetworkChatService,
network_profile: NetworkProfilePreferences, network_profile: NetworkProfilePreferences,
spawn_service: PlayerSpawnService, spawn_service: PlayerSpawnService,
@ -459,6 +480,7 @@ func setup(
discovery, discovery,
player_jobs, player_jobs,
world_time, world_time,
world_weather,
world_environment, world_environment,
world_sun, world_sun,
) )
@ -490,6 +512,15 @@ func setup(
network_sale_service, network_sale_service,
reservations, reservations,
) )
_decor_shop.setup(
player,
wallet,
fishing_spot,
decor_shop_interaction,
bag,
home_state,
network_shop_service,
)
_player_storage.setup( _player_storage.setup(
player, player,
fishing_spot, fishing_spot,
@ -505,6 +536,8 @@ func setup(
) )
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) _fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_fishing_shop.menu_exit_started.connect(_on_shop_exit_started) _fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
_decor_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_decor_shop.menu_exit_started.connect(_on_shop_exit_started)
_fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested) _fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested)
_fishing_shop.shop_cooler_return_requested.connect( _fishing_shop.shop_cooler_return_requested.connect(
_on_shop_cooler_return_requested _on_shop_cooler_return_requested
@ -514,6 +547,7 @@ func setup(
) )
_main_shop_buyer = main_shop_buyer _main_shop_buyer = main_shop_buyer
_shop_interaction = shop_interaction _shop_interaction = shop_interaction
_decor_shop_interaction = decor_shop_interaction
_storage_interaction = storage_interaction _storage_interaction = storage_interaction
_surface_drawing = surface_drawing _surface_drawing = surface_drawing
if ( if (
@ -536,11 +570,14 @@ func setup(
func set_world_interactions( func set_world_interactions(
shop_interaction: ShopInteractionType, shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType, storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
) -> void: ) -> void:
_shop_interaction = shop_interaction _shop_interaction = shop_interaction
_storage_interaction = storage_interaction _storage_interaction = storage_interaction
_decor_shop_interaction = decor_shop_interaction
_fishing_shop.set_world_interaction(shop_interaction) _fishing_shop.set_world_interaction(shop_interaction)
_player_storage.set_world_interaction(storage_interaction) _player_storage.set_world_interaction(storage_interaction)
_decor_shop.set_world_interaction(decor_shop_interaction)
func _prioritize_surface_drawing_pointer_input() -> void: func _prioritize_surface_drawing_pointer_input() -> void:
@ -1651,6 +1688,34 @@ func _apply_active_bait_indicator_style() -> void:
_active_bait_button.add_theme_color_override(state, Color.WHITE) _active_bait_button.add_theme_color_override(state, Color.WHITE)
func _apply_rv_storage_button_style() -> void:
var normal_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_normal_style()
var hover_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_hover_style()
var pressed_style: StyleBoxFlat = MAIN_BUBBLE_PROFILE.make_pressed_style()
for style: StyleBoxFlat in [normal_style, hover_style, pressed_style]:
style.set_corner_radius_all(29)
style.content_margin_left = 9.0
style.content_margin_top = 9.0
style.content_margin_right = 9.0
style.content_margin_bottom = 9.0
_rv_storage_button.icon = RV_STORAGE_ICON
_rv_storage_button.add_theme_stylebox_override("normal", normal_style)
_rv_storage_button.add_theme_stylebox_override("hover", hover_style)
_rv_storage_button.add_theme_stylebox_override("focus", hover_style)
_rv_storage_button.add_theme_stylebox_override("pressed", pressed_style)
for state: StringName in [
&"icon_normal_color",
&"icon_hover_color",
&"icon_focus_color",
&"icon_pressed_color",
]:
_rv_storage_button.add_theme_color_override(state, Color.WHITE)
func _on_rv_storage_button_pressed() -> void:
rv_storage_requested.emit()
func _refresh_active_bait_indicator() -> void: func _refresh_active_bait_indicator() -> void:
_refresh_active_bait_indicator_visibility() _refresh_active_bait_indicator_visibility()
if not is_node_ready(): if not is_node_ready():
@ -1714,17 +1779,20 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
if not enabled: if not enabled:
_gameplay_hud_hidden = false _gameplay_hud_hidden = false
_refresh_active_bait_indicator_visibility() _refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
if enabled: if enabled:
_try_start_shop_npc_speech() _try_start_shop_npc_speech()
_refresh_gameplay_hud_visibility() _refresh_gameplay_hud_visibility()
_refresh_chat_availability() _refresh_chat_availability()
if not enabled: if not enabled:
_home_decor_toolbar.set_active(false)
_end_virtual_mouse() _end_virtual_mouse()
_close_radial_menus() _close_radial_menus()
if _surface_drawing != null: if _surface_drawing != null:
_surface_drawing.deactivate() _surface_drawing.deactivate()
close_player_menu_for_session_end() close_player_menu_for_session_end()
_fishing_shop.close_for_session_end() _fishing_shop.close_for_session_end()
_decor_shop.close_for_session_end()
_player_storage.close_for_session_end() _player_storage.close_for_session_end()
_fishing_panel.visible = false _fishing_panel.visible = false
_shop_prompt.hide() _shop_prompt.hide()
@ -1745,6 +1813,7 @@ func set_gameplay_hud_hidden(hidden: bool) -> void:
_quick_radial_menu.set_hud_hidden(hidden) _quick_radial_menu.set_hud_hidden(hidden)
_refresh_gameplay_hud_visibility() _refresh_gameplay_hud_visibility()
_refresh_active_bait_indicator_visibility() _refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
_refresh_hotbar_visibility() _refresh_hotbar_visibility()
_refresh_chat_availability() _refresh_chat_availability()
@ -1860,13 +1929,43 @@ func get_fishing_shop() -> FishingShopType:
return _fishing_shop return _fishing_shop
func get_decor_shop() -> DecorShopType:
return _decor_shop
func get_player_storage() -> PlayerStorageType: func get_player_storage() -> PlayerStorageType:
return _player_storage return _player_storage
func get_home_decor_toolbar() -> HomeDecorToolbarType:
return _home_decor_toolbar
func set_rv_interior_camera_mode(enabled: bool) -> void:
_hotbar_ui.set_rv_interior_camera_mode(enabled)
func set_rv_storage_button_visible(requested_visible: bool) -> void:
_rv_storage_button_requested_visible = requested_visible
_refresh_rv_storage_button_visibility()
func _refresh_rv_storage_button_visibility() -> void:
_rv_storage_button.visible = (
_rv_storage_button_requested_visible
and _gameplay_ui_enabled
and not _gameplay_hud_hidden
and not _system_menu_open
and not _player_menu_open
and not _shop_open
and not _storage_open
)
func set_storage_prompt_visible( func set_storage_prompt_visible(
requested_visible: bool, requested_visible: bool,
world_anchor: Vector3 = Vector3(0.0, INF, 0.0), world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
action_text: String = "open storage",
) -> void: ) -> void:
if not _storage_prompt.has_meta(&"styled"): if not _storage_prompt.has_meta(&"styled"):
_storage_prompt.set_meta(&"styled", true) _storage_prompt.set_meta(&"styled", true)
@ -1875,6 +1974,7 @@ func set_storage_prompt_visible(
) )
style.anti_aliasing = false style.anti_aliasing = false
_storage_prompt.add_theme_stylebox_override("panel", style) _storage_prompt.add_theme_stylebox_override("panel", style)
_storage_prompt_message.text = action_text
_storage_prompt.visible = ( _storage_prompt.visible = (
requested_visible requested_visible
and _gameplay_ui_enabled and _gameplay_ui_enabled
@ -1892,7 +1992,7 @@ func _position_storage_prompt(world_anchor: Vector3) -> void:
if _player == null: if _player == null:
_storage_prompt.hide() _storage_prompt.hide()
return return
var camera := _player.get_gameplay_camera() var camera := _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor): if camera == null or camera.is_position_behind(world_anchor):
_storage_prompt.hide() _storage_prompt.hide()
return return
@ -2006,7 +2106,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
if _player == null: if _player == null:
_shop_prompt.hide() _shop_prompt.hide()
return return
var camera: Camera3D = _player.get_gameplay_camera() var camera: Camera3D = _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor): if camera == null or camera.is_position_behind(world_anchor):
_shop_prompt.hide() _shop_prompt.hide()
return return
@ -2758,6 +2858,7 @@ func _refresh_hotbar_visibility() -> void:
func _emit_interactive_pointer_ui_changed() -> void: func _emit_interactive_pointer_ui_changed() -> void:
_refresh_active_bait_indicator_visibility() _refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
interactive_pointer_ui_changed.emit( interactive_pointer_ui_changed.emit(
_system_menu_open _system_menu_open
or _player_menu_open or _player_menu_open

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=22 format=3] [gd_scene load_steps=24 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
@ -15,6 +15,8 @@
[ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"] [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"] [ext_resource type="PackedScene" path="res://ui/player_storage.tscn" id="14_storage"]
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="15_social_prompt"] [ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="15_social_prompt"]
[ext_resource type="PackedScene" path="res://ui/decor_shop.tscn" id="16_decor_shop"]
[ext_resource type="PackedScene" path="res://ui/home_decor_toolbar.tscn" id="17_decor_toolbar"]
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.032, 0.118, 0.15, 1) bg_color = Color(0.032, 0.118, 0.15, 1)
@ -472,9 +474,37 @@ polygon = PackedVector2Array(-8, 0, 8, 0, 0, 10)
[node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")] [node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")]
unique_name_in_owner = true unique_name_in_owner = true
[node name="DecorShop" parent="UIRoot/CanonicalStage" instance=ExtResource("16_decor_shop")]
unique_name_in_owner = true
[node name="HomeDecorToolbar" parent="UIRoot/CanonicalStage" instance=ExtResource("17_decor_toolbar")]
unique_name_in_owner = true
[node name="PlayerStorage" parent="UIRoot/CanonicalStage" instance=ExtResource("14_storage")] [node name="PlayerStorage" parent="UIRoot/CanonicalStage" instance=ExtResource("14_storage")]
unique_name_in_owner = true unique_name_in_owner = true
[node name="RVStorageButton" type="Button" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
z_index = 57
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -74.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 74.0
grow_horizontal = 0
mouse_default_cursor_shape = 2
focus_mode = 0
tooltip_text = "open RV storage"
theme = ExtResource("3_theme")
texture_filter = 1
expand_icon = true
icon_alignment = 1
vertical_icon_alignment = 1
[node name="StoragePrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] [node name="StoragePrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false

126
ui/home_decor_toolbar.gd Normal file
View file

@ -0,0 +1,126 @@
class_name HomeDecorToolbar
extends PanelContainer
signal placement_mode_requested(mode: int)
signal rotate_requested
signal store_requested
signal cancel_requested
var _mode_button: Button
var _selection_label: Label
var _rotate_button: Button
var _store_button: Button
var _mode: PlayerHomeState.PlacementMode = PlayerHomeState.PlacementMode.GRID
func _ready() -> void:
_build_ui()
hide()
func set_active(active: bool) -> void:
if visible == active:
return
visible = active
if active and _mode_button != null:
_mode_button.call_deferred("grab_focus")
func set_mode(mode: PlayerHomeState.PlacementMode) -> void:
_mode = mode
if _mode_button != null:
_mode_button.text = (
"placement: grid"
if mode == PlayerHomeState.PlacementMode.GRID
else "placement: free"
)
func set_selection(instance_id: String, display_name: String = "") -> void:
var selected: bool = not instance_id.is_empty()
if _selection_label != null:
_selection_label.text = (
"selected: %s" % display_name
if selected else "click decor to select; drag to move"
)
if _rotate_button != null:
_rotate_button.disabled = not selected
if _store_button != null:
_store_button.disabled = not selected
func report_status(message: String) -> void:
if _selection_label != null:
_selection_label.text = message
func _build_ui() -> void:
custom_minimum_size = Vector2(330.0, 0.0)
position = Vector2(925.0, 540.0)
z_index = 58
mouse_filter = Control.MOUSE_FILTER_STOP
add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_PANEL_DEEP, 0.96), 16
),
)
var margin := MarginContainer.new()
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom",
]:
margin.add_theme_constant_override(side, 12)
add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 8)
margin.add_child(layout)
var title := Label.new()
title.text = "RV decor"
title.add_theme_font_size_override("font_size", 24)
layout.add_child(title)
_selection_label = Label.new()
_selection_label.text = "click decor to select; drag to move"
_selection_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_selection_label.add_theme_font_size_override("font_size", 17)
layout.add_child(_selection_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
layout.add_child(row)
_mode_button = Button.new()
_mode_button.text = "placement: grid"
_mode_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(_mode_button)
_mode_button.pressed.connect(_toggle_mode)
row.add_child(_mode_button)
_rotate_button = Button.new()
_rotate_button.text = "rotate"
_rotate_button.disabled = true
UtilityPageStyle.apply_ocean_button(_rotate_button)
_rotate_button.pressed.connect(rotate_requested.emit)
row.add_child(_rotate_button)
var action_row := HBoxContainer.new()
action_row.add_theme_constant_override("separation", 8)
layout.add_child(action_row)
_store_button = Button.new()
_store_button.text = "pack up"
_store_button.disabled = true
_store_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(_store_button)
_store_button.pressed.connect(store_requested.emit)
action_row.add_child(_store_button)
var cancel_button := Button.new()
cancel_button.text = "clear selection"
cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(cancel_button)
cancel_button.pressed.connect(cancel_requested.emit)
action_row.add_child(cancel_button)
func _toggle_mode() -> void:
_mode = (
PlayerHomeState.PlacementMode.FREE
if _mode == PlayerHomeState.PlacementMode.GRID
else PlayerHomeState.PlacementMode.GRID
)
set_mode(_mode)
placement_mode_requested.emit(int(_mode))

View file

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

View file

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/home_decor_toolbar.gd" id="1_script"]
[node name="HomeDecorToolbar" type="PanelContainer"]
unique_name_in_owner = true
visible = false
script = ExtResource("1_script")

View file

@ -37,6 +37,7 @@ var _slots: Array[BubbleHotbarSlotType] = []
var _gameplay_input_enabled: bool = false var _gameplay_input_enabled: bool = false
var _drag_enabled: bool = false var _drag_enabled: bool = false
var _swap_hotbar_camera_scroll: bool = false var _swap_hotbar_camera_scroll: bool = false
var _rv_interior_camera_mode: bool = false
var _motion_elapsed: float = 0.0 var _motion_elapsed: float = 0.0
var _compact_layout: bool = false var _compact_layout: bool = false
var _player_menu_context: bool = false var _player_menu_context: bool = false
@ -99,6 +100,10 @@ func set_swap_hotbar_camera_scroll(enabled: bool) -> void:
_swap_hotbar_camera_scroll = enabled _swap_hotbar_camera_scroll = enabled
func set_rv_interior_camera_mode(enabled: bool) -> void:
_rv_interior_camera_mode = enabled
func set_drag_enabled(enabled: bool) -> void: func set_drag_enabled(enabled: bool) -> void:
_drag_enabled = enabled _drag_enabled = enabled
for slot: BubbleHotbarSlotType in _slots: for slot: BubbleHotbarSlotType in _slots:
@ -308,7 +313,11 @@ func _unhandled_input(event: InputEvent) -> void:
if ( if (
event is InputEventMouseButton event is InputEventMouseButton
and event.pressed and event.pressed
and event.shift_pressed == _swap_hotbar_camera_scroll and (
event.shift_pressed
if _rv_interior_camera_mode
else event.shift_pressed == _swap_hotbar_camera_scroll
)
): ):
if event.button_index == MOUSE_BUTTON_WHEEL_UP: if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_hotbar.cycle_selection(-1) _hotbar.cycle_selection(-1)

View file

@ -2,6 +2,9 @@ class_name LogbookCatalog
extends RefCounted extends RefCounted
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const CollectionLogType = preload("res://collection/collection_log.gd")
const CalendarSeasonType = preload("res://world/calendar_season.gd")
enum Category { enum Category {
FRESH_WATER, FRESH_WATER,
@ -10,6 +13,66 @@ enum Category {
SHELLFISH, SHELLFISH,
} }
enum DiscoveryFilter {
ALL,
CAUGHT,
MISSING,
INCOMPLETE,
}
enum SortMode {
CATALOG_NUMBER,
NAME,
RARITY,
CATCH_COUNT,
QUALITY_PROGRESS,
VALUE,
WEIGHT,
}
enum AvailabilityFilter {
ANY,
AVAILABLE_NOW,
}
enum SeasonFilter {
ANY,
CURRENT,
SPRING,
SUMMER,
FALL,
WINTER,
}
enum TimeFilter {
ANY,
CURRENT,
DAY,
NIGHT,
}
enum RarityFilter {
ANY,
COMMON,
UNCOMMON,
RARE,
EPIC,
LEGENDARY,
}
enum MethodFilter {
ANY,
FISHING,
NET,
DIGGING,
}
enum HoldingFilter {
ANY,
IN_COOLER,
FAVORITED,
}
static func category_for(fish: FishDataType) -> Category: static func category_for(fish: FishDataType) -> Category:
if fish == null: if fish == null:
@ -73,3 +136,278 @@ static func ordered_species(
return a.catalog_number < b.catalog_number return a.catalog_number < b.catalog_number
) )
return ordered return ordered
static func filtered_species(
candidates: Array[FishDataType],
category: Category,
collection_log: CollectionLogType,
inventory: FishInventoryType,
options: Dictionary,
available_now_resolver: Callable = Callable(),
current_season: int = CalendarSeasonType.UNKNOWN,
current_is_night: bool = false,
) -> Array[FishDataType]:
var filtered: Array[FishDataType] = []
for fish: FishDataType in ordered_species(candidates):
if category_for(fish) != category:
continue
if not _matches_filters(
fish,
collection_log,
inventory,
options,
available_now_resolver,
current_season,
current_is_night,
):
continue
filtered.append(fish)
var sort_mode := int(options.get("sort", SortMode.CATALOG_NUMBER))
var descending := bool(options.get("descending", false))
filtered.sort_custom(
func(first: FishDataType, second: FishDataType) -> bool:
return _species_less_than(
first,
second,
collection_log,
sort_mode,
descending,
)
)
return filtered
static func available_groups(
candidates: Array[FishDataType],
category: Category,
_collection_log: CollectionLogType,
) -> Array[String]:
var groups: Array[String] = []
for fish: FishDataType in ordered_species(candidates):
if category_for(fish) != category:
continue
var group := _group_label(fish)
if not group.is_empty() and group not in groups:
groups.append(group)
groups.sort_custom(
func(first: String, second: String) -> bool:
return first.naturalnocasecmp_to(second) < 0
)
return groups
static func quality_progress(
collection_log: CollectionLogType,
fish_id: StringName,
) -> int:
if collection_log == null:
return 0
var mask: int = collection_log.get_quality_mask(fish_id)
var count: int = 0
while mask > 0:
count += mask & 1
mask >>= 1
return count
static func _matches_filters(
fish: FishDataType,
collection_log: CollectionLogType,
inventory: FishInventoryType,
options: Dictionary,
available_now_resolver: Callable,
current_season: int,
current_is_night: bool,
) -> bool:
var discovered := (
collection_log != null and collection_log.has_discovered(fish.id)
)
match int(options.get("show", DiscoveryFilter.ALL)):
DiscoveryFilter.CAUGHT:
if not discovered:
return false
DiscoveryFilter.MISSING:
if discovered:
return false
DiscoveryFilter.INCOMPLETE:
if (
not discovered
or collection_log.has_mastered(fish.id)
):
return false
var search_text := str(options.get("search", "")).strip_edges().to_lower()
if not search_text.is_empty():
var number_text := str(catalog_number(fish))
var number_query := search_text.trim_prefix("#")
var number_matches := (
number_query.is_valid_int()
and number_text == number_query
)
var identity_matches := false
if discovered:
identity_matches = " ".join([
fish.display_name,
_group_label(fish),
fish.get_habitat_label(),
]).to_lower().contains(search_text)
if not number_matches and not identity_matches:
return false
if (
int(options.get("availability", AvailabilityFilter.ANY))
== AvailabilityFilter.AVAILABLE_NOW
):
if available_now_resolver.is_valid():
var resolved: Variant = available_now_resolver.call(fish)
if typeof(resolved) != TYPE_BOOL or not bool(resolved):
return false
elif not _matches_season(fish, current_season):
return false
var season_filter := int(options.get("season", SeasonFilter.ANY))
if season_filter != SeasonFilter.ANY:
var requested_season := current_season
if season_filter >= SeasonFilter.SPRING:
requested_season = season_filter - SeasonFilter.SPRING
if not _matches_season(fish, requested_season):
return false
var time_filter := int(options.get("time", TimeFilter.ANY))
if time_filter != TimeFilter.ANY:
var requested_night := current_is_night
if time_filter == TimeFilter.DAY:
requested_night = false
elif time_filter == TimeFilter.NIGHT:
requested_night = true
if not _matches_time(fish, requested_night):
return false
var rarity_filter := int(options.get("rarity", RarityFilter.ANY))
if (
rarity_filter != RarityFilter.ANY
and fish.rarity != rarity_filter - RarityFilter.COMMON
):
return false
var method_filter := int(options.get("method", MethodFilter.ANY))
if (
method_filter != MethodFilter.ANY
and fish.collection_method != method_filter - MethodFilter.FISHING
):
return false
var holding_filter := int(options.get("holding", HoldingFilter.ANY))
if holding_filter != HoldingFilter.ANY:
var favorites_only := holding_filter == HoldingFilter.FAVORITED
if not _has_current_catch(inventory, fish.id, favorites_only):
return false
var requested_group := str(options.get("group", ""))
if (
not requested_group.is_empty()
and _group_label(fish).nocasecmp_to(requested_group) != 0
):
return false
return true
static func _species_less_than(
first: FishDataType,
second: FishDataType,
collection_log: CollectionLogType,
sort_mode: int,
descending: bool,
) -> bool:
var first_discovered := (
collection_log != null and collection_log.has_discovered(first.id)
)
var second_discovered := (
collection_log != null and collection_log.has_discovered(second.id)
)
if sort_mode != SortMode.CATALOG_NUMBER:
if first_discovered != second_discovered:
return first_discovered
if not first_discovered and not second_discovered:
return _catalog_less_than(first, second)
var comparison: int = 0
match sort_mode:
SortMode.NAME:
comparison = first.display_name.naturalnocasecmp_to(
second.display_name
)
SortMode.RARITY:
comparison = first.rarity - second.rarity
SortMode.CATCH_COUNT:
comparison = (
collection_log.get_catch_count(first.id)
- collection_log.get_catch_count(second.id)
)
SortMode.QUALITY_PROGRESS:
comparison = (
quality_progress(collection_log, first.id)
- quality_progress(collection_log, second.id)
)
SortMode.VALUE:
comparison = first.sell_value_max - second.sell_value_max
SortMode.WEIGHT:
comparison = _compare_floats(
first.get_maximum_weight(),
second.get_maximum_weight(),
)
_:
comparison = first.catalog_number - second.catalog_number
if comparison != 0:
return comparison > 0 if descending else comparison < 0
return _catalog_less_than(first, second)
static func _catalog_less_than(first: FishDataType, second: FishDataType) -> bool:
if first.catalog_number == second.catalog_number:
return String(first.id) < String(second.id)
return first.catalog_number < second.catalog_number
static func _compare_floats(first: float, second: float) -> int:
if is_equal_approx(first, second):
return 0
return -1 if first < second else 1
static func _matches_season(fish: FishDataType, season: int) -> bool:
return fish.is_available_in_season(season)
static func _matches_time(fish: FishDataType, requested_night: bool) -> bool:
if (
fish.collection_method != FishDataType.CollectionMethod.FISHING
or fish.availability == null
):
return true
return (
fish.availability.allow_night
if requested_night
else fish.availability.allow_day
)
static func _has_current_catch(
inventory: FishInventoryType,
fish_id: StringName,
favorites_only: bool,
) -> bool:
if inventory == null:
return false
for fish_catch in inventory.get_catches_by_fish_id(fish_id):
if fish_catch != null and (not favorites_only or fish_catch.is_favorited):
return true
return false
static func _group_label(fish: FishDataType) -> String:
if fish == null:
return ""
var group := String(fish.collection_group).strip_edges()
return group if not group.is_empty() else fish.get_habitat_label()

View file

@ -6,10 +6,21 @@ const FishDataType = preload("res://fish/fish_data.gd")
const FishQualityType = preload("res://fish/fish_quality.gd") const FishQualityType = preload("res://fish/fish_quality.gd")
const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const WorldTimeServiceType = preload("res://world/world_time_service.gd")
const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd"
)
const SILHOUETTE_SHADER: Shader = preload( const SILHOUETTE_SHADER: Shader = preload(
"res://ui/logbook_silhouette.gdshader" "res://ui/logbook_silhouette.gdshader"
) )
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd") const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const NOTEPAD_INK_ACTION_SCENE: PackedScene = preload(
"res://ui/components/bubble_menu/notepad_ink_action.tscn"
)
const InventoryNotepadType = preload( const InventoryNotepadType = preload(
"res://ui/components/inventory_notepad.gd" "res://ui/components/inventory_notepad.gd"
) )
@ -48,6 +59,8 @@ const LOGBOOK_TAB_LEFT_INSET: float = 92.0
const LOGBOOK_TAB_SIZE := Vector2(108.0, 38.0) const LOGBOOK_TAB_SIZE := Vector2(108.0, 38.0)
const LOGBOOK_TAB_SEPARATION: float = 6.0 const LOGBOOK_TAB_SEPARATION: float = 6.0
const LOGBOOK_TAB_TEXT_SIDE_INSET: float = 10.0 const LOGBOOK_TAB_TEXT_SIDE_INSET: float = 10.0
const INDEX_TAB_SIZE := Vector2(88.0, 38.0)
const INDEX_TAB_RIGHT_INSET: float = 72.0
const PAGE_CONTENT_SCALE: float = 0.97 const PAGE_CONTENT_SCALE: float = 0.97
const WIDE_OUTER_MARGINS := Rect2(52.0, 85.0, 52.0, 18.0) const WIDE_OUTER_MARGINS := Rect2(52.0, 85.0, 52.0, 18.0)
const COMPACT_OUTER_MARGINS := Rect2(160.0, 85.0, 160.0, 106.0) const COMPACT_OUTER_MARGINS := Rect2(160.0, 85.0, 160.0, 106.0)
@ -61,17 +74,53 @@ const DETAIL_SECTION_SEPARATION: int = 10
const DETAIL_QUALITY_SECTION_SIZE := Vector2(300.0, 104.0) const DETAIL_QUALITY_SECTION_SIZE := Vector2(300.0, 104.0)
const DETAIL_STATS_SECTION_SIZE := Vector2(400.0, 160.0) const DETAIL_STATS_SECTION_SIZE := Vector2(400.0, 160.0)
const DETAIL_STATS_ROW_SEPARATION: int = 8 const DETAIL_STATS_ROW_SEPARATION: int = 8
const INDEX_OPTION_HEIGHT: float = 34.0
const INDEX_CONTENT_LEFT_INSET: float = 44.0
const INDEX_CONTENT_TOP_INSET: float = 4.0
const INDEX_CONTENT_RIGHT_INSET: float = 22.0
const INDEX_CONTENT_BOTTOM_INSET: float = 10.0
const INDEX_SHOW_LABELS := [
"all entries", "caught", "missing", "needs quality",
]
const INDEX_SORT_LABELS := [
"catalog no.", "name", "rarity", "number caught",
"quality progress", "value", "weight",
]
const INDEX_AVAILABILITY_LABELS := [
"any time", "available now",
]
const INDEX_SEASON_LABELS := [
"any season", "current season", "spring", "summer", "fall", "winter",
]
const INDEX_TIME_LABELS := [
"any time", "current time", "day", "night",
]
const INDEX_RARITY_LABELS := [
"any rarity", "common", "uncommon", "rare", "epic", "legendary",
]
const INDEX_METHOD_LABELS := [
"any method", "fishing", "netting", "digging",
]
const INDEX_HOLDING_LABELS := [
"any ownership", "in cooler", "favorited",
]
signal controller_text_entry_requested(control: Control)
enum ControllerZone { enum ControllerZone {
TABS, TABS,
ENTRIES, ENTRIES,
DETAILS, DETAILS,
INDEX,
OVERLAY, OVERLAY,
} }
var _collection_log: CollectionLogType var _collection_log: CollectionLogType
var _inventory: FishInventoryType var _inventory: FishInventoryType
var _catalog: FishPoolType var _catalog: FishPoolType
var _fishing_spot: FishingSpotType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _category: LogbookCatalog.Category = ( var _category: LogbookCatalog.Category = (
LogbookCatalog.Category.FRESH_WATER LogbookCatalog.Category.FRESH_WATER
) )
@ -111,6 +160,31 @@ var _portrait_overlay_stats_view: Control
var _portrait_overlay_stats: GridContainer var _portrait_overlay_stats: GridContainer
var _overlay_return_focus: Control var _overlay_return_focus: Control
var _controller_zone: ControllerZone = ControllerZone.TABS var _controller_zone: ControllerZone = ControllerZone.TABS
var _index_tab: OrganizerTabType
var _index_body: VBoxContainer
var _index_search: LineEdit
var _index_summary: Label
var _index_note: Label
var _index_reset: Button
var _index_option_buttons: Dictionary[StringName, Button] = {}
var _index_controls: Array[Control] = []
var _index_open: bool = false
var _index_update_in_progress: bool = false
var _index_visible_count: int = 0
var _index_category_total: int = 0
var _index_options: Dictionary = {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
func _ready() -> void: func _ready() -> void:
@ -124,10 +198,16 @@ func setup(
collection_log: CollectionLogType, collection_log: CollectionLogType,
inventory: FishInventoryType, inventory: FishInventoryType,
catalog: FishPoolType, catalog: FishPoolType,
fishing_spot: FishingSpotType = null,
world_time: WorldTimeServiceType = null,
world_weather: WorldWeatherServiceType = null,
) -> void: ) -> void:
_collection_log = collection_log _collection_log = collection_log
_inventory = inventory _inventory = inventory
_catalog = catalog _catalog = catalog
_fishing_spot = fishing_spot
_world_time = world_time
_world_weather = world_weather
if not _collection_log.fish_discovered.is_connected( if not _collection_log.fish_discovered.is_connected(
_on_fish_discovered _on_fish_discovered
): ):
@ -138,12 +218,28 @@ func setup(
_collection_log.collection_changed.connect(_on_collection_changed) _collection_log.collection_changed.connect(_on_collection_changed)
if not _inventory.catches_changed.is_connected(_on_inventory_changed): if not _inventory.catches_changed.is_connected(_on_inventory_changed):
_inventory.catches_changed.connect(_on_inventory_changed) _inventory.catches_changed.connect(_on_inventory_changed)
if (
_world_time != null
and not _world_time.time_changed.is_connected(
_on_logbook_context_changed
)
):
_world_time.time_changed.connect(_on_logbook_context_changed)
_world_time.calendar_date_changed.connect(_on_logbook_context_changed)
if (
_world_weather != null
and not _world_weather.weather_changed.is_connected(
_on_logbook_context_changed
)
):
_world_weather.weather_changed.connect(_on_logbook_context_changed)
_refresh_catalog() _refresh_catalog()
func set_compact_presentation(compact: bool) -> void: func set_compact_presentation(compact: bool) -> void:
_compact_presentation = compact _compact_presentation = compact
_apply_presentation_margins() _apply_presentation_margins()
_apply_index_presentation()
func activate() -> void: func activate() -> void:
@ -180,6 +276,17 @@ func set_interactive(value: bool) -> void:
if _interactive and not tab.button_pressed if _interactive and not tab.button_pressed
else Control.MOUSE_FILTER_IGNORE else Control.MOUSE_FILTER_IGNORE
) )
if _index_tab != null:
_index_tab.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.TABS
else Control.FOCUS_NONE
)
_index_tab.mouse_filter = (
Control.MOUSE_FILTER_STOP
if _interactive
else Control.MOUSE_FILTER_IGNORE
)
for entry: Button in _entry_buttons.values(): for entry: Button in _entry_buttons.values():
entry.focus_mode = ( entry.focus_mode = (
Control.FOCUS_ALL Control.FOCUS_ALL
@ -199,6 +306,19 @@ func set_interactive(value: bool) -> void:
if _interactive and _controller_zone == ControllerZone.DETAILS if _interactive and _controller_zone == ControllerZone.DETAILS
else Control.FOCUS_NONE else Control.FOCUS_NONE
) )
for index_control: Control in _index_controls:
if not is_instance_valid(index_control):
continue
index_control.focus_mode = (
Control.FOCUS_ALL
if _interactive and _controller_zone == ControllerZone.INDEX
else Control.FOCUS_NONE
)
index_control.mouse_filter = (
Control.MOUSE_FILTER_STOP
if _interactive and _index_open
else Control.MOUSE_FILTER_IGNORE
)
if _portrait_overlay_backdrop != null: if _portrait_overlay_backdrop != null:
_portrait_overlay_backdrop.focus_mode = ( _portrait_overlay_backdrop.focus_mode = (
Control.FOCUS_ALL Control.FOCUS_ALL
@ -214,6 +334,9 @@ func focus_initial() -> void:
_portrait_overlay_backdrop.grab_focus() _portrait_overlay_backdrop.grab_focus()
return return
if _controller_zone == ControllerZone.TABS: if _controller_zone == ControllerZone.TABS:
if _index_open and _index_tab != null:
_index_tab.grab_focus()
return
var category_tab_index: int = _category_tab_categories.find(_category) var category_tab_index: int = _category_tab_categories.find(_category)
if category_tab_index >= 0: if category_tab_index >= 0:
_category_tabs[category_tab_index].grab_focus() _category_tabs[category_tab_index].grab_focus()
@ -222,6 +345,10 @@ func focus_initial() -> void:
if not _detail_buttons.is_empty(): if not _detail_buttons.is_empty():
_detail_buttons.front().grab_focus() _detail_buttons.front().grab_focus()
return return
if _controller_zone == ControllerZone.INDEX:
if _index_search != null:
_index_search.grab_focus()
return
var selected := _entry_buttons.get(_selected_entry_key) as Button var selected := _entry_buttons.get(_selected_entry_key) as Button
if selected != null: if selected != null:
selected.grab_focus() selected.grab_focus()
@ -247,6 +374,8 @@ func handle_controller_input(event: InputEvent) -> bool:
_hide_portrait_overlay() _hide_portrait_overlay()
ControllerZone.DETAILS: ControllerZone.DETAILS:
_set_controller_zone(ControllerZone.ENTRIES) _set_controller_zone(ControllerZone.ENTRIES)
ControllerZone.INDEX:
_set_index_open(false)
ControllerZone.ENTRIES: ControllerZone.ENTRIES:
_set_controller_zone(ControllerZone.TABS) _set_controller_zone(ControllerZone.TABS)
ControllerZone.TABS: ControllerZone.TABS:
@ -254,15 +383,43 @@ func handle_controller_input(event: InputEvent) -> bool:
return true return true
if _controller_zone == ControllerZone.TABS: if _controller_zone == ControllerZone.TABS:
if event.is_action_pressed("ui_left"): if event.is_action_pressed("ui_left"):
if _index_open:
_set_index_open(false, false)
_category_tabs.back().grab_focus()
return true
_select_adjacent_controller_category(-1) _select_adjacent_controller_category(-1)
return true return true
if event.is_action_pressed("ui_right"): if event.is_action_pressed("ui_right"):
if (
_category_tab_categories.find(_category)
== _category_tab_categories.size() - 1
):
_set_index_open(true, false)
_index_tab.grab_focus()
return true
_select_adjacent_controller_category(1) _select_adjacent_controller_category(1)
return true return true
if accept_pressed: if accept_pressed:
if not _entry_buttons.is_empty(): if _index_open:
_set_controller_zone(ControllerZone.INDEX)
elif not _entry_buttons.is_empty():
_set_controller_zone(ControllerZone.ENTRIES) _set_controller_zone(ControllerZone.ENTRIES)
return true return true
if _controller_zone == ControllerZone.INDEX:
var focus_owner := get_viewport().gui_get_focus_owner()
if accept_pressed and focus_owner == _index_search:
controller_text_entry_requested.emit(_index_search)
return true
if event.is_action_pressed("ui_left"):
var previous_key := _index_key_for_control(focus_owner)
if not previous_key.is_empty():
_cycle_index_option(previous_key, -1)
return true
if event.is_action_pressed("ui_right") or accept_pressed:
var next_key := _index_key_for_control(focus_owner)
if not next_key.is_empty():
_cycle_index_option(next_key, 1)
return true
if _controller_zone == ControllerZone.ENTRIES and accept_pressed: if _controller_zone == ControllerZone.ENTRIES and accept_pressed:
if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty(): if not _selected_entry_key.is_empty() and not _detail_buttons.is_empty():
_set_controller_zone(ControllerZone.DETAILS) _set_controller_zone(ControllerZone.DETAILS)
@ -329,6 +486,20 @@ func _build_interface() -> void:
tabs.add_child(tab) tabs.add_child(tab)
_category_tabs.append(tab) _category_tabs.append(tab)
_category_tab_categories.append(category) _category_tab_categories.append(category)
_index_tab = OrganizerTabType.new()
_index_tab.name = "CatalogIndexTab"
_index_tab.anchor_left = 1.0
_index_tab.anchor_right = 1.0
_index_tab.offset_left = -INDEX_TAB_RIGHT_INSET - INDEX_TAB_SIZE.x
_index_tab.offset_top = 37.0
_index_tab.offset_right = -INDEX_TAB_RIGHT_INSET
_index_tab.offset_bottom = 37.0 + INDEX_TAB_SIZE.y
_index_tab.text = "index"
_index_tab.palette_index = 0
_index_tab.tooltip_text = "Open catalog index"
_index_tab.accessibility_name = "Catalog index"
_index_tab.pressed.connect(_toggle_index_page)
stack.add_child(_index_tab)
_configure_category_focus() _configure_category_focus()
var book := Control.new() var book := Control.new()
@ -418,9 +589,176 @@ func _build_interface() -> void:
right_page.add_child(_detail_body) right_page.add_child(_detail_body)
_apply_page_content_scale(_detail_body) _apply_page_content_scale(_detail_body)
_show_no_selection() _show_no_selection()
_build_index_page(right_page)
_build_portrait_overlay() _build_portrait_overlay()
func _build_index_page(right_page: Control) -> void:
_index_body = VBoxContainer.new()
_index_body.name = "CatalogIndex"
_index_body.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_index_body.offset_left = INDEX_CONTENT_LEFT_INSET
_index_body.offset_top = INDEX_CONTENT_TOP_INSET
_index_body.offset_right = -INDEX_CONTENT_RIGHT_INSET
_index_body.offset_bottom = -INDEX_CONTENT_BOTTOM_INSET
_index_body.add_theme_constant_override("separation", 4)
_index_body.visible = false
right_page.add_child(_index_body)
_apply_page_content_scale(_index_body)
_apply_index_presentation()
var heading := _label("catalog index", 28)
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_body.add_child(heading)
_index_summary = _field_label("", 14)
_index_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_summary.add_theme_color_override("font_color", MUTED_INK)
_index_body.add_child(_index_summary)
var search_stack := VBoxContainer.new()
search_stack.add_theme_constant_override("separation", 0)
_index_body.add_child(search_stack)
var search_label := _field_label("find by name, group, or catalog no.", 13)
search_label.add_theme_color_override("font_color", MUTED_INK)
search_stack.add_child(search_label)
_index_search = LineEdit.new()
_index_search.name = "CatalogIndexSearch"
_index_search.custom_minimum_size.y = 34.0
_index_search.placeholder_text = "write here..."
_index_search.max_length = 48
_index_search.add_theme_font_override(
"font", InventoryNotepadType.HANDWRITTEN_FONT
)
_index_search.add_theme_font_size_override("font_size", 18)
_index_search.add_theme_color_override("font_color", INK)
_index_search.add_theme_color_override("caret_color", INK)
_index_search.add_theme_color_override(
"font_placeholder_color", Color(MUTED_INK, 0.72)
)
for style_name: StringName in [
&"normal", &"focus", &"read_only",
]:
_index_search.add_theme_stylebox_override(
style_name,
_index_search_style(style_name == &"focus"),
)
_index_search.text_changed.connect(_on_index_search_changed)
search_stack.add_child(_index_search)
_index_controls.append(_index_search)
var options_grid := GridContainer.new()
options_grid.columns = 2
options_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
options_grid.add_theme_constant_override("h_separation", 18)
options_grid.add_theme_constant_override("v_separation", 2)
_index_body.add_child(options_grid)
for descriptor: Dictionary in [
{"key": &"show", "label": "show"},
{"key": &"rarity", "label": "rarity"},
{"key": &"sort", "label": "sort by"},
{"key": &"availability", "label": "availability"},
{"key": &"descending", "label": "order"},
{"key": &"season", "label": "season"},
{"key": &"method", "label": "method"},
{"key": &"time", "label": "time of day"},
{"key": &"holding", "label": "ownership"},
{"key": &"group", "label": "collection group"},
]:
options_grid.add_child(_make_index_option(
descriptor["key"],
str(descriptor["label"]),
))
var footer := HBoxContainer.new()
footer.alignment = BoxContainer.ALIGNMENT_CENTER
_index_body.add_child(footer)
_index_reset = NOTEPAD_INK_ACTION_SCENE.instantiate() as Button
_index_reset.name = "CatalogIndexReset"
_index_reset.custom_minimum_size = Vector2(126.0, 34.0)
_index_reset.text = "clear index"
_apply_index_value_font(_index_reset)
_index_reset.tooltip_text = "Clear every catalog filter"
_index_reset.accessibility_name = "Clear catalog index filters"
_index_reset.pressed.connect(_reset_index)
footer.add_child(_index_reset)
_index_controls.append(_index_reset)
_index_note = _field_label(
"silhouettes keep names hidden in filtered results",
12,
)
_index_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_index_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_index_note.add_theme_color_override("font_color", Color(MUTED_INK, 0.82))
_index_body.add_child(_index_note)
_update_index_controls()
_configure_index_focus.call_deferred()
func _make_index_option(key: StringName, label_text: String) -> Control:
var stack := VBoxContainer.new()
stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
stack.add_theme_constant_override("separation", -3)
var label := _field_label(label_text, 12)
label.add_theme_color_override("font_color", MUTED_INK)
stack.add_child(label)
var button := NOTEPAD_INK_ACTION_SCENE.instantiate() as Button
button.name = "CatalogIndex%s" % String(key).capitalize().replace(" ", "")
button.custom_minimum_size = Vector2(0.0, INDEX_OPTION_HEIGHT)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.clip_text = true
_apply_index_value_font(button)
button.tooltip_text = "Change %s; use left or right on a controller" % label_text
button.pressed.connect(_cycle_index_option.bind(key, 1))
button.gui_input.connect(_on_index_option_gui_input.bind(key))
stack.add_child(button)
_index_option_buttons[key] = button
_index_controls.append(button)
return stack
func _apply_index_value_font(button: Button) -> void:
button.add_theme_font_override(
"font", InventoryNotepadType.HANDWRITTEN_FONT
)
button.add_theme_font_size_override("font_size", 18)
for color_name: StringName in [
&"font_color",
&"font_hover_color",
&"font_focus_color",
&"font_pressed_color",
]:
button.add_theme_color_override(color_name, INK)
func _index_search_style(focused: bool) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.97, 0.82, 0.12 if focused else 0.0)
style.border_color = Color(INK, 0.72 if focused else 0.42)
style.border_width_bottom = 2
style.content_margin_left = 6
style.content_margin_right = 22
style.content_margin_top = 2
style.content_margin_bottom = 3
return style
func _configure_index_focus() -> void:
var controls: Array[Control] = []
for control: Control in _index_controls:
if control != null and is_instance_valid(control):
controls.append(control)
if not controls.is_empty():
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
func _apply_index_presentation() -> void:
if _index_body == null:
return
var scale_value := 0.82 if _compact_presentation else PAGE_CONTENT_SCALE
_index_body.scale = Vector2.ONE * scale_value
func _apply_presentation_margins() -> void: func _apply_presentation_margins() -> void:
if _outer_margin == null: if _outer_margin == null:
return return
@ -548,11 +886,30 @@ func _refresh_catalog() -> void:
var preserved_entry_key: StringName = _selected_entry_key var preserved_entry_key: StringName = _selected_entry_key
_clear_entries() _clear_entries()
var species: Array[FishDataType] = [] var species: Array[FishDataType] = []
var category_total: int = 0
if _catalog != null: if _catalog != null:
species = LogbookCatalog.ordered_species(_catalog.candidates) for fish: FishDataType in LogbookCatalog.ordered_species(
_catalog.candidates
):
if LogbookCatalog.category_for(fish) == _category:
category_total += 1
var available_now_resolver := Callable()
if _fishing_spot != null and is_instance_valid(_fishing_spot):
available_now_resolver = Callable(
_fishing_spot,
"is_species_available_now",
)
species = LogbookCatalog.filtered_species(
_catalog.candidates,
_category,
_collection_log,
_inventory,
_index_options,
available_now_resolver,
_current_season(),
_current_is_night(),
)
for fish: FishDataType in species: for fish: FishDataType in species:
if LogbookCatalog.category_for(fish) != _category:
continue
var discovered: bool = _collection_log.has_discovered(fish.id) var discovered: bool = _collection_log.has_discovered(fish.id)
var entry: Button = _make_entry(fish, discovered) var entry: Button = _make_entry(fish, discovered)
var selection_key: StringName = ( var selection_key: StringName = (
@ -572,7 +929,11 @@ func _refresh_catalog() -> void:
) )
) )
_catalog_grid.add_child(entry) _catalog_grid.add_child(entry)
_empty_state.text = LogbookCatalog.empty_state(_category) _empty_state.text = (
"No entries match this index."
if _index_has_active_criteria()
else LogbookCatalog.empty_state(_category)
)
_empty_state.visible = _entry_buttons.is_empty() _empty_state.visible = _entry_buttons.is_empty()
_catalog_scroll.visible = not _entry_buttons.is_empty() _catalog_scroll.visible = not _entry_buttons.is_empty()
if ( if (
@ -589,10 +950,269 @@ func _refresh_catalog() -> void:
_selected_entry_key = StringName() _selected_entry_key = StringName()
_refresh_selection_styles() _refresh_selection_styles()
_refresh_details(false) _refresh_details(false)
_update_index_controls(species.size(), category_total)
set_interactive(_interactive) set_interactive(_interactive)
_refresh_catalog_scroll_indicators.call_deferred() _refresh_catalog_scroll_indicators.call_deferred()
func _toggle_index_page() -> void:
_set_index_open(not _index_open)
func _set_index_open(open: bool, restore_focus: bool = true) -> void:
_index_open = open
if _index_tab != null:
_index_tab.set_selected(open)
_index_tab.tooltip_text = (
"Return to catch record" if open else "Open catalog index"
)
if _index_body != null:
_index_body.visible = open
if _detail_body != null:
_detail_body.visible = not open
if not open:
_refresh_details(false)
_update_index_controls()
if restore_focus and _active and _interactive:
if open:
_controller_zone = ControllerZone.INDEX
set_interactive(true)
_index_search.grab_focus()
elif _controller_zone == ControllerZone.INDEX:
_controller_zone = ControllerZone.TABS
set_interactive(true)
_index_tab.grab_focus()
func _on_index_search_changed(value: String) -> void:
if _index_update_in_progress:
return
_index_options["search"] = value
_refresh_catalog()
func _cycle_index_option(key: StringName, direction: int = 1) -> void:
if not _index_options.has(key):
return
if key == &"descending":
_index_options[key] = not bool(_index_options[key])
elif key == &"group":
var groups: Array[String] = [""]
if _catalog != null:
groups.append_array(LogbookCatalog.available_groups(
_catalog.candidates,
_category,
_collection_log,
))
var current_index := groups.find(str(_index_options[key]))
if current_index < 0:
current_index = 0
_index_options[key] = groups[wrapi(
current_index + direction,
0,
groups.size(),
)]
else:
var option_count := _index_option_count(key)
if option_count <= 0:
return
_index_options[key] = wrapi(
int(_index_options[key]) + direction,
0,
option_count,
)
_refresh_catalog()
func _on_index_option_gui_input(
event: InputEvent,
key: StringName,
) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event == null or not mouse_event.pressed:
return
if mouse_event.button_index in [MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_WHEEL_UP]:
_cycle_index_option(key, -1)
get_viewport().set_input_as_handled()
elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_cycle_index_option(key, 1)
get_viewport().set_input_as_handled()
func _index_key_for_control(control: Control) -> StringName:
for key: StringName in _index_option_buttons:
if _index_option_buttons[key] == control:
return key
return StringName()
func _reset_index() -> void:
_index_options = {
"search": "",
"show": LogbookCatalog.DiscoveryFilter.ALL,
"sort": LogbookCatalog.SortMode.CATALOG_NUMBER,
"descending": false,
"availability": LogbookCatalog.AvailabilityFilter.ANY,
"season": LogbookCatalog.SeasonFilter.ANY,
"time": LogbookCatalog.TimeFilter.ANY,
"rarity": LogbookCatalog.RarityFilter.ANY,
"method": LogbookCatalog.MethodFilter.ANY,
"holding": LogbookCatalog.HoldingFilter.ANY,
"group": "",
}
_index_update_in_progress = true
_index_search.text = ""
_index_update_in_progress = false
_refresh_catalog()
func _update_index_controls(
visible_count: int = -1,
category_total: int = -1,
) -> void:
if visible_count >= 0:
_index_visible_count = visible_count
if category_total >= 0:
_index_category_total = category_total
if _index_summary != null:
_index_summary.text = "%d shown of %d" % [
_index_visible_count,
_index_category_total,
]
for key: StringName in _index_option_buttons:
var button := _index_option_buttons[key]
if button == null:
continue
button.text = _index_option_label(key)
button.accessibility_name = "%s: %s" % [
String(key).replace("_", " "),
button.text,
]
if _index_tab != null:
var marked := _index_has_active_criteria()
_index_tab.text = "index •" if marked else "index"
_index_tab.accessibility_name = (
"Catalog index, filters active" if marked else "Catalog index"
)
if _index_reset != null:
_index_reset.disabled = not _index_has_active_criteria()
func _index_option_label(key: StringName) -> String:
match key:
&"show":
return INDEX_SHOW_LABELS[int(_index_options[key])]
&"sort":
return INDEX_SORT_LABELS[int(_index_options[key])]
&"availability":
return INDEX_AVAILABILITY_LABELS[int(_index_options[key])]
&"season":
return INDEX_SEASON_LABELS[int(_index_options[key])]
&"time":
return INDEX_TIME_LABELS[int(_index_options[key])]
&"rarity":
return INDEX_RARITY_LABELS[int(_index_options[key])]
&"method":
return INDEX_METHOD_LABELS[int(_index_options[key])]
&"holding":
return INDEX_HOLDING_LABELS[int(_index_options[key])]
&"group":
var group := str(_index_options[key])
return group if not group.is_empty() else "any group"
&"descending":
var descending := bool(_index_options[key])
match int(_index_options["sort"]):
LogbookCatalog.SortMode.NAME:
return "za" if descending else "az"
LogbookCatalog.SortMode.CATALOG_NUMBER:
return "highlow" if descending else "lowhigh"
LogbookCatalog.SortMode.RARITY:
return (
"rarecommon" if descending else "commonrare"
)
LogbookCatalog.SortMode.VALUE, LogbookCatalog.SortMode.WEIGHT:
return "highlow" if descending else "lowhigh"
_:
return "most first" if descending else "least first"
return "any"
func _index_option_count(key: StringName) -> int:
match key:
&"show":
return INDEX_SHOW_LABELS.size()
&"sort":
return INDEX_SORT_LABELS.size()
&"availability":
return INDEX_AVAILABILITY_LABELS.size()
&"season":
return INDEX_SEASON_LABELS.size()
&"time":
return INDEX_TIME_LABELS.size()
&"rarity":
return INDEX_RARITY_LABELS.size()
&"method":
return INDEX_METHOD_LABELS.size()
&"holding":
return INDEX_HOLDING_LABELS.size()
return 0
func _index_has_active_criteria() -> bool:
return (
not str(_index_options["search"]).is_empty()
or int(_index_options["show"])
!= LogbookCatalog.DiscoveryFilter.ALL
or int(_index_options["sort"])
!= LogbookCatalog.SortMode.CATALOG_NUMBER
or bool(_index_options["descending"])
or int(_index_options["availability"])
!= LogbookCatalog.AvailabilityFilter.ANY
or int(_index_options["season"])
!= LogbookCatalog.SeasonFilter.ANY
or int(_index_options["time"])
!= LogbookCatalog.TimeFilter.ANY
or int(_index_options["rarity"])
!= LogbookCatalog.RarityFilter.ANY
or int(_index_options["method"])
!= LogbookCatalog.MethodFilter.ANY
or int(_index_options["holding"])
!= LogbookCatalog.HoldingFilter.ANY
or not str(_index_options["group"]).is_empty()
)
func _current_season() -> int:
return (
_world_time.get_season()
if _world_time != null
else CalendarSeason.UNKNOWN
)
func _current_is_night() -> bool:
return _world_time != null and _world_time.is_night_period()
func notify_availability_context_changed() -> void:
_on_logbook_context_changed()
func _on_logbook_context_changed(
_first: Variant = null,
_second: Variant = null,
) -> void:
if (
int(_index_options["availability"])
== LogbookCatalog.AvailabilityFilter.AVAILABLE_NOW
or int(_index_options["season"])
== LogbookCatalog.SeasonFilter.CURRENT
or int(_index_options["time"])
== LogbookCatalog.TimeFilter.CURRENT
):
_refresh_catalog()
func _make_entry(fish: FishDataType, discovered: bool) -> Button: func _make_entry(fish: FishDataType, discovered: bool) -> Button:
var entry := Button.new() var entry := Button.new()
entry.custom_minimum_size = CATALOG_ENTRY_SIZE entry.custom_minimum_size = CATALOG_ENTRY_SIZE
@ -663,8 +1283,11 @@ func _add_entry_content(
func _select_category(category: LogbookCatalog.Category) -> void: func _select_category(category: LogbookCatalog.Category) -> void:
if category == _category: if category == _category:
if _index_open:
_set_index_open(false)
return return
_category = category _category = category
_set_index_open(false, false)
_selected_id = StringName() _selected_id = StringName()
_selected_entry_key = StringName() _selected_entry_key = StringName()
for index: int in _category_tabs.size(): for index: int in _category_tabs.size():
@ -683,6 +1306,11 @@ func _configure_category_focus() -> void:
tab.focus_neighbor_right = tab.get_path_to( tab.focus_neighbor_right = tab.get_path_to(
_category_tabs[mini(index + 1, _category_tabs.size() - 1)] _category_tabs[mini(index + 1, _category_tabs.size() - 1)]
) )
if not _category_tabs.is_empty() and _index_tab != null:
var final_tab: Button = _category_tabs.back()
final_tab.focus_neighbor_right = final_tab.get_path_to(_index_tab)
_index_tab.focus_neighbor_left = _index_tab.get_path_to(final_tab)
_index_tab.focus_neighbor_right = _index_tab.get_path_to(_index_tab)
func _animate_tab_entry() -> void: func _animate_tab_entry() -> void:
@ -690,11 +1318,15 @@ func _animate_tab_entry() -> void:
(_category_tabs[index] as OrganizerTabType).animate_entrance( (_category_tabs[index] as OrganizerTabType).animate_entrance(
float(index) * 0.025 float(index) * 0.025
) )
if _index_tab != null:
_index_tab.animate_entrance(float(_category_tabs.size()) * 0.025)
func _settle_tabs_for_close() -> void: func _settle_tabs_for_close() -> void:
for tab: OrganizerTabType in _category_tabs: for tab: OrganizerTabType in _category_tabs:
tab.settle_for_close() tab.settle_for_close()
if _index_tab != null:
_index_tab.settle_for_close()
func _begin_category_transition() -> void: func _begin_category_transition() -> void:
@ -750,9 +1382,12 @@ func _set_category_content_alpha(alpha: float) -> void:
_empty_state.modulate.a = alpha _empty_state.modulate.a = alpha
if _detail_body != null: if _detail_body != null:
_detail_body.modulate.a = alpha _detail_body.modulate.a = alpha
if _index_body != null:
_index_body.modulate.a = alpha
func _select_entry(entry_key: StringName, fish_id: StringName) -> void: func _select_entry(entry_key: StringName, fish_id: StringName) -> void:
_set_index_open(false, false)
if fish_id.is_empty(): if fish_id.is_empty():
_selected_id = StringName() _selected_id = StringName()
_selected_entry_key = entry_key _selected_entry_key = entry_key
@ -1284,7 +1919,9 @@ func _on_collection_changed() -> void:
func _on_inventory_changed() -> void: func _on_inventory_changed() -> void:
_refresh_details(false) # Ownership filters and favorite-backed views depend on the live cooler,
# while rebuilding the catalog also keeps the selected detail current.
_refresh_catalog()
func _unknown_selection_key(fish: FishDataType) -> StringName: func _unknown_selection_key(fish: FishDataType) -> StringName:

View file

@ -105,6 +105,7 @@ signal controller_hotbar_management_requested(initial_slot: int)
signal controller_hotbar_management_ended signal controller_hotbar_management_ended
signal menu_exit_started signal menu_exit_started
signal shop_cooler_modal_changed(is_open: bool) signal shop_cooler_modal_changed(is_open: bool)
signal controller_text_entry_requested(control: Control)
enum Section { enum Section {
COOLER, COOLER,
@ -409,6 +410,9 @@ func _ready() -> void:
_logbook_tab.pressed.connect( _logbook_tab.pressed.connect(
_show_section.bind(Section.LOGBOOK) _show_section.bind(Section.LOGBOOK)
) )
_catalog_logbook.controller_text_entry_requested.connect(
controller_text_entry_requested.emit
)
_the_net_tab.pressed.connect(_show_section.bind(Section.NET)) _the_net_tab.pressed.connect(_show_section.bind(Section.NET))
_mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
_profile_tab.pressed.connect(_show_section.bind(Section.PROFILE)) _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE))
@ -556,6 +560,7 @@ func setup(
discovery: DiscoveryClient, discovery: DiscoveryClient,
player_jobs: PlayerJobService, player_jobs: PlayerJobService,
world_time: WorldTimeService, world_time: WorldTimeService,
world_weather: WorldWeatherService,
world_environment: WorldEnvironment, world_environment: WorldEnvironment,
world_sun: DirectionalLight3D, world_sun: DirectionalLight3D,
) -> void: ) -> void:
@ -589,7 +594,14 @@ func setup(
world_sun, world_sun,
) )
_players_page.setup(network_player_list, discovery) _players_page.setup(network_player_list, discovery)
_catalog_logbook.setup(collection_log, inventory, catalog) _catalog_logbook.setup(
collection_log,
inventory,
catalog,
fishing_spot,
world_time,
world_weather,
)
_the_net_page.setup(player_jobs, world_time) _the_net_page.setup(player_jobs, world_time)
_network_mail_service.unread_count_changed.connect( _network_mail_service.unread_count_changed.connect(
_on_mail_unread_count_changed _on_mail_unread_count_changed
@ -1547,11 +1559,14 @@ func open_menu() -> void:
_cancel_page_tween() _cancel_page_tween()
_prior_movement_enabled = _player.is_movement_enabled() _prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_input_enabled = _player.is_camera_input_enabled() _prior_camera_input_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode _player.set_movement_enabled(false)
# Ending an active right-click camera drag restores its ordinary pointer
# mode. Snapshot after that handoff so this menu never restores the drag's
# transient captured mode when it closes.
_player.set_camera_input_enabled(false)
_prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode)
_control_snapshot_stored = true _control_snapshot_stored = true
_mouse_snapshot_stored = true _mouse_snapshot_stored = true
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
visible = true visible = true
@ -1763,6 +1778,16 @@ func _apply_mouse_close_policy(reason: CloseReason) -> void:
_mouse_snapshot_stored = false _mouse_snapshot_stored = false
func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
# Mouse capture belongs to an in-progress camera drag, never to the player
# menu itself. A held right button can be released while this menu owns
# input, so restoring CAPTURED here would leave the pointer hidden without
# an active drag to release it.
if mouse_mode == Input.MOUSE_MODE_CAPTURED:
return Input.MOUSE_MODE_VISIBLE
return mouse_mode
func _on_bite_activated() -> void: func _on_bite_activated() -> void:
if visible: if visible:
close_menu(CloseReason.BITE_STARTED) close_menu(CloseReason.BITE_STARTED)
@ -2779,6 +2804,7 @@ func _toggle_active_tackle() -> void:
func _on_active_bait_changed(_item_id: StringName) -> void: func _on_active_bait_changed(_item_id: StringName) -> void:
_update_tackle_detail() _update_tackle_detail()
_catalog_logbook.notify_availability_context_changed()
func _on_active_lure_changed(_item_id: StringName) -> void: func _on_active_lure_changed(_item_id: StringName) -> void:
@ -3733,6 +3759,7 @@ func _refresh_all() -> void:
func _on_bag_changed() -> void: func _on_bag_changed() -> void:
_refresh_bag() _refresh_bag()
_refresh_tackle_box() _refresh_tackle_box()
_catalog_logbook.notify_availability_context_changed()
func _on_hotbar_changed() -> void: func _on_hotbar_changed() -> void:

View file

@ -4,6 +4,7 @@ extends Control
const INPUT_OWNER: StringName = &"player_storage" const INPUT_OWNER: StringName = &"player_storage"
signal menu_visibility_changed(is_open: bool) signal menu_visibility_changed(is_open: bool)
signal decorate_requested
var _player: Player var _player: Player
var _fishing_spot: FishingSpot var _fishing_spot: FishingSpot
@ -14,6 +15,9 @@ var _storage_grid: GeneralInventoryGrid
var _inventory_count: Label var _inventory_count: Label
var _storage_count: Label var _storage_count: Label
var _feedback: Label var _feedback: Label
var _title: Label
var _decorate_button: Button
var _home_access: bool = false
var _prior_movement_enabled: bool = true var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
@ -75,20 +79,42 @@ func set_world_interaction(interaction: PlayerStorageInteraction) -> void:
func open_storage() -> bool: func open_storage() -> bool:
return _open_storage(false)
func open_storage_from_home(allow_decorating: bool = true) -> bool:
return _open_storage(true, allow_decorating)
func _open_storage(
home_access: bool,
allow_decorating: bool = false,
) -> bool:
if ( if (
visible visible
or _player == null or _player == null
or _interaction == null
or not _interaction.is_local_player_in_range()
or _fishing_spot == null or _fishing_spot == null
or not _fishing_spot.can_open_fishing_shop() or not _fishing_spot.can_open_fishing_shop()
or (
not home_access
and (
_interaction == null
or not _interaction.is_local_player_in_range()
)
)
): ):
return false return false
_home_access = home_access
_title.text = "RV storage" if home_access else "private storage"
_decorate_button.visible = home_access and allow_decorating
_prior_movement_enabled = _player.is_movement_enabled() _prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled() _prior_camera_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode
_player.set_movement_enabled(false) _player.set_movement_enabled(false)
# End an in-progress right-click camera drag before taking the snapshot.
# A held button can be released while this page owns input, so captured mode
# is never a valid mouse mode to restore when the page closes.
_player.set_camera_input_enabled(false) _player.set_camera_input_enabled(false)
_prior_mouse_mode = _get_restorable_mouse_mode(Input.mouse_mode)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_feedback.text = "select an item to move it · drag to choose a slot" _feedback.text = "select an item to move it · drag to choose a slot"
@ -112,10 +138,13 @@ func close_storage(restore_controls: bool = true) -> void:
_player.set_movement_enabled(_prior_movement_enabled) _player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_enabled) _player.set_camera_input_enabled(_prior_camera_enabled)
Input.mouse_mode = _prior_mouse_mode Input.mouse_mode = _prior_mouse_mode
_home_access = false
menu_visibility_changed.emit(false) menu_visibility_changed.emit(false)
func close_for_range_exit() -> void: func close_for_range_exit() -> void:
if _home_access:
return
close_storage() close_storage()
@ -127,6 +156,12 @@ func close_for_session_end() -> void:
close_storage(false) close_storage(false)
func _get_restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
if mouse_mode == Input.MOUSE_MODE_CAPTURED:
return Input.MOUSE_MODE_VISIBLE
return mouse_mode
func consume_escape() -> bool: func consume_escape() -> bool:
if not visible: if not visible:
return false return false
@ -248,12 +283,20 @@ func _build_ui() -> void:
var header := HBoxContainer.new() var header := HBoxContainer.new()
layout.add_child(header) layout.add_child(header)
var title := Label.new() _title = Label.new()
title.text = "private storage" _title.text = "private storage"
title.add_theme_font_size_override("font_size", 32) _title.add_theme_font_size_override("font_size", 32)
title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY) _title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL _title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title) header.add_child(_title)
_decorate_button = Button.new()
_decorate_button.text = "decorate RV"
_decorate_button.tooltip_text = "Place decor inside your RV"
_decorate_button.custom_minimum_size = Vector2(160.0, 48.0)
UtilityPageStyle.apply_ocean_button(_decorate_button)
_decorate_button.hide()
_decorate_button.pressed.connect(_request_decorating)
header.add_child(_decorate_button)
var close := Button.new() var close := Button.new()
close.text = "×" close.text = "×"
close.tooltip_text = "Close storage" close.tooltip_text = "Close storage"
@ -285,6 +328,13 @@ func _build_ui() -> void:
layout.add_child(_feedback) layout.add_child(_feedback)
func _request_decorating() -> void:
if not _home_access:
return
close_storage()
decorate_requested.emit()
func _build_column(parent: HBoxContainer, title_text: String) -> Dictionary: func _build_column(parent: HBoxContainer, title_text: String) -> Dictionary:
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(550.0, 0.0) panel.custom_minimum_size = Vector2(550.0, 0.0)

View file

@ -213,7 +213,7 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "v0.19.0-alpha" text = "v0.20.2-alpha"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]

View file

@ -0,0 +1,2 @@
class_name DecorShopInteraction
extends FishingShopInteraction

View file

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

View file

@ -9,6 +9,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload( const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd" "res://world/player_storage_interaction.gd"
) )
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PrecipitationOcclusionType = preload( const PrecipitationOcclusionType = preload(
"res://world/environment/precipitation_occlusion.gd" "res://world/environment/precipitation_occlusion.gd"
) )
@ -43,6 +46,12 @@ const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE := 0.35 const PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE := 0.35
const PROP_CHANCE_SCALE := 10000 const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000 const PROP_SELECTION_WEIGHT_SCALE := 1000
const WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE := 0.05
const WATER_RECOVERY_MINIMUM_UP_DOT := 0.6
const WATER_RECOVERY_FOOTPRINT_RADIUS := 0.6
const WATER_RECOVERY_INSET_STEP := 0.25
const WATER_RECOVERY_MAXIMUM_INSET := 1.5
const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [ const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
Vector2(1.0, 0.0), Vector2(1.0, 0.0),
Vector2(0.70710678, 0.70710678), Vector2(0.70710678, 0.70710678),
@ -74,8 +83,12 @@ const PROCEDURAL_PROP_GROUPS: Array[StringName] = [
@onready var _player_storage: PlayerStorageInteractionType = ( @onready var _player_storage: PlayerStorageInteractionType = (
$Interactables/PlayerStorageBox/InteractionArea $Interactables/PlayerStorageBox/InteractionArea
) )
@onready var _decor_shop: DecorShopInteractionType = (
$Interactables/DecorShopWorld/InteractionArea
)
@onready var _shop_root: Node3D = %FishingShopWorld @onready var _shop_root: Node3D = %FishingShopWorld
@onready var _storage_root: Node3D = %PlayerStorageBox @onready var _storage_root: Node3D = %PlayerStorageBox
@onready var _decor_shop_root: Node3D = %DecorShopWorld
@onready var _decorations: Node3D = %Decorations @onready var _decorations: Node3D = %Decorations
@onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks @onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks
@onready var _diggable_beach: DiggableArea3D = %DiggableBeach @onready var _diggable_beach: DiggableArea3D = %DiggableBeach
@ -90,6 +103,7 @@ var _placed_prop_clearance_radii: Array[float] = []
var _placed_prop_groups: Array[StringName] = [] var _placed_prop_groups: Array[StringName] = []
var _placed_group_coordinates: Dictionary[StringName, Array] = {} var _placed_group_coordinates: Dictionary[StringName, Array] = {}
var _biome_assignments: Dictionary[Vector2i, StringName] = {} var _biome_assignments: Dictionary[Vector2i, StringName] = {}
var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {}
func _ready() -> void: func _ready() -> void:
@ -141,6 +155,10 @@ func get_player_storage() -> PlayerStorageInteractionType:
return _player_storage return _player_storage
func get_decor_shop() -> DecorShopInteractionType:
return _decor_shop
func get_saltwater_shoreline_mesh() -> MeshInstance3D: func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference return _shoreline_reference
@ -190,6 +208,37 @@ func get_spawn_surface_triangles(
return triangles return triangles
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if (
not entry_position.is_finite()
or _water_recovery_triangles_by_coordinate.is_empty()
):
return super(entry_position, fallback_position)
var nearest_surface_distance := _nearest_recovery_surface_distance(
entry_position
)
if not is_finite(nearest_surface_distance):
return super(entry_position, fallback_position)
for expansion: float in WATER_RECOVERY_SEARCH_EXPANSIONS:
var maximum_distance := nearest_surface_distance + expansion
var result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
maximum_distance,
)
if result is Vector3:
return result as Vector3
var map_wide_result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
get_playable_half_extents().length() * 2.0,
)
if map_wide_result is Vector3:
return map_wide_result as Vector3
return super(entry_position, fallback_position)
func _on_generation_completed(summary: Dictionary) -> void: func _on_generation_completed(summary: Dictionary) -> void:
_diggable_beach.invalidate_surface_cache() _diggable_beach.invalidate_surface_cache()
var records: Array[Dictionary] = _generator.placement_records() var records: Array[Dictionary] = _generator.placement_records()
@ -325,6 +374,10 @@ func _on_generation_completed(summary: Dictionary) -> void:
grass_surface_triangles_by_coordinate, grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate, sand_surface_triangles_by_coordinate,
) )
_cache_water_recovery_surfaces(
grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate,
)
_place_spawn_amenities(spawn_position) _place_spawn_amenities(spawn_position)
_configure_fresh_water(records) _configure_fresh_water(records)
_configure_diggable_area() _configure_diggable_area()
@ -386,6 +439,8 @@ func _place_spawn_amenities(center: Vector3) -> void:
_shop_root.rotation.y = PI _shop_root.rotation.y = PI
_storage_root.position = center + Vector3(-2.4, 0.0, 1.2) _storage_root.position = center + Vector3(-2.4, 0.0, 1.2)
_storage_root.rotation.y = PI * 0.5 _storage_root.rotation.y = PI * 0.5
_decor_shop_root.position = center + Vector3(-2.4, 0.0, 1.2)
_decor_shop_root.rotation.y = PI * 0.5
func _configure_static_water() -> void: func _configure_static_water() -> void:
@ -1261,6 +1316,203 @@ func _terrain_surface_triangles_by_coordinate(
return triangles_by_coordinate return triangles_by_coordinate
func _cache_water_recovery_surfaces(
grass_triangles: Dictionary[Vector2i, Array],
sand_triangles: Dictionary[Vector2i, Array],
) -> void:
_water_recovery_triangles_by_coordinate.clear()
_append_water_recovery_surfaces(grass_triangles)
_append_water_recovery_surfaces(sand_triangles)
func _append_water_recovery_surfaces(
source: Dictionary[Vector2i, Array],
) -> void:
for coordinate: Vector2i in source:
var eligible: Array = _water_recovery_triangles_by_coordinate.get(
coordinate,
[],
)
for triangle: PackedVector3Array in source[coordinate]:
if triangle.size() != 3:
continue
if minf(
triangle[0].y,
minf(triangle[1].y, triangle[2].y),
) <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE:
continue
var cross := (triangle[1] - triangle[0]).cross(
triangle[2] - triangle[0]
)
if (
cross.length_squared() <= 0.0000001
or absf(cross.normalized().dot(Vector3.UP))
< WATER_RECOVERY_MINIMUM_UP_DOT
):
continue
eligible.append(triangle)
if not eligible.is_empty():
_water_recovery_triangles_by_coordinate[coordinate] = eligible
func _nearest_recovery_surface_distance(entry_position: Vector3) -> float:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var nearest_distance_squared := INF
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
nearest_distance_squared = minf(
nearest_distance_squared,
entry_horizontal.distance_squared_to(closest),
)
return sqrt(nearest_distance_squared)
func _nearest_safe_recovery_surface_position(
entry_position: Vector3,
maximum_distance: float,
) -> Variant:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var maximum_distance_squared := maximum_distance * maximum_distance
var best_distance_squared := INF
var best_position: Variant = null
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
if (
entry_horizontal.distance_squared_to(closest)
> maximum_distance_squared
):
continue
var centroid := Vector2(
(triangle[0].x + triangle[1].x + triangle[2].x) / 3.0,
(triangle[0].z + triangle[1].z + triangle[2].z) / 3.0,
)
var inset_distance := minf(
closest.distance_to(centroid),
WATER_RECOVERY_MAXIMUM_INSET,
)
var inset_steps := ceili(
inset_distance / WATER_RECOVERY_INSET_STEP
)
for inset_index: int in inset_steps + 1:
var candidate_horizontal := closest.move_toward(
centroid,
minf(
float(inset_index) * WATER_RECOVERY_INSET_STEP,
inset_distance,
),
)
var distance_squared := entry_horizontal.distance_squared_to(
candidate_horizontal
)
if (
distance_squared > maximum_distance_squared
or distance_squared >= best_distance_squared
):
continue
var candidate := Vector3(
candidate_horizontal.x,
entry_position.y,
candidate_horizontal.y,
)
var safe_position: Variant = _safe_recovery_surface_position(
candidate
)
if safe_position is Vector3:
best_distance_squared = distance_squared
best_position = safe_position
return best_position
func _safe_recovery_surface_position(candidate: Vector3) -> Variant:
var nearby_triangles := _water_recovery_surface_triangles_near(candidate)
var surface_height := _surface_height_at(candidate, nearby_triangles)
if (
surface_height <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
or not _surface_supports_prop_footprint(
candidate,
surface_height,
WATER_RECOVERY_FOOTPRINT_RADIUS,
nearby_triangles,
)
or not _has_prop_clearance(
candidate,
WATER_RECOVERY_FOOTPRINT_RADIUS,
)
):
return null
candidate.y = surface_height
return candidate
func _water_recovery_surface_triangles_near(
position: Vector3,
) -> Array[PackedVector3Array]:
var result: Array[PackedVector3Array] = []
if _generator.catalog == null or _generator.catalog.chunk_size <= 0.0:
return result
var local_position := _generator.to_local(position)
var half_grid := Vector2(
float(_generator.grid_size.x - 1) * 0.5,
float(_generator.grid_size.y - 1) * 0.5,
)
var coordinate := Vector2i(
roundi(local_position.x / _generator.catalog.chunk_size + half_grid.x),
roundi(local_position.z / _generator.catalog.chunk_size + half_grid.y),
)
for offset_x: int in range(-1, 2):
for offset_y: int in range(-1, 2):
var nearby_coordinate := coordinate + Vector2i(offset_x, offset_y)
result.append_array(
_water_recovery_triangles_by_coordinate.get(
nearby_coordinate,
[],
)
)
return result
func _closest_horizontal_point_on_triangle(
point: Vector2,
triangle: PackedVector3Array,
) -> Vector2:
var polygon := PackedVector2Array([
Vector2(triangle[0].x, triangle[0].z),
Vector2(triangle[1].x, triangle[1].z),
Vector2(triangle[2].x, triangle[2].z),
])
if Geometry2D.is_point_in_polygon(point, polygon):
return point
var closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[0],
polygon[1],
)
var nearest_distance_squared := point.distance_squared_to(closest)
for edge_index: int in range(1, 3):
var edge_closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[edge_index],
polygon[(edge_index + 1) % 3],
)
var distance_squared := point.distance_squared_to(edge_closest)
if distance_squared < nearest_distance_squared:
nearest_distance_squared = distance_squared
closest = edge_closest
return closest
func _terrain_coordinate_for(mesh_instance: MeshInstance3D) -> Vector2i: func _terrain_coordinate_for(mesh_instance: MeshInstance3D) -> Vector2i:
var current: Node = mesh_instance var current: Node = mesh_instance
while current != null and current != _generator: while current != null and current != _generator:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=14 format=3] [gd_scene load_steps=15 format=3]
[ext_resource type="Script" path="res://world/generation/generated_world_region.gd" id="1_region"] [ext_resource type="Script" path="res://world/generation/generated_world_region.gd" id="1_region"]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"] [ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"]
@ -13,6 +13,7 @@
[ext_resource type="Resource" path="res://fish/pools/starter_pond_pool.tres" id="11_pond_pool"] [ext_resource type="Resource" path="res://fish/pools/starter_pond_pool.tres" id="11_pond_pool"]
[ext_resource type="Resource" path="res://world/generation/props/terrain_prop_catalog.tres" id="12_prop_catalog"] [ext_resource type="Resource" path="res://world/generation/props/terrain_prop_catalog.tres" id="12_prop_catalog"]
[ext_resource type="Resource" path="res://world/generation/biomes/terrain_biome_catalog.tres" id="13_biome_catalog"] [ext_resource type="Resource" path="res://world/generation/biomes/terrain_biome_catalog.tres" id="13_biome_catalog"]
[ext_resource type="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="14_decor_shop"]
[node name="GeneratedWorldRegion" type="Node3D"] [node name="GeneratedWorldRegion" type="Node3D"]
script = ExtResource("1_region") script = ExtResource("1_region")
@ -98,6 +99,9 @@ unique_name_in_owner = true
[node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("6_storage")] [node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("6_storage")]
unique_name_in_owner = true unique_name_in_owner = true
[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("14_decor_shop")]
unique_name_in_owner = true
[node name="Decorations" type="Node3D" parent="."] [node name="Decorations" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true

View file

@ -0,0 +1,35 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://world/decor_shop_interaction.gd" id="1_shop"]
[ext_resource type="PackedScene" path="res://art/exported/characters/base/straywild_base_character.glb" id="2_character"]
[ext_resource type="Script" path="res://world/world_character_display.gd" id="3_display"]
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 3.0
[node name="DecorShopWorld" type="Node3D"]
[node name="Shopkeeper" parent="." instance=ExtResource("2_character")]
script = ExtResource("3_display")
species_id = "round"
fur_color_id = "orange"
ears_id = "pointy_short"
tail_id = "cat"
eyes_id = "simple_shine"
nose_id = "dog_round"
mouth_id = "three"
head_look_enabled = true
[node name="AnimationPlayer" parent="Shopkeeper"]
autoplay = "idle"
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")
[node name="InteractionShape" type="CollisionShape3D" parent="InteractionArea"]
shape = SubResource("InteractionShape")

View file

@ -27,6 +27,8 @@ size = Vector3(1.62, 0.86, 1.02)
radius = 2.3 radius = 2.3
[node name="PlayerStorageBox" type="Node3D"] [node name="PlayerStorageBox" type="Node3D"]
process_mode = 4
visible = false
[node name="Body" type="MeshInstance3D" parent="."] [node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.35, 0) position = Vector3(0, 0.35, 0)
@ -44,6 +46,7 @@ collision_mask = 0
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"] [node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"]
position = Vector3(0, 0.43, 0) position = Vector3(0, 0.43, 0)
disabled = true
shape = SubResource("CollisionShape") shape = SubResource("CollisionShape")
[node name="InteractionArea" type="Area3D" parent="."] [node name="InteractionArea" type="Area3D" parent="."]
@ -54,6 +57,7 @@ collision_mask = 2
script = ExtResource("1_interaction") script = ExtResource("1_interaction")
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractionArea"] [node name="CollisionShape3D" type="CollisionShape3D" parent="InteractionArea"]
disabled = true
shape = SubResource("InteractionShape") shape = SubResource("InteractionShape")
[node name="PromptAnchor" type="Marker3D" parent="."] [node name="PromptAnchor" type="Marker3D" parent="."]

View file

@ -8,6 +8,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload( const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd" "res://world/player_storage_interaction.gd"
) )
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const FOLIAGE_WIND_SHADER: Shader = preload( const FOLIAGE_WIND_SHADER: Shader = preload(
"res://world/materials/foliage_wind.gdshader" "res://world/materials/foliage_wind.gdshader"
) )
@ -49,6 +52,10 @@ var fishing_shop_path: NodePath = (
var player_storage_path: NodePath = ( var player_storage_path: NodePath = (
^"Interactables/PlayerStorageBox/InteractionArea" ^"Interactables/PlayerStorageBox/InteractionArea"
) )
@export_node_path("Area3D")
var decor_shop_path: NodePath = (
^"Interactables/DecorShopWorld/InteractionArea"
)
@export_group("Terrain Collision") @export_group("Terrain Collision")
# The imported GLB is the collision authority. Rebuild once per region load so # The imported GLB is the collision authority. Rebuild once per region load so
# newly authored terrain and props cannot retain a stale saved fallback shape. # newly authored terrain and props cannot retain a stale saved fallback shape.
@ -84,6 +91,10 @@ func get_player_storage() -> PlayerStorageInteractionType:
return get_node_or_null(player_storage_path) as PlayerStorageInteractionType return get_node_or_null(player_storage_path) as PlayerStorageInteractionType
func get_decor_shop() -> DecorShopInteractionType:
return get_node_or_null(decor_shop_path) as DecorShopInteractionType
func get_saltwater_shoreline_mesh() -> MeshInstance3D: func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D

File diff suppressed because one or more lines are too long

View file

@ -18,6 +18,10 @@ func get_fishing_shop() -> FishingShopInteraction:
return null return null
func get_decor_shop() -> DecorShopInteraction:
return null
func get_player_storage() -> PlayerStorageInteraction: func get_player_storage() -> PlayerStorageInteraction:
return null return null
@ -72,6 +76,24 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
return points return points
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if not entry_position.is_finite():
return fallback_position
var target_position := fallback_position
var nearest_distance := INF
for point: SafeRespawnPoint in get_safe_respawn_points():
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func get_diggable_areas() -> Array[DiggableArea3D]: func get_diggable_areas() -> Array[DiggableArea3D]:
var areas: Array[DiggableArea3D] = [] var areas: Array[DiggableArea3D] = []
var root: Node = get_node_or_null(diggable_area_root) var root: Node = get_node_or_null(diggable_area_root)

View file

@ -7,6 +7,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload( const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd" "res://world/player_storage_interaction.gd"
) )
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const WorldLayoutType = preload("res://world/world_layout.gd") const WorldLayoutType = preload("res://world/world_layout.gd")
const GENERATED_REGION_SCENE: PackedScene = preload( const GENERATED_REGION_SCENE: PackedScene = preload(
"res://world/generation/generated_world_region.tscn" "res://world/generation/generated_world_region.tscn"
@ -31,6 +34,7 @@ var _world_layout: StringName = WorldLayoutType.GENERATED
var _world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED var _world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _light_performance_profile: bool = false var _light_performance_profile: bool = false
var _dedicated_simulation: bool = false var _dedicated_simulation: bool = false
var _local_home_interior_presentation_active: bool = false
func get_player_water_triggers() -> Array[PlayerWaterTrigger]: func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
@ -49,6 +53,16 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
) )
func get_water_recovery_position(entry_position: Vector3) -> Vector3:
if _active_region == null:
return global_position
var fallback_position := get_player_spawn_transform().origin
return _active_region.get_water_recovery_position(
entry_position,
fallback_position,
)
func get_fishing_shop() -> FishingShopInteractionType: func get_fishing_shop() -> FishingShopInteractionType:
return _active_region.get_fishing_shop() return _active_region.get_fishing_shop()
@ -57,6 +71,10 @@ func get_player_storage() -> PlayerStorageInteractionType:
return _active_region.get_player_storage() return _active_region.get_player_storage()
func get_decor_shop() -> DecorShopInteractionType:
return _active_region.get_decor_shop()
func get_player_spawn_transform() -> Transform3D: func get_player_spawn_transform() -> Transform3D:
return _active_region.get_player_spawn_transform() return _active_region.get_player_spawn_transform()
@ -75,6 +93,12 @@ func set_light_performance_profile(enabled: bool) -> void:
_active_region.set_light_performance_profile(enabled) _active_region.set_light_performance_profile(enabled)
func set_local_home_interior_presentation_active(enabled: bool) -> void:
_local_home_interior_presentation_active = enabled
if _active_region != null and not _dedicated_simulation:
_active_region.visible = not enabled
func set_dedicated_simulation(enabled: bool) -> void: func set_dedicated_simulation(enabled: bool) -> void:
_dedicated_simulation = enabled _dedicated_simulation = enabled
if _active_region != null: if _active_region != null:
@ -262,6 +286,8 @@ func _replace_active_region(layout: StringName, seed: int) -> bool:
_active_region.set_light_performance_profile(_light_performance_profile) _active_region.set_light_performance_profile(_light_performance_profile)
if _dedicated_simulation: if _dedicated_simulation:
_set_region_presentation_enabled(_active_region, false) _set_region_presentation_enabled(_active_region, false)
else:
_active_region.visible = not _local_home_interior_presentation_active
return true return true

View file

@ -25,6 +25,7 @@ var _game_ui: GameUI
var _screen_fade: ScreenFade var _screen_fade: ScreenFade
var _water_triggers: Array[PlayerWaterTrigger] = [] var _water_triggers: Array[PlayerWaterTrigger] = []
var _safe_points: Array[SafeRespawnPoint] = [] var _safe_points: Array[SafeRespawnPoint] = []
var _recovery_position_resolver := Callable()
var _initial_spawn_transform: Transform3D var _initial_spawn_transform: Transform3D
var _entry_position: Vector3 var _entry_position: Vector3
var _bob_base_position: Vector3 var _bob_base_position: Vector3
@ -43,6 +44,7 @@ func setup(
screen_fade: ScreenFade, screen_fade: ScreenFade,
water_triggers: Array[PlayerWaterTrigger], water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint], safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void: ) -> void:
_player = player _player = player
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
@ -50,6 +52,7 @@ func setup(
_screen_fade = screen_fade _screen_fade = screen_fade
_water_triggers = water_triggers _water_triggers = water_triggers
_safe_points = safe_points _safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
_initial_spawn_transform = player.global_transform _initial_spawn_transform = player.global_transform
for water_trigger: PlayerWaterTrigger in _water_triggers: for water_trigger: PlayerWaterTrigger in _water_triggers:
if ( if (
@ -73,6 +76,7 @@ func update_world_context(
initial_spawn_transform: Transform3D, initial_spawn_transform: Transform3D,
water_triggers: Array[PlayerWaterTrigger], water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint], safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void: ) -> void:
for water_trigger: PlayerWaterTrigger in _water_triggers: for water_trigger: PlayerWaterTrigger in _water_triggers:
if ( if (
@ -88,6 +92,7 @@ func update_world_context(
_initial_spawn_transform = initial_spawn_transform _initial_spawn_transform = initial_spawn_transform
_water_triggers = water_triggers _water_triggers = water_triggers
_safe_points = safe_points _safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
for water_trigger: PlayerWaterTrigger in _water_triggers: for water_trigger: PlayerWaterTrigger in _water_triggers:
if ( if (
water_trigger != null water_trigger != null
@ -183,17 +188,7 @@ func _on_fade_transition_completed(
func _respawn_player() -> void: func _respawn_player() -> void:
var target_position: Vector3 = _initial_spawn_transform.origin var target_position := _resolve_recovery_position()
var nearest_distance: float = INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
_entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
target_position.y += respawn_height_offset target_position.y += respawn_height_offset
var respawn_transform: Transform3D = _player.global_transform var respawn_transform: Transform3D = _player.global_transform
respawn_transform.basis = _recovery_root_basis respawn_transform.basis = _recovery_root_basis
@ -204,6 +199,23 @@ func _respawn_player() -> void:
local_respawn_completed.emit(_entry_position) local_respawn_completed.emit(_entry_position)
func _resolve_recovery_position() -> Vector3:
if _recovery_position_resolver.is_valid():
var resolved: Variant = _recovery_position_resolver.call(_entry_position)
if resolved is Vector3 and (resolved as Vector3).is_finite():
return resolved as Vector3
var target_position := _initial_spawn_transform.origin
var nearest_distance := INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(_entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func _finish_recovery() -> void: func _finish_recovery() -> void:
_player.restore_gameplay_orientation_after_recovery() _player.restore_gameplay_orientation_after_recovery()
_player.set_water_recovery_active(false) _player.set_water_recovery_active(false)