Add portable RV homes and decor prototype

This commit is contained in:
Alexander Sellite 2026-08-30 21:37:26 -04:00
parent 81decf2b71
commit fe099f47dd
56 changed files with 5459 additions and 112 deletions

View file

@ -41,8 +41,6 @@ const FishingSurfaceSampleType = preload(
const FishingSurfaceResolverType = preload(
"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 WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd"
@ -134,6 +132,7 @@ const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate"
var state: FishingState = FishingState.READY
var _external_input_blocked: bool = false
var _gameplay_input_enabled: bool = false
var _fishing_environment_enabled: bool = true
var _local_menu_input_owners: Dictionary[StringName, bool] = {}
var _local_player: PlayerType
var _local_inventory: FishInventoryType
@ -399,6 +398,22 @@ func set_gameplay_input_enabled(enabled: bool) -> void:
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:
var local_state_was_active: bool = (
state != FishingState.READY or _active_player != null
@ -533,6 +548,7 @@ func _process(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void:
if (
not _gameplay_input_enabled
or not _fishing_environment_enabled
or _external_input_blocked
or not _local_menu_input_owners.is_empty()
):
@ -545,51 +561,23 @@ func _unhandled_input(event: InputEvent) -> void:
alternate_reel and state == FishingState.FIGHTING
):
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
# like a mouse button: one barrier hit per physical press, while the normal
# held-input path continues driving free reeling between barriers.
if event is InputEventKey and event.echo:
get_viewport().set_input_as_handled()
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():
match state:
FishingState.READY:
if not _new_cast_press_armed:
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 (
_is_inventory_full()
):
@ -661,6 +649,34 @@ func _unhandled_input(event: InputEvent) -> void:
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:
return (
Input.is_action_pressed("fish_primary")

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,457 @@
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 _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
):
_finish_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
if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
_finish_drag()
return
if not _drag_target_valid:
return
if not _can_decorate():
_cancel_drag()
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:
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:
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
var forward: Vector3 = -_player.global_basis.z
forward.y = 0.0
forward = forward.normalized()
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 instance_id: String = _network_home.place_local_decor(
decor_id, local_position, _player.global_rotation.y, _mode
)
if instance_id.is_empty():
_toolbar.report_status("make room in front of you to place this decor")
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
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.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_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

1183
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
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:
var item: ItemDataType = _resolve_available_item(item_id)
if item == null or quantity <= 0:

View file

@ -314,6 +314,25 @@ func get_fish_slots() -> Array[StringName]:
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(
slots: Array[StringName],
selected_slot: int,

View file

@ -37,6 +37,7 @@ var _fish_inventory: FishInventoryType
var _item_catalog: ItemCatalogType
var _storage_capacity: PlayerCoolerCapacityType
var _placements: Dictionary[String, Dictionary] = {}
var _prepared_item_placements: Dictionary[String, Dictionary] = {}
var _reconciling: bool = false
var _backpack_level: int = 0
@ -72,7 +73,44 @@ func can_accept_item(item_id: StringName) -> bool:
if not is_managed_item(item_id):
return true
var key := item_key(item_id)
return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0
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:
@ -332,6 +370,7 @@ func restore_from_save_data(data: Dictionary) -> bool:
"container": container,
"slot": slot,
}
_prepared_item_placements.clear()
_placements = restored
_reconcile(true)
return true
@ -340,6 +379,7 @@ func restore_from_save_data(data: Dictionary) -> bool:
func reset_to_defaults() -> void:
var capacity_changed := _backpack_level != 0
_backpack_level = 0
_prepared_item_placements.clear()
_placements.clear()
_reconcile(true)
if capacity_changed:
@ -402,8 +442,17 @@ func _reconcile(force_signal: bool) -> void:
for key: String in expected:
if _placements.has(key):
continue
var prepared: Dictionary = _prepared_item_placements.get(key, {})
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:
target_container = InventoryContainer.STORAGE
target_slot = _first_free_slot(target_container)
@ -484,6 +533,37 @@ func _first_free_slot(container: InventoryContainer) -> int:
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:
for key: String in _placements:
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="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/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/basic_cube.tres" id="42_basic_cube"]
[resource]
script = ExtResource("1_script")
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), 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(
"res://world/fishing_shop_interaction.gd"
)
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
@ -70,6 +73,7 @@ const NetworkFishingServiceType = preload(
"res://network/network_fishing_service.gd"
)
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const NetworkSaleServiceType = preload(
"res://network/network_sale_service.gd"
)
@ -123,6 +127,14 @@ const GatheringControllerType = preload(
const NetworkWorldSpawnServiceType = preload(
"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_PATH: String = (
@ -153,6 +165,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
@onready var _game_ui: GameUIType = %GameUI
@onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery
@onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager
@onready var _home_state: PlayerHomeStateType = %PlayerHomeState
@onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager
@onready var _controller_mapping_manager: ControllerMappingManagerType = (
%ControllerMappingManager
@ -212,6 +225,10 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
)
@onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService
@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 = (
%NetworkItemUseService
)
@ -234,6 +251,7 @@ const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
@onready var _players_root: Node3D = $Players
@onready var _surface_drawings_root: Node3D = $SurfaceDrawings
@onready var _world_gatherables_root: Node3D = $WorldGatherables
@onready var _home_world: HomeWorldServiceType = %HomeWorldService
@onready var _title_background: ColorRect = %TitleBackground
@onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop
@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 _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
@ -740,6 +759,7 @@ func _initialize_application(dedicated: bool) -> void:
_player.inventory_layout,
)
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_storage_interaction = _test_world.get_player_storage()
if not dedicated:
_shop_interaction.setup_local_player(_player)
@ -749,6 +769,10 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.set_shop_npc_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.local_player_range_changed.connect(
_on_storage_range_changed
@ -770,6 +794,23 @@ func _initialize_application(dedicated: bool) -> void:
_world_weather,
_player_jobs,
_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)
_save_manager.set_autosave_enabled(false)
@ -903,6 +944,9 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager,
_asset_reservations,
_player.inventory_layout,
_home_state,
_network_home,
_decor_shop_interaction,
)
_fishing_spot.setup(
_player,
@ -942,11 +986,13 @@ func _initialize_application(dedicated: bool) -> void:
_player.fishing_upgrades,
_shop_interaction,
_storage_interaction,
_decor_shop_interaction,
_player.item_effects,
_player.cooler_capacity,
_network_session,
_network_sale,
_network_shop,
_home_state,
_network_chat,
_network_profile,
_player_spawn_service,
@ -964,6 +1010,21 @@ func _initialize_application(dedicated: bool) -> void:
_test_world.get_world_environment(),
_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(
_data_root,
_identity_backups,
@ -1475,6 +1536,9 @@ func _input(event: InputEvent) -> void:
if _game_ui.get_player_storage().consume_escape():
get_viewport().set_input_as_handled()
return
if _game_ui.get_decor_shop().consume_escape():
get_viewport().set_input_as_handled()
return
if fishing_shop.consume_escape():
get_viewport().set_input_as_handled()
return
@ -1573,19 +1637,50 @@ func _is_pause_open_request(event: InputEvent) -> bool:
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 (
not _gameplay_started
or not event.is_action_pressed("interact")
or (event is InputEventKey and event.echo)
):
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 (
_storage_interaction != null
and _storage_interaction.is_local_player_in_range()
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
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()
return
if (
@ -1608,25 +1703,28 @@ func _process(delta: float) -> void:
)
_print_dedicated_metrics()
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 := (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null
else Vector3.ZERO
_decor_shop_interaction.get_prompt_anchor_position()
if show_decor_shop_prompt
else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
)
)
_game_ui.set_shop_prompt_visible(
show_shop_prompt,
shop_prompt_anchor,
)
var show_storage_prompt := _can_show_storage_prompt()
var storage_prompt_anchor := (
_storage_interaction.get_prompt_anchor_position()
if _storage_interaction != null
else Vector3.ZERO
)
var home_prompt := _get_home_prompt()
_game_ui.set_storage_prompt_visible(
show_storage_prompt,
storage_prompt_anchor,
not home_prompt.is_empty(),
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()
)
@ -2421,6 +2519,7 @@ func _apply_world(
if _application_initialized:
_refresh_active_world_bindings()
_network_world_spawns.refresh_world_context()
_network_home.refresh_world_presentations()
if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context(
spawn_transform,
@ -2450,10 +2549,19 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction.local_player_range_changed.disconnect(
_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()
_decor_shop_interaction = _test_world.get_decor_shop()
_storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction)
_network_shop.set_decor_shop_interaction(_decor_shop_interaction)
if _dedicated_runtime:
return
if _shop_interaction != null:
@ -2466,9 +2574,15 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction.local_player_range_changed.connect(
_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(
_shop_interaction,
_storage_interaction,
_decor_shop_interaction,
)
_on_shop_range_changed(
_shop_interaction != null
@ -2478,6 +2592,10 @@ func _refresh_active_world_bindings() -> void:
_storage_interaction != null
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:
@ -2491,6 +2609,7 @@ func _on_water_recovery_starting() -> void:
_game_ui.close_player_menu_for_water_recovery()
_game_ui.get_pause_menu().close_for_water_recovery()
_game_ui.get_fishing_shop().close_for_water_recovery()
_game_ui.get_decor_shop().close_for_water_recovery()
_game_ui.get_player_storage().close_for_water_recovery()
@ -2552,6 +2671,12 @@ func _on_active_hotbar_item_changed(
and item.is_available()
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(
item as FishingRodDataType if active_is_rod else null,
true,
@ -2562,6 +2687,7 @@ func _on_active_hotbar_item_changed(
)
_player.set_active_catching_net(active_is_catching_net)
_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)
_network_item_use.submit_local_equipped(item_id, active_is_rod or (
item != null and _player.bag.owns_item(item_id)
@ -2698,7 +2824,13 @@ func _exit_tree() -> 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:
_game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
@ -2707,7 +2839,50 @@ func _on_shop_range_changed(in_range: bool) -> void:
func _on_storage_range_changed(in_range: bool) -> void:
if not in_range:
_game_ui.get_player_storage().close_for_range_exit()
_game_ui.set_storage_prompt_visible(_can_show_storage_prompt())
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:
@ -2716,18 +2891,66 @@ func _can_show_shop_prompt() -> bool:
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
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 _fishing_spot.can_open_fishing_shop()
)
func _can_show_storage_prompt() -> bool:
return not _get_home_prompt().is_empty()
func _can_show_rv_storage_button() -> bool:
return (
_gameplay_started
and _storage_interaction != null
and _storage_interaction.is_local_player_in_range()
and not _game_ui.get_player_storage().visible
and not _game_ui.get_fishing_shop().visible
and not _network_home.get_local_home_owner_fingerprint().is_empty()
and _network_home.can_local_decorate()
and not _water_recovery.is_recovery_active()
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="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://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"]
shader = ExtResource("42_menu_pattern")
@ -259,6 +263,14 @@ script = ExtResource("22_network_sale")
unique_name_in_owner = true
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]
unique_name_in_owner = true
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="HomeWorldService" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("62_home_world")
[node name="GatheringController" type="Node3D" parent="."]
unique_name_in_owner = true
script = ExtResource("60_gathering_controller")
@ -338,6 +354,10 @@ script = ExtResource("8_recovery")
unique_name_in_owner = true
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]
unique_name_in_owner = true
script = ExtResource("10_settings")

View file

@ -0,0 +1,176 @@
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 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 _valid_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 _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

View file

@ -0,0 +1,956 @@
class_name NetworkHomeService
extends Node
const NetworkHomeProtocolType = preload(
"res://network/network_home_protocol.gd"
)
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
signal local_transition_finished(accepted: bool, message: String)
signal local_space_changed(space_id: StringName, owner_fingerprint: String)
signal local_manifest_rejected(message: String)
const MAX_MANIFESTS: int = 128
var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _local_home: PlayerHomeStateType
var _save_manager: PlayerSaveManager
var _world_service: HomeWorldService
var _bag: PlayerBag
var _inventory_layout: PlayerInventoryLayout
var _hotbar: PlayerHotbar
var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {}
var _fingerprint_by_peer: Dictionary[int, String] = {}
var _authorized_owned_counts: Dictionary[int, Dictionary] = {}
var _authorization_result_owner: Dictionary[String, int] = {}
var _next_interior_index: int = 0
var _suppress_local_submission: bool = false
var _publish_queued: bool = false
func setup(
session: NetworkSession,
spawn_service: PlayerSpawnService,
local_home: PlayerHomeStateType,
save_manager: PlayerSaveManager,
world_service: HomeWorldService,
bag: PlayerBag,
inventory_layout: PlayerInventoryLayout,
hotbar: PlayerHotbar,
) -> void:
_session = session
_spawn_service = spawn_service
_local_home = local_home
_save_manager = save_manager
_world_service = world_service
_bag = bag
_inventory_layout = inventory_layout
_hotbar = hotbar
if not _session.state_changed.is_connected(_on_session_state_changed):
_session.state_changed.connect(_on_session_state_changed)
if not _session.join_authenticated.is_connected(_on_join_authenticated):
_session.join_authenticated.connect(_on_join_authenticated)
if not _session.peer_authenticated.is_connected(_on_peer_authenticated):
_session.peer_authenticated.connect(_on_peer_authenticated)
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _local_home.changed.is_connected(_on_local_home_changed):
_local_home.changed.connect(_on_local_home_changed)
func get_local_space() -> StringName:
if _session == null or not _session.is_gameplay_session_active():
return NetworkHomeProtocolType.WORLD_SPACE
return _session.get_peer_space(_session.get_local_peer_id())
func get_local_home_owner_fingerprint() -> String:
var space_id: String = String(get_local_space())
return space_id.trim_prefix("rv:") if space_id.begins_with("rv:") else ""
func can_local_decorate() -> bool:
return (
get_local_home_owner_fingerprint()
== _session.get_local_identity_fingerprint()
)
func get_authoritative_owned_count(
peer_id: int,
decor_id: StringName,
) -> int:
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
if authorized.has(String(decor_id)):
return int(authorized[String(decor_id)])
var fingerprint: String = _fingerprint_for_peer(peer_id)
var manifest: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
if manifest.is_empty():
return -1
var home: Dictionary = manifest.get("home", {})
var owned: Dictionary = home.get("owned_decor", {})
return int(owned.get(String(decor_id), 0))
func place_local_decor(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> String:
if (
not can_local_decorate()
or _bag == null
or _bag.get_quantity(decor_id) <= 0
or _world_service == null
):
return ""
var owner_fingerprint: String = (
_session.get_local_identity_fingerprint()
)
var normalized: Dictionary = (
_local_home.get_nearest_valid_placement_transform(
decor_id,
position,
yaw_radians,
mode,
"",
Callable(
self, "_candidate_clear_of_players"
).bind(owner_fingerprint, decor_id),
)
)
if normalized.is_empty():
return ""
var instance_id: String = _local_home.place_decor(
decor_id,
normalized["position"],
float(normalized["yaw"]),
mode,
)
if instance_id.is_empty():
return ""
if not _bag.remove_item(decor_id, 1):
_local_home.remove_placement(instance_id)
return ""
_save_manager.save_if_dirty()
return instance_id
func remove_local_decor(instance_id: String) -> bool:
if (
not can_local_decorate()
or _bag == null
or _inventory_layout == null
or _hotbar == null
):
return false
var placement: Dictionary = _local_home.get_placement(instance_id)
if placement.is_empty():
return false
var decor_id := StringName(str(placement["decor_id"]))
var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id)
if target_hotbar_slot < 0:
target_hotbar_slot = _hotbar.find_first_empty_slot()
var prepared_hotbar_placement: bool = false
if (
target_hotbar_slot >= 0
and _inventory_layout.get_container(
PlayerInventoryLayout.EntryKind.ITEM,
decor_id,
) < 0
):
prepared_hotbar_placement = (
_inventory_layout.prepare_new_item_placement(
decor_id,
PlayerInventoryLayout.InventoryContainer.HOTBAR,
target_hotbar_slot,
)
)
var returned_to_inventory: bool = _bag.add_item(decor_id, 1)
if not returned_to_inventory:
if prepared_hotbar_placement:
_inventory_layout.cancel_prepared_item_placement(decor_id)
returned_to_inventory = _bag.add_item_to_storage(decor_id, 1)
if not returned_to_inventory:
return false
if target_hotbar_slot >= 0:
_hotbar.assign_item(target_hotbar_slot, decor_id)
_hotbar.select_slot(target_hotbar_slot)
if not _local_home.remove_placement(instance_id):
_bag.remove_item(decor_id, 1)
return false
_save_manager.save_if_dirty()
return true
func update_local_decor(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> bool:
var normalized: Dictionary = get_valid_local_decor_transform(
instance_id, position, yaw_radians, mode
)
if normalized.is_empty():
return false
if not _local_home.update_placement(
instance_id,
normalized["position"],
float(normalized["yaw"]),
mode,
):
return false
_save_manager.save_if_dirty()
return true
func get_valid_local_decor_transform(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> Dictionary:
if not can_local_decorate() or _world_service == null:
return {}
var placement: Dictionary = _local_home.get_placement(instance_id)
if placement.is_empty():
return {}
var decor_id := StringName(str(placement.get("decor_id", "")))
return _local_home.get_valid_update_transform(
instance_id,
position,
yaw_radians,
mode,
Callable(
self, "_candidate_clear_of_players"
).bind(
_session.get_local_identity_fingerprint(), decor_id
),
)
func _candidate_clear_of_players(
candidate: Dictionary,
owner_fingerprint: String,
decor_id: StringName,
) -> bool:
return (
_world_service != null
and _world_service.is_decor_placement_clear_of_players(
owner_fingerprint,
decor_id,
candidate["position"],
float(candidate["yaw"]),
)
)
func request_enter(owner_fingerprint: String) -> bool:
if (
_session == null
or not _session.is_gameplay_session_active()
or not _manifests_by_fingerprint.has(owner_fingerprint)
or get_local_space() != NetworkHomeProtocolType.WORLD_SPACE
):
return false
return _submit_transition(
NetworkHomeProtocolType.TransitionAction.ENTER,
owner_fingerprint,
)
func request_exit() -> bool:
var owner_fingerprint: String = get_local_home_owner_fingerprint()
if owner_fingerprint.is_empty():
return false
return _submit_transition(
NetworkHomeProtocolType.TransitionAction.EXIT,
owner_fingerprint,
)
func authorize_decor_purchase(
peer_id: int,
product_id: StringName,
resulting_count: int,
result_id: String,
) -> void:
if not _session.is_host() or peer_id <= 0 or result_id.is_empty():
return
var counts: Dictionary = _authorized_owned_counts.get(peer_id, {})
counts[String(product_id)] = resulting_count
_authorized_owned_counts[peer_id] = counts
_authorization_result_owner[result_id] = peer_id
func finalize_decor_purchase(
peer_id: int,
result_id: String,
applied: bool,
) -> void:
if _authorization_result_owner.get(result_id, 0) != peer_id:
return
_authorization_result_owner.erase(result_id)
if applied:
return
_authorized_owned_counts.erase(peer_id)
func refresh_world_presentations() -> void:
if _world_service == null:
return
_world_service.clear_presentations()
if not _world_service.is_world_ready():
return
if not _session.is_host():
for manifest: Dictionary in _manifests_by_fingerprint.values():
_world_service.apply_manifest(manifest)
return
var local_fingerprint: String = _session.get_local_identity_fingerprint()
if (
not _session.is_dedicated_host()
and not _manifests_by_fingerprint.has(local_fingerprint)
):
publish_local_home()
var fingerprints: Array[String] = []
fingerprints.assign(_manifests_by_fingerprint.keys())
fingerprints.sort()
var reserved: Array[Transform3D] = []
for fingerprint: String in fingerprints:
var manifest: Dictionary = _manifests_by_fingerprint[fingerprint]
var transform: Transform3D = _world_service.choose_exterior_transform(
int(manifest["owner_peer_id"]), reserved
)
reserved.append(transform)
manifest["exterior_transform"] = (
NetworkHomeProtocolType.transform_to_array(transform)
)
_manifests_by_fingerprint[fingerprint] = manifest
_broadcast_manifest(manifest)
for fingerprint: String in fingerprints:
var manifest: Dictionary = _manifests_by_fingerprint[fingerprint]
_spawn_peer_at_exterior(
int(manifest["owner_peer_id"]), fingerprint
)
func publish_local_home() -> bool:
if (
_session == null
or not _session.is_gameplay_session_active()
or _session.is_dedicated_host()
or _local_home == null
):
return false
if _session.is_host() and (
_world_service == null or not _world_service.is_world_ready()
):
return false
var home_data: Dictionary = _local_home.to_save_data()
var request: Dictionary = {
"request_id": _new_id("home"),
"session_id": _session.get_session_id(),
"owner_fingerprint": _session.get_local_identity_fingerprint(),
"home": home_data,
"digest": NetworkHomeProtocolType.manifest_digest(home_data),
}
request["sender_signature"] = _session.sign_local_action(
"portable_home_manifest",
NetworkHomeProtocolType.manifest_signature_fields(request),
)
if _session.is_host():
_process_manifest_request(_session.get_local_peer_id(), request)
else:
submit_home_manifest.rpc_id(1, request)
return true
@rpc(
"any_peer", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func submit_home_manifest(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
_session == null
or not _session.is_host()
or not _session.is_authenticated_peer(sender_id)
):
return
_process_manifest_request(sender_id, data)
func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
if (
not NetworkHomeProtocolType.validate_manifest_request(data)
or str(data["session_id"]) != _session.get_session_id()
):
_reject_manifest(peer_id, "The RV update was invalid.")
return
var record := _session.get_peer_record(peer_id)
var fingerprint: String = str(data["owner_fingerprint"])
if (
record == null
or record.identity_fingerprint != fingerprint
or not _session.verify_peer_action(
peer_id,
"portable_home_manifest",
NetworkHomeProtocolType.manifest_signature_fields(data),
data["sender_signature"],
)
):
_reject_manifest(peer_id, "The RV owner could not be verified.")
return
var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
)
var prior: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
var is_first_manifest: bool = prior.is_empty()
if not _valid_manifest_revision(peer_id, prior, home_data):
_reject_manifest(peer_id, "The RV update was rejected by the host.")
return
if not _moved_decor_clear_of_players(fingerprint, prior, home_data):
_reject_manifest(peer_id, "A player is in the way of that decor.")
return
var manifest: Dictionary
if prior.is_empty():
if _manifests_by_fingerprint.size() >= MAX_MANIFESTS:
_reject_manifest(peer_id, "This room cannot place another RV.")
return
var reserved: Array[Transform3D] = []
for existing: Dictionary in _manifests_by_fingerprint.values():
reserved.append(NetworkHomeProtocolType.array_to_transform(
existing["exterior_transform"]
))
var exterior_transform: Transform3D = (
_world_service.choose_exterior_transform(peer_id, reserved)
)
manifest = {
"owner_peer_id": peer_id,
"owner_fingerprint": fingerprint,
"home": home_data,
"exterior_transform": (
NetworkHomeProtocolType.transform_to_array(exterior_transform)
),
"interior_index": _next_interior_index,
}
_next_interior_index += 1
else:
manifest = prior.duplicate(true)
manifest["home"] = home_data
manifest["owner_peer_id"] = peer_id
_manifests_by_fingerprint[fingerprint] = manifest
_fingerprint_by_peer[peer_id] = fingerprint
_consume_owned_authorizations(peer_id, home_data)
_broadcast_manifest(manifest)
if is_first_manifest:
_spawn_peer_at_exterior(peer_id, fingerprint)
func _valid_manifest_revision(
peer_id: int,
prior_manifest: Dictionary,
home_data: Dictionary,
) -> bool:
if prior_manifest.is_empty():
# A portable RV is owned by the player's signed local save. A new host has
# no prior room ledger with which to compare its first manifest.
return true
var prior_home: Dictionary = prior_manifest["home"]
if (
int(home_data["revision"]) <= int(prior_home["revision"])
or int(home_data["tier"]) != int(prior_home["tier"])
or str(home_data["exterior_id"]) != str(prior_home["exterior_id"])
):
return false
var prior_owned: Dictionary = prior_home["owned_decor"]
var next_owned: Dictionary = home_data["owned_decor"]
var all_ids: Dictionary[String, bool] = {}
for value: Variant in prior_owned.keys():
all_ids[str(value)] = true
for value: Variant in next_owned.keys():
all_ids[str(value)] = true
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
for product_id: String in all_ids:
var prior_count: int = int(prior_owned.get(product_id, 0))
var next_count: int = int(next_owned.get(product_id, 0))
if prior_count == next_count:
continue
if next_count < prior_count or int(authorized.get(product_id, -1)) != next_count:
return false
return true
func _moved_decor_clear_of_players(
owner_fingerprint: String,
prior_manifest: Dictionary,
home_data: Dictionary,
) -> bool:
if _world_service == null:
return false
var prior_by_instance: Dictionary[String, Dictionary] = {}
if not prior_manifest.is_empty():
var prior_home: Dictionary = prior_manifest.get("home", {})
for value: Variant in prior_home.get("placements", []):
if typeof(value) != TYPE_DICTIONARY:
continue
var prior_placement: Dictionary = value
prior_by_instance[str(
prior_placement.get("instance_id", "")
)] = prior_placement
for value: Variant in home_data.get("placements", []):
if typeof(value) != TYPE_DICTIONARY:
return false
var placement: Dictionary = value
var instance_id: String = str(placement.get("instance_id", ""))
var prior_placement: Dictionary = prior_by_instance.get(
instance_id, {}
)
if (
not prior_placement.is_empty()
and _same_decor_transform(prior_placement, placement)
):
continue
var decor_id := StringName(str(placement.get("decor_id", "")))
var position: Vector3 = _placement_position(placement)
if (
not position.is_finite()
or not _world_service.is_decor_placement_clear_of_players(
owner_fingerprint,
decor_id,
position,
float(placement.get("yaw", 0.0)),
)
):
return false
return true
static func _same_decor_transform(
first: Dictionary,
second: Dictionary,
) -> bool:
if str(first.get("decor_id", "")) != str(second.get("decor_id", "")):
return false
var first_position: Vector3 = _placement_position(first)
var second_position: Vector3 = _placement_position(second)
return (
first_position.is_equal_approx(second_position)
and is_equal_approx(
float(first.get("yaw", 0.0)),
float(second.get("yaw", 0.0)),
)
)
static func _placement_position(placement: Dictionary) -> Vector3:
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 _consume_owned_authorizations(peer_id: int, home_data: Dictionary) -> void:
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
if authorized.is_empty():
return
var owned: Dictionary = home_data["owned_decor"]
for product_id: String in authorized.keys():
if int(owned.get(product_id, -1)) == int(authorized[product_id]):
authorized.erase(product_id)
if authorized.is_empty():
_authorized_owned_counts.erase(peer_id)
else:
_authorized_owned_counts[peer_id] = authorized
func _broadcast_manifest(manifest: Dictionary) -> void:
if _world_service != null and _world_service.is_world_ready():
_world_service.apply_manifest(manifest)
for peer_id: int in _session.get_authenticated_peer_ids():
if peer_id == 1:
continue
receive_home_manifest.rpc_id(peer_id, manifest)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_manifest(data: Dictionary) -> void:
if not NetworkHomeProtocolType.validate_manifest_broadcast(data):
return
var fingerprint: String = str(data["owner_fingerprint"])
_manifests_by_fingerprint[fingerprint] = data.duplicate(true)
_fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint
if _world_service != null and _world_service.is_world_ready():
_world_service.apply_manifest(data)
func _reject_manifest(peer_id: int, message: String) -> void:
var fingerprint: String = _fingerprint_for_peer(peer_id)
var canonical: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
if peer_id == _session.get_local_peer_id():
_apply_manifest_rejection(message, canonical)
else:
receive_home_manifest_rejection.rpc_id(peer_id, message, canonical)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_manifest_rejection(
message: String,
canonical: Dictionary,
) -> void:
_apply_manifest_rejection(message, canonical)
func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void:
if (
canonical.is_empty()
or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical)
):
local_manifest_rejected.emit(message)
return
_suppress_local_submission = true
_local_home.restore_from_save_data(canonical["home"])
_suppress_local_submission = false
_save_manager.save_if_dirty()
receive_home_manifest(canonical)
local_manifest_rejected.emit(message)
func _submit_transition(action: int, owner_fingerprint: String) -> bool:
var request: Dictionary = {
"request_id": _new_id("home_transition"),
"session_id": _session.get_session_id(),
"action": action,
"owner_fingerprint": owner_fingerprint,
}
request["sender_signature"] = _session.sign_local_action(
"portable_home_transition",
NetworkHomeProtocolType.transition_signature_fields(request),
)
if _session.is_host():
_process_transition_request(_session.get_local_peer_id(), request)
else:
submit_home_transition.rpc_id(1, request)
return true
@rpc(
"any_peer", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func submit_home_transition(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not _session.is_host()
or not _session.is_authenticated_peer(sender_id)
):
return
_process_transition_request(sender_id, data)
func _process_transition_request(peer_id: int, data: Dictionary) -> void:
if (
not NetworkHomeProtocolType.validate_transition_request(data)
or str(data["session_id"]) != _session.get_session_id()
or not _session.verify_peer_action(
peer_id,
"portable_home_transition",
NetworkHomeProtocolType.transition_signature_fields(data),
data["sender_signature"],
)
):
_send_transition_rejection(peer_id, "The RV transition was invalid.")
return
var owner_fingerprint: String = str(data["owner_fingerprint"])
var action: int = int(data["action"])
var avatar: Player = _spawn_service.get_avatar(peer_id)
var manifest: Dictionary = _manifests_by_fingerprint.get(
owner_fingerprint, {}
)
if avatar == null or manifest.is_empty():
_send_transition_rejection(peer_id, "That RV is unavailable.")
return
if action == NetworkHomeProtocolType.TransitionAction.ENTER:
var record := _session.get_peer_record(peer_id)
var owner_is_local: bool = (
record != null and record.identity_fingerprint == owner_fingerprint
)
var privacy: int = int((manifest["home"] as Dictionary)["privacy"])
if (
(not owner_is_local and privacy != PlayerHomeStateType.Privacy.OPEN)
or _session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.WORLD_SPACE
or not _world_service.is_avatar_near_exterior(
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "You cannot enter that RV.")
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.make_space_id(owner_fingerprint),
owner_fingerprint,
_world_service.get_interior_spawn_transform(
owner_fingerprint, peer_id
),
)
return
if (
_session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.make_space_id(owner_fingerprint)
or not _world_service.is_avatar_near_interior_exit(
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "Move closer to the RV door.")
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.WORLD_SPACE,
owner_fingerprint,
_world_service.get_exterior_spawn_transform(owner_fingerprint),
)
func _spawn_peer_at_exterior(
peer_id: int,
owner_fingerprint: String,
) -> void:
if (
not _session.is_host()
or _session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.WORLD_SPACE
or _spawn_service.get_avatar(peer_id) == null
):
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.WORLD_SPACE,
owner_fingerprint,
_world_service.get_exterior_spawn_transform(owner_fingerprint),
)
func _apply_host_transition(
peer_id: int,
space_id: StringName,
owner_fingerprint: String,
transform: Transform3D,
) -> void:
var result: Dictionary = {
"peer_id": peer_id,
"space_id": String(space_id),
"owner_fingerprint": owner_fingerprint,
"transform": NetworkHomeProtocolType.transform_to_array(transform),
}
_apply_transition_result(result)
for recipient_id: int in _session.get_authenticated_peer_ids():
if recipient_id == 1:
continue
receive_home_transition.rpc_id(recipient_id, result)
_session.publish_authoritative_teleport(peer_id)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_transition(data: Dictionary) -> void:
if (
typeof(data.get("peer_id")) != TYPE_INT
or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME]
or not NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
):
return
var transform: Transform3D = NetworkHomeProtocolType.array_to_transform(
data.get("transform")
)
if transform == Transform3D():
return
_apply_transition_result(data)
func _apply_transition_result(data: Dictionary) -> void:
var peer_id: int = int(data["peer_id"])
var space_id := StringName(str(data["space_id"]))
if not _session.set_peer_space(peer_id, space_id):
return
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.global_transform = NetworkHomeProtocolType.array_to_transform(
data["transform"]
)
avatar.velocity = Vector3.ZERO
if peer_id == _session.get_local_peer_id():
_world_service.set_local_space(space_id)
local_space_changed.emit(
space_id, str(data["owner_fingerprint"])
)
local_transition_finished.emit(true, "")
_refresh_peer_visibility()
func _send_transition_rejection(peer_id: int, message: String) -> void:
if peer_id == _session.get_local_peer_id():
local_transition_finished.emit(false, message)
else:
receive_home_transition_rejection.rpc_id(peer_id, message)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_transition_rejection(message: String) -> void:
local_transition_finished.emit(
false, message.left(NetworkHomeProtocolType.MAX_MESSAGE_LENGTH)
)
func _refresh_peer_visibility() -> void:
var local_peer_id: int = _session.get_local_peer_id()
if local_peer_id <= 0:
return
for peer_id: int in _spawn_service.get_peer_ids():
if peer_id == local_peer_id:
continue
_spawn_service.set_peer_presentation_visible(
peer_id, _session.peers_share_space(local_peer_id, peer_id)
)
func _on_local_home_changed() -> void:
if _suppress_local_submission or _publish_queued:
return
_publish_queued = true
call_deferred("_publish_queued_home")
func _publish_queued_home() -> void:
_publish_queued = false
if _save_manager != null:
_save_manager.save_if_dirty()
publish_local_home()
func _on_join_authenticated() -> void:
publish_local_home()
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
if not _session.is_host():
return
for manifest: Dictionary in _manifests_by_fingerprint.values():
receive_home_manifest.rpc_id(peer_id, manifest)
for existing_peer_id: int in _session.get_authenticated_peer_ids():
if existing_peer_id == peer_id:
continue
var avatar: Player = _spawn_service.get_avatar(existing_peer_id)
if avatar == null:
continue
var space_id: StringName = _session.get_peer_space(existing_peer_id)
var owner_fingerprint: String = (
String(space_id).trim_prefix("rv:")
if String(space_id).begins_with("rv:")
else _fingerprint_for_peer(existing_peer_id)
)
if owner_fingerprint.is_empty():
continue
receive_home_transition.rpc_id(peer_id, {
"peer_id": existing_peer_id,
"space_id": String(space_id),
"owner_fingerprint": owner_fingerprint,
"transform": NetworkHomeProtocolType.transform_to_array(
avatar.global_transform
),
})
func _on_peer_removed(peer_id: int) -> void:
var fingerprint: String = _fingerprint_by_peer.get(peer_id, "")
_fingerprint_by_peer.erase(peer_id)
_authorized_owned_counts.erase(peer_id)
if fingerprint.is_empty():
return
if _session.is_host():
for occupant_id: int in _session.get_authenticated_peer_ids():
if (
occupant_id == peer_id
or _session.get_peer_space(occupant_id)
!= NetworkHomeProtocolType.make_space_id(fingerprint)
):
continue
_apply_host_transition(
occupant_id,
NetworkHomeProtocolType.WORLD_SPACE,
fingerprint,
_world_service.get_exterior_spawn_transform(fingerprint),
)
_manifests_by_fingerprint.erase(fingerprint)
_world_service.remove_presentation(fingerprint)
if _session.is_host():
for recipient_id: int in _session.get_authenticated_peer_ids():
if recipient_id != 1:
receive_home_removed.rpc_id(recipient_id, fingerprint)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_removed(owner_fingerprint: String) -> void:
if not NetworkIdentityCrypto.valid_fingerprint(owner_fingerprint):
return
_manifests_by_fingerprint.erase(owner_fingerprint)
_world_service.remove_presentation(owner_fingerprint)
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]:
if (
not _session.is_dedicated_host()
and _world_service != null
and _world_service.is_world_ready()
):
publish_local_home()
return
if state in [
NetworkSession.State.INACTIVE,
NetworkSession.State.DISCONNECTING,
NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST,
]:
_manifests_by_fingerprint.clear()
_fingerprint_by_peer.clear()
_authorized_owned_counts.clear()
_authorization_result_owner.clear()
_next_interior_index = 0
_world_service.clear_presentations()
_world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE)
local_space_changed.emit(NetworkHomeProtocolType.WORLD_SPACE, "")
func _fingerprint_for_peer(peer_id: int) -> String:
if _fingerprint_by_peer.has(peer_id):
return _fingerprint_by_peer[peer_id]
var record := _session.get_peer_record(peer_id)
return record.identity_fingerprint if record != null else ""
func _new_id(prefix: String) -> String:
return "%s:%s" % [
prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
]

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 FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const MAX_LEDGER_ENTRIES: int = 64
@ -394,6 +395,10 @@ func _apply_equipped(data: Dictionary) -> void:
item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and bool(data["owns_item"]),
)
avatar.set_active_decor_package(
DecorCatalogType.has_product(item_id)
and bool(data["owns_item"]),
)
equipped_state_changed.emit(
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 PROTOCOL_VERSION: int = 11
const PROTOCOL_VERSION: int = 12
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
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,
# 10 observer-only fishing summaries, 11 reliable movement animation state,
# 12 reliable fish showcase state, 13 reliable world-spawn events,
# 14 reliable artwork state, 15 world-spawn snapshots, and 16 reliable
# fishing lifecycle. Bulk world/art payloads must not head-of-line block held
# items, fishing, or animation presentation.
# 14 reliable artwork state, 15 world-spawn snapshots, 16 reliable fishing
# lifecycle, and 17 reliable portable-home state. Bulk world/art payloads must
# not head-of-line block held items, fishing, animation, or home transitions.
const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6
const ITEM_RELIABLE_CHANNEL: int = 7
@ -30,7 +30,8 @@ const SHOWCASE_RELIABLE_CHANNEL: int = 12
const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13
const DRAWING_RELIABLE_CHANNEL: int = 14
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 ART_SHOP_CAPABILITY: String = "art_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 MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2"
const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2"
const HOME_CAPABILITY: String = "portable_home_v1"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
@ -202,6 +204,7 @@ static func make_client_hello(
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -342,6 +345,7 @@ static func make_server_hello(
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -141,6 +141,7 @@ var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray()
var _peer_space_ids: Dictionary[int, StringName] = {}
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
@ -334,6 +335,7 @@ func _register_player_host() -> void:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -661,6 +663,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -685,6 +688,21 @@ func get_authenticated_peer_ids() -> Array[int]:
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 get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1:
return 0
@ -1030,6 +1048,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
@ -1313,6 +1332,7 @@ func submit_client_hello(data: Dictionary) -> void:
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]:
if required_capability not in client_capabilities:
_reject_peer(
@ -1546,6 +1566,7 @@ func receive_server_hello(data: Dictionary) -> void:
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]:
if required_capability not in _server_capabilities:
_teardown_peer()
@ -1578,6 +1599,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
@ -1623,6 +1645,7 @@ func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id)
@ -2247,6 +2270,8 @@ func _broadcast_movement_snapshots() -> void:
continue
var snapshots: Array = []
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)
if subject_avatar == null:
continue
@ -2315,7 +2340,7 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL,
)
var updates: Array = []
var updates_by_subject: Dictionary[int, Array] = {}
for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null:
@ -2333,12 +2358,18 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty():
updates.append(encoded)
if updates.is_empty():
updates_by_subject[subject_id] = encoded
if updates_by_subject.is_empty():
return
for recipient_id: int in peer_ids:
if recipient_id == 1:
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)
_movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size()
@ -2888,6 +2919,7 @@ func _teardown_peer() -> void:
_pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray()
_peer_space_ids.clear()
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()

View file

@ -4,6 +4,7 @@ extends RefCounted
const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_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 MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
@ -18,6 +19,7 @@ enum ProductCategory {
ART_KIT,
ART_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 OwnedItemType = preload("res://items/owned_item.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 BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
@ -31,6 +33,7 @@ var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _interaction: FishingShopInteraction
var _decor_interaction: DecorShopInteraction
var _wallet: PlayerWallet
var _bag: PlayerBag
var _item_catalog: ItemCatalog
@ -47,6 +50,8 @@ var _received_results: Dictionary[String, bool] = {}
var _applied_results: Dictionary[String, bool] = {}
var _pending_local_request: Dictionary = {}
var _reservations: PlayerAssetReservationService
var _home_state: PlayerHomeState
var _network_home: NetworkHomeService
func setup(
@ -63,6 +68,9 @@ func setup(
save_manager: PlayerSaveManager,
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
home_state: PlayerHomeState = null,
network_home: NetworkHomeService = null,
decor_interaction: DecorShopInteraction = null,
) -> void:
_session = session
_spawn_service = spawn_service
@ -77,6 +85,9 @@ func setup(
_save_manager = save_manager
_reservations = reservations
_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):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -87,6 +98,12 @@ func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_interaction = interaction
func set_decor_shop_interaction(
interaction: DecorShopInteraction,
) -> void:
_decor_interaction = interaction
func can_request_purchase() -> bool:
return (
_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:
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:
return _request_purchase(
ArtShopStockType.ART_KIT_ITEM_ID,
@ -269,11 +308,25 @@ func _request_purchase(
product_id, category, 0, 0,
)
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 shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
var request: Dictionary = {
"request_id": request_id,
"session_id": _session.get_session_id(),
"shop_id": str(SHOP_ID),
"shop_id": str(shop_id),
"product_id": str(product_id),
"category": category,
"quantity": quantity,
@ -345,16 +398,34 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
_rejected_result(data, "A purchase is already pending.")
)
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 requested_interaction: FishingShopInteraction = (
_decor_interaction
if requested_shop_id == DECOR_SHOP_ID
else _interaction
)
var message: String = (
"The shop is unavailable."
if avatar == null or _interaction == null
if avatar == null or requested_interaction == null
else "Move closer to the shop."
)
_record_and_send(peer_id, _rejected_result(data, message))
return
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
_record_and_send(peer_id, result)
@ -368,10 +439,24 @@ func _build_authoritative_result(
var quantity: int = request["quantity"]
var wallet_balance: int = request["wallet_balance"]
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 resulting_state: int = current_state
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."
elif quantity < 1:
rejection = "Purchase could not be completed."
@ -545,6 +630,19 @@ func _build_authoritative_result(
resulting_state = PlayerArtUnlocks.resulting_mask(
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):
rejection = "Insufficient funds."
if not rejection.is_empty():
@ -667,6 +765,9 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null else 0
)
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)
if not applied or not _save_manager.save_if_dirty():
_bag.replace_all_items(bag_snapshot)
@ -676,6 +777,8 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null:
_inventory_layout.restore_backpack_level(backpack_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)
_save_manager.save_if_dirty()
_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 _art_unlocks == null
or _save_manager == null
or _home_state == null
or _wallet.get_balance() != int(data["expected_wallet"])
or str(data["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
):
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 ""
@ -845,6 +963,12 @@ func _apply_local_product(data: Dictionary) -> bool:
return _art_unlocks.purchase_product(
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
@ -921,7 +1045,7 @@ func _handle_acknowledgement(
peer_id: int,
result_id: String,
request_id: String,
_applied: bool,
applied: bool,
message: String,
) -> void:
if (
@ -933,20 +1057,32 @@ func _handle_acknowledgement(
or _result_owners.get(result_id, 0) != peer_id
):
return
if _network_home != null:
_network_home.finalize_decor_purchase(
peer_id, result_id, applied
)
_acknowledged_results[result_id] = true
_bound_dictionary(_acknowledged_results)
if _pending_by_peer.get(peer_id, "") == request_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 (
_session == null
or not _session.is_host()
or not _session.is_gameplay_session_active()
or _spawn_service == null
or _interaction == null
or not is_instance_valid(_interaction)
or interaction == null
or not is_instance_valid(interaction)
):
return false
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
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

@ -427,6 +427,9 @@ class ShowcaseCameraSnapshot:
@export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2
@export var invert_camera_y: 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(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.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_art_kit_display: Node3D
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 _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(
"HeldArtKitDisplay/HeldArtKitSprite"
) as Sprite3D
_held_decor_package_display = _build_decor_package_display(attachment)
_catch_display = attachment.get_node("CatchDisplay") as Node3D
_catch_attachment_offset = _catch_display.position
_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 _character_animation_player.has_animation(animation_action_id)
)
var held_show_item_visible: bool = (
_held_fish_visible or _held_art_kit_visible
)
var held_show_item_visible: bool = _has_held_show_item()
var requested_animation: Array[StringName] = []
if _pocket_visual_active:
requested_animation = [_pocket_visual_animation]
@ -1766,7 +1770,11 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_zoom_in: bool = (
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")
)
var controller_zoom_in: bool = (
@ -1775,7 +1783,11 @@ func _unhandled_input(event: InputEvent) -> void:
)
var mouse_zoom_out: bool = (
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")
)
var controller_zoom_out: bool = (
@ -1963,7 +1975,11 @@ func get_active_gameplay_camera() -> Camera3D:
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:
@ -3021,6 +3037,49 @@ func apply_camera_settings(
_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:
return _camera_input_enabled
@ -3147,6 +3206,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:
var next_id: StringName = rod.item_id if rod != null else StringName()
if next_id == _active_fishing_rod_id:
@ -3319,7 +3395,52 @@ func set_held_fish(
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:

View file

@ -34,8 +34,9 @@ const WorldWeatherServiceType = preload(
const WorldLayoutType = preload("res://world/world_layout.gd")
const PlayerJobServiceType = preload("res://jobs/player_job_service.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 ARCHIVE_EXTENSION := ".nfsave"
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
@ -76,6 +77,7 @@ class LoadSnapshot:
WorldWeatherServiceType.SUNNY_DURATION_RANGE.x
)
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
@ -96,6 +98,7 @@ var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType
var _player: PlayerType
var _home_state: PlayerHomeStateType
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
@ -175,6 +178,7 @@ func setup(
world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType,
player: PlayerType,
home_state: PlayerHomeStateType,
) -> void:
_inventory = inventory
_collection_log = collection_log
@ -192,6 +196,7 @@ func setup(
_world_weather = world_weather
_jobs = jobs
_player = player
_home_state = home_state
_is_configured = (
_inventory != null
and _collection_log != null
@ -209,6 +214,7 @@ func setup(
and _world_weather != null
and _jobs != null
and _player != null
and _home_state != null
)
if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.")
@ -255,6 +261,8 @@ func setup(
_player.active_bait_changed.connect(_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)
if not _home_state.changed.is_connected(_mark_dirty):
_home_state.changed.connect(_mark_dirty)
func load_player_data() -> bool:
@ -360,6 +368,9 @@ func load_player_data() -> bool:
snapshot.world_weather_seconds_remaining,
)
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
if (
not inventory_restored
@ -376,13 +387,14 @@ func load_player_data() -> bool:
or not world_time_restored
or not world_weather_restored
or not jobs_restored
or not home_restored
):
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%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,
@ -399,6 +411,7 @@ func load_player_data() -> bool:
world_time_restored,
world_weather_restored,
jobs_restored,
home_restored,
]
)
return false
@ -899,6 +912,7 @@ func _build_save_dictionary() -> Dictionary:
),
},
"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("experience")) != TYPE_DICTIONARY
or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
or typeof(save_data.get("home")) != TYPE_DICTIONARY
):
return null
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 experience_data: Dictionary = save_data["experience"]
var jobs_data: Dictionary = save_data["jobs"]
var home_data: Dictionary = save_data["home"]
var world_data: Dictionary = {}
if typeof(save_data.get("world")) == TYPE_DICTIONARY:
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):
return null
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"):
snapshot.world_time_hours = _read_world_time_hours(
world_data["time_hours"]
@ -1340,6 +1362,8 @@ func _migrate_save(
migrated = _migrate_version_8_to_9(migrated)
9:
migrated = _migrate_version_9_to_10(migrated)
10:
migrated = _migrate_version_10_to_11(migrated)
_:
return {}
if migrated.is_empty():
@ -1597,6 +1621,13 @@ func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
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:
if (
_is_restoring
@ -1866,6 +1897,7 @@ func _restore_defaults() -> void:
_world_layout = WorldLayoutType.GENERATED
_world_seed = DEFAULT_WORLD_SEED
_jobs.reset_to_defaults()
_home_state.reset_to_defaults()
_is_restoring = false
_is_dirty = false

View file

@ -19,9 +19,15 @@ func _run() -> void:
for _frame: int in 8:
await process_frame
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")))
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")
await physics_frame
await physics_frame
@ -94,11 +100,7 @@ func _run() -> void:
assert(keyboard_mapping.reset_mapping())
await process_frame
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(storage_prompt_message.text == "E open storage")
var remapped_interact := InputEventKey.new()
remapped_interact.physical_keycode = KEY_F
remapped_interact.pressed = true
@ -108,7 +110,6 @@ func _run() -> void:
))
await process_frame
assert(shop_prompt_key.text == "F")
assert(storage_prompt_message.text == "F open storage")
assert(keyboard_mapping.reset_mapping())
await process_frame
assert(toolbar.get_parent() == chat_ui.get_parent())

View file

@ -104,7 +104,10 @@ func _run() -> void:
var hotbar_data: Dictionary = parsed["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int(parsed["save_version"]) == 10)
assert(int(parsed["save_version"]) == 11)
assert(not PlayerHomeState.sanitize_save_data(
parsed.get("home", {})
).is_empty())
assert(
int(parsed["experience"]["total_experience"])
== 125

View file

@ -485,7 +485,7 @@ func _validate_version_four_migration() -> void:
version_four,
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(
is_equal_approx(
@ -509,4 +509,7 @@ func _validate_version_four_migration() -> void:
assert(
PlayerJobService.validate_save_data(migrated.get("jobs", {}))
)
assert(not PlayerHomeState.sanitize_save_data(
migrated.get("home", {})
).is_empty())
manager.queue_free()

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

@ -205,8 +205,11 @@ func _run() -> void:
)
assert(bool(decoded.get("ok", false)))
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(not PlayerHomeState.sanitize_save_data(
save_data.get("home", {})
).is_empty())
_validate_pause_session_switch(main, session)
main.queue_free()

View file

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

View file

@ -22,11 +22,24 @@ func _run() -> void:
var save_manager := main.get("_save_manager") as PlayerSaveManager
var player := main.get("_player") as Player
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))
save_manager.set_autosave_enabled(true)
assert(player.wallet.restore_balance(4321))
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())
var save_path: String = str(save_manager.get("_save_path"))
@ -38,7 +51,14 @@ func _run() -> void:
_assert_opaque(save_path)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
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(
"netfishing-container.nfsave"
)
@ -74,6 +94,8 @@ func _run() -> void:
assert(save_manager.load_player_data())
assert(player.wallet.get_balance() == 4321)
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 = (
ProgressionSaveCodec.read_local_save(save_path)["data"]

View file

@ -35,6 +35,41 @@ func _run() -> void:
hotbar.setup(bag, ItemCatalogResource, catches, layout)
assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36])
assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72])
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(layout.get_inventory_count() == 1)

323
ui/decor_shop.gd Normal file
View file

@ -0,0 +1,323 @@
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()
_prior_mouse_mode = Input.mouse_mode
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_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 _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

@ -20,6 +20,7 @@ const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.gd")
const NetworkSessionType = preload("res://network/network_session.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 PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
@ -27,7 +28,11 @@ const PlayerFishingUpgradesType = preload(
const ShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PlayerStorageType = preload("res://ui/player_storage.gd")
const HomeDecorToolbarType = preload("res://ui/home_decor_toolbar.gd")
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
@ -71,6 +76,9 @@ const MAIN_BUBBLE_PROFILE: BubbleMenuProfile = preload(
const ACTIVE_BAIT_EMPTY_ICON: Texture2D = preload(
"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 crisp_reset_focus_requested
@ -82,6 +90,7 @@ signal virtual_pointer_mode_changed(is_active: bool)
signal social_prompt_accepted
signal social_prompt_declined
signal data_root_changed
signal rv_storage_requested
const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse"
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 _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _decor_shop: DecorShopType = %DecorShop
@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_message: Label = %StoragePromptMessage
@onready var _shop_prompt: Control = %ShopPrompt
@ -195,6 +207,7 @@ var _chat_input_open: bool = false
var _player_menu_hotbar_visible: bool = false
var _main_shop_buyer: FishBuyerProfileType
var _shop_interaction: ShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _surface_drawing: NetworkSurfaceDrawingService
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_close: Callable
var _restore_chat_keyboard_after_fishing: bool = false
var _rv_storage_button_requested_visible: bool = false
func _ready() -> void:
_prioritize_surface_drawing_pointer_input()
_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.cancelled.connect(_decline_social_prompt)
_apply_active_bait_indicator_style()
_apply_rv_storage_button_style()
_refresh_active_bait_indicator()
_refresh_interaction_prompt_bindings()
# Reward feedback must remain above full-screen canonical menus. Keeping the
@ -346,11 +362,13 @@ func setup(
fishing_upgrades: PlayerFishingUpgradesType,
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType,
network_sale_service: NetworkSaleService,
network_shop_service: NetworkShopService,
home_state: PlayerHomeState,
network_chat_service: NetworkChatService,
network_profile: NetworkProfilePreferences,
spawn_service: PlayerSpawnService,
@ -494,6 +512,15 @@ func setup(
network_sale_service,
reservations,
)
_decor_shop.setup(
player,
wallet,
fishing_spot,
decor_shop_interaction,
bag,
home_state,
network_shop_service,
)
_player_storage.setup(
player,
fishing_spot,
@ -509,6 +536,8 @@ func setup(
)
_fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_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.shop_cooler_return_requested.connect(
_on_shop_cooler_return_requested
@ -518,6 +547,7 @@ func setup(
)
_main_shop_buyer = main_shop_buyer
_shop_interaction = shop_interaction
_decor_shop_interaction = decor_shop_interaction
_storage_interaction = storage_interaction
_surface_drawing = surface_drawing
if (
@ -540,11 +570,14 @@ func setup(
func set_world_interactions(
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
) -> void:
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_decor_shop_interaction = decor_shop_interaction
_fishing_shop.set_world_interaction(shop_interaction)
_player_storage.set_world_interaction(storage_interaction)
_decor_shop.set_world_interaction(decor_shop_interaction)
func _prioritize_surface_drawing_pointer_input() -> void:
@ -1655,6 +1688,34 @@ func _apply_active_bait_indicator_style() -> void:
_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:
_refresh_active_bait_indicator_visibility()
if not is_node_ready():
@ -1718,17 +1779,20 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
if not enabled:
_gameplay_hud_hidden = false
_refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
if enabled:
_try_start_shop_npc_speech()
_refresh_gameplay_hud_visibility()
_refresh_chat_availability()
if not enabled:
_home_decor_toolbar.set_active(false)
_end_virtual_mouse()
_close_radial_menus()
if _surface_drawing != null:
_surface_drawing.deactivate()
close_player_menu_for_session_end()
_fishing_shop.close_for_session_end()
_decor_shop.close_for_session_end()
_player_storage.close_for_session_end()
_fishing_panel.visible = false
_shop_prompt.hide()
@ -1749,6 +1813,7 @@ func set_gameplay_hud_hidden(hidden: bool) -> void:
_quick_radial_menu.set_hud_hidden(hidden)
_refresh_gameplay_hud_visibility()
_refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
_refresh_hotbar_visibility()
_refresh_chat_availability()
@ -1864,13 +1929,43 @@ func get_fishing_shop() -> FishingShopType:
return _fishing_shop
func get_decor_shop() -> DecorShopType:
return _decor_shop
func get_player_storage() -> PlayerStorageType:
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(
requested_visible: bool,
world_anchor: Vector3 = Vector3(0.0, INF, 0.0),
action_text: String = "open storage",
) -> void:
if not _storage_prompt.has_meta(&"styled"):
_storage_prompt.set_meta(&"styled", true)
@ -1879,6 +1974,7 @@ func set_storage_prompt_visible(
)
style.anti_aliasing = false
_storage_prompt.add_theme_stylebox_override("panel", style)
_storage_prompt_message.text = action_text
_storage_prompt.visible = (
requested_visible
and _gameplay_ui_enabled
@ -2762,6 +2858,7 @@ func _refresh_hotbar_visibility() -> void:
func _emit_interactive_pointer_ui_changed() -> void:
_refresh_active_bait_indicator_visibility()
_refresh_rv_storage_button_visibility()
interactive_pointer_ui_changed.emit(
_system_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="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="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/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"]
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")]
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")]
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"]
unique_name_in_owner = true
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 _drag_enabled: bool = false
var _swap_hotbar_camera_scroll: bool = false
var _rv_interior_camera_mode: bool = false
var _motion_elapsed: float = 0.0
var _compact_layout: 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
func set_rv_interior_camera_mode(enabled: bool) -> void:
_rv_interior_camera_mode = enabled
func set_drag_enabled(enabled: bool) -> void:
_drag_enabled = enabled
for slot: BubbleHotbarSlotType in _slots:
@ -308,7 +313,11 @@ func _unhandled_input(event: InputEvent) -> void:
if (
event is InputEventMouseButton
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:
_hotbar.cycle_selection(-1)

View file

@ -4,6 +4,7 @@ extends Control
const INPUT_OWNER: StringName = &"player_storage"
signal menu_visibility_changed(is_open: bool)
signal decorate_requested
var _player: Player
var _fishing_spot: FishingSpot
@ -14,6 +15,9 @@ var _storage_grid: GeneralInventoryGrid
var _inventory_count: Label
var _storage_count: Label
var _feedback: Label
var _title: Label
var _decorate_button: Button
var _home_access: bool = false
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
@ -75,15 +79,34 @@ func set_world_interaction(interaction: PlayerStorageInteraction) -> void:
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 (
visible
or _player == null
or _interaction == null
or not _interaction.is_local_player_in_range()
or _fishing_spot == null
or not _fishing_spot.can_open_fishing_shop()
or (
not home_access
and (
_interaction == null
or not _interaction.is_local_player_in_range()
)
)
):
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_camera_enabled = _player.is_camera_input_enabled()
_prior_mouse_mode = Input.mouse_mode
@ -112,10 +135,13 @@ func close_storage(restore_controls: bool = true) -> void:
_player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_enabled)
Input.mouse_mode = _prior_mouse_mode
_home_access = false
menu_visibility_changed.emit(false)
func close_for_range_exit() -> void:
if _home_access:
return
close_storage()
@ -248,12 +274,20 @@ func _build_ui() -> void:
var header := HBoxContainer.new()
layout.add_child(header)
var title := Label.new()
title.text = "private storage"
title.add_theme_font_size_override("font_size", 32)
title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
_title = Label.new()
_title.text = "private storage"
_title.add_theme_font_size_override("font_size", 32)
_title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY)
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(_title)
_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()
close.text = "×"
close.tooltip_text = "Close storage"
@ -285,6 +319,13 @@ func _build_ui() -> void:
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:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(550.0, 0.0)

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(
"res://world/player_storage_interaction.gd"
)
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const PrecipitationOcclusionType = preload(
"res://world/environment/precipitation_occlusion.gd"
)
@ -80,8 +83,12 @@ const PROCEDURAL_PROP_GROUPS: Array[StringName] = [
@onready var _player_storage: PlayerStorageInteractionType = (
$Interactables/PlayerStorageBox/InteractionArea
)
@onready var _decor_shop: DecorShopInteractionType = (
$Interactables/DecorShopWorld/InteractionArea
)
@onready var _shop_root: Node3D = %FishingShopWorld
@onready var _storage_root: Node3D = %PlayerStorageBox
@onready var _decor_shop_root: Node3D = %DecorShopWorld
@onready var _decorations: Node3D = %Decorations
@onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks
@onready var _diggable_beach: DiggableArea3D = %DiggableBeach
@ -148,6 +155,10 @@ func get_player_storage() -> PlayerStorageInteractionType:
return _player_storage
func get_decor_shop() -> DecorShopInteractionType:
return _decor_shop
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference
@ -428,6 +439,8 @@ func _place_spawn_amenities(center: Vector3) -> void:
_shop_root.rotation.y = PI
_storage_root.position = center + Vector3(-2.4, 0.0, 1.2)
_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:

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/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://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="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="14_decor_shop"]
[node name="GeneratedWorldRegion" type="Node3D"]
script = ExtResource("1_region")
@ -98,6 +99,9 @@ unique_name_in_owner = true
[node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("6_storage")]
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="."]
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
[node name="PlayerStorageBox" type="Node3D"]
process_mode = 4
visible = false
[node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.35, 0)
@ -44,6 +46,7 @@ collision_mask = 0
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"]
position = Vector3(0, 0.43, 0)
disabled = true
shape = SubResource("CollisionShape")
[node name="InteractionArea" type="Area3D" parent="."]
@ -54,6 +57,7 @@ collision_mask = 2
script = ExtResource("1_interaction")
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractionArea"]
disabled = true
shape = SubResource("InteractionShape")
[node name="PromptAnchor" type="Marker3D" parent="."]

View file

@ -8,6 +8,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const FOLIAGE_WIND_SHADER: Shader = preload(
"res://world/materials/foliage_wind.gdshader"
)
@ -49,6 +52,10 @@ var fishing_shop_path: NodePath = (
var player_storage_path: NodePath = (
^"Interactables/PlayerStorageBox/InteractionArea"
)
@export_node_path("Area3D")
var decor_shop_path: NodePath = (
^"Interactables/DecorShopWorld/InteractionArea"
)
@export_group("Terrain Collision")
# 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.
@ -84,6 +91,10 @@ func get_player_storage() -> 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:
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
func get_decor_shop() -> DecorShopInteraction:
return null
func get_player_storage() -> PlayerStorageInteraction:
return null

View file

@ -7,6 +7,9 @@ const FishingShopInteractionType = preload(
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const WorldLayoutType = preload("res://world/world_layout.gd")
const GENERATED_REGION_SCENE: PackedScene = preload(
"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 _light_performance_profile: bool = false
var _dedicated_simulation: bool = false
var _local_home_interior_presentation_active: bool = false
func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
@ -67,6 +71,10 @@ func get_player_storage() -> PlayerStorageInteractionType:
return _active_region.get_player_storage()
func get_decor_shop() -> DecorShopInteractionType:
return _active_region.get_decor_shop()
func get_player_spawn_transform() -> Transform3D:
return _active_region.get_player_spawn_transform()
@ -85,6 +93,12 @@ func set_light_performance_profile(enabled: bool) -> void:
_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:
_dedicated_simulation = enabled
if _active_region != null:
@ -272,6 +286,8 @@ func _replace_active_region(layout: StringName, seed: int) -> bool:
_active_region.set_light_performance_profile(_light_performance_profile)
if _dedicated_simulation:
_set_region_presentation_enabled(_active_region, false)
else:
_active_region.visible = not _local_home_interior_presentation_active
return true