straywild/homes/player_home_state.gd

786 lines
21 KiB
GDScript

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)]