straywild/homes/home_decor_controller.gd

499 lines
15 KiB
GDScript

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