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

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