1162 lines
33 KiB
GDScript
1162 lines
33 KiB
GDScript
class_name NetworkWorldSpawnService
|
|
extends Node
|
|
|
|
const FishCatchType = preload("res://fish/fish_catch.gd")
|
|
const FishDataType = preload("res://fish/fish_data.gd")
|
|
const FishExperienceType = preload("res://fish/fish_experience.gd")
|
|
const FishPoolType = preload("res://fish/fish_pool.gd")
|
|
const FishQualityType = preload("res://fish/fish_quality.gd")
|
|
const FishSelectorType = preload("res://fish/fish_selector.gd")
|
|
const GatherableCatalogType = preload("res://gathering/gatherable_catalog.gd")
|
|
const GatherableDataType = preload("res://gathering/gatherable_data.gd")
|
|
const WorldGatherableType = preload("res://gathering/world_gatherable.gd")
|
|
const TestWorldType = preload("res://world/test_world.gd")
|
|
|
|
signal local_capture_received(fish_catch: FishCatch)
|
|
signal local_interaction_finished(accepted: bool, message: String)
|
|
|
|
const SNAPSHOT_INTERVAL_SECONDS: float = 0.2
|
|
const CAPACITY_RESPONSE_TIMEOUT_SECONDS: float = 3.0
|
|
const MAX_REQUEST_ID_LENGTH: int = 128
|
|
const MAX_LEDGER_ENTRIES: int = 96
|
|
|
|
var _session: NetworkSession
|
|
var _spawn_service: PlayerSpawnService
|
|
var _world: TestWorldType
|
|
var _world_root: Node3D
|
|
var _catalog: GatherableCatalogType
|
|
var _fish_catalog: FishPoolType
|
|
var _local_inventory: FishInventory
|
|
var _local_collection: CollectionLog
|
|
var _local_capacity: PlayerCoolerCapacity
|
|
var _local_experience: PlayerExperience
|
|
var _save_manager: PlayerSaveManager
|
|
var _item_use: NetworkItemUseService
|
|
|
|
var _entities: Dictionary = {}
|
|
var _presentations: Dictionary = {}
|
|
var _entity_revisions: Dictionary = {}
|
|
var _surface_triangles: Dictionary = {}
|
|
var _surface_areas: Dictionary = {}
|
|
var _surface_total_areas: Dictionary = {}
|
|
var _respawns: Array[Dictionary] = []
|
|
var _next_respawn_by_type: Dictionary[StringName, float] = {}
|
|
var _charge_requests: Dictionary = {}
|
|
var _pending_captures: Dictionary = {}
|
|
var _received_results: Dictionary = {}
|
|
var _showcase_deadlines: Dictionary = {}
|
|
var _envelope_sequence: int = 0
|
|
var _snapshot_elapsed: float = 0.0
|
|
var _population_session_id: String = ""
|
|
var _rng := RandomNumberGenerator.new()
|
|
|
|
|
|
func setup(
|
|
session: NetworkSession,
|
|
spawn_service: PlayerSpawnService,
|
|
world: TestWorldType,
|
|
world_root: Node3D,
|
|
catalog: GatherableCatalogType,
|
|
fish_catalog: FishPoolType,
|
|
local_inventory: FishInventory,
|
|
local_collection: CollectionLog,
|
|
local_capacity: PlayerCoolerCapacity,
|
|
local_experience: PlayerExperience,
|
|
save_manager: PlayerSaveManager,
|
|
item_use: NetworkItemUseService,
|
|
) -> void:
|
|
_session = session
|
|
_spawn_service = spawn_service
|
|
_world = world
|
|
_world_root = world_root
|
|
_catalog = catalog
|
|
_fish_catalog = fish_catalog
|
|
_local_inventory = local_inventory
|
|
_local_collection = local_collection
|
|
_local_capacity = local_capacity
|
|
_local_experience = local_experience
|
|
_save_manager = save_manager
|
|
_item_use = item_use
|
|
_rng.randomize()
|
|
_session.peer_authenticated.connect(_on_peer_authenticated)
|
|
_session.peer_removed.connect(_on_peer_removed)
|
|
_session.state_changed.connect(_on_session_state_changed)
|
|
set_physics_process(true)
|
|
_begin_population_if_ready.call_deferred()
|
|
|
|
|
|
func begin_local_interaction() -> String:
|
|
if (
|
|
_session == null
|
|
or not _session.is_gameplay_session_active()
|
|
or (
|
|
not _session.is_host()
|
|
and not _session.supports_server_capability(
|
|
NetworkWorldSpawnProtocol.CAPABILITY
|
|
)
|
|
)
|
|
):
|
|
local_interaction_finished.emit(false, "Catching is unavailable.")
|
|
return ""
|
|
var request_id: String = _new_id("gather")
|
|
var data: Dictionary = {
|
|
"session_id": _session.get_session_id(),
|
|
"request_id": request_id,
|
|
}
|
|
if _session.is_host():
|
|
_handle_interaction_begin(_session.get_local_peer_id(), data)
|
|
else:
|
|
submit_interaction_begin.rpc_id(1, data)
|
|
return request_id
|
|
|
|
|
|
func finish_local_interaction(
|
|
request_id: String,
|
|
entity_id: String,
|
|
target_position: Vector3,
|
|
) -> void:
|
|
if request_id.is_empty() or not target_position.is_finite():
|
|
return
|
|
var data: Dictionary = {
|
|
"session_id": _session.get_session_id(),
|
|
"request_id": request_id,
|
|
"entity_id": entity_id,
|
|
"target_position": NetworkWorldSpawnProtocol.vector3_to_array(
|
|
target_position
|
|
),
|
|
}
|
|
if _session.is_host():
|
|
_handle_interaction_finish(_session.get_local_peer_id(), data)
|
|
else:
|
|
submit_interaction_finish.rpc_id(1, data)
|
|
|
|
|
|
func cancel_local_interaction(request_id: String) -> void:
|
|
if request_id.is_empty() or _session == null:
|
|
return
|
|
if _session.is_host():
|
|
_handle_interaction_cancel(_session.get_local_peer_id(), request_id)
|
|
else:
|
|
submit_interaction_cancel.rpc_id(1, request_id)
|
|
|
|
|
|
func find_entity_near(
|
|
position: Vector3,
|
|
radius: float,
|
|
) -> String:
|
|
var best_id: String = ""
|
|
var best_distance_squared: float = radius * radius
|
|
for entity_id: String in _entities:
|
|
var state: Dictionary = _entities[entity_id]
|
|
if bool(state.get("locked", false)):
|
|
continue
|
|
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
|
|
var distance_squared: float = Vector2(
|
|
entity_position.x - position.x,
|
|
entity_position.z - position.z,
|
|
).length_squared()
|
|
if distance_squared <= best_distance_squared:
|
|
best_distance_squared = distance_squared
|
|
best_id = entity_id
|
|
return best_id
|
|
|
|
|
|
func find_capture_target(
|
|
position: Vector3,
|
|
tool_id: StringName,
|
|
) -> String:
|
|
if tool_id.is_empty():
|
|
return ""
|
|
var best_id: String = ""
|
|
var best_distance_squared: float = INF
|
|
for entity_id: String in _entities:
|
|
var state: Dictionary = _entities[entity_id]
|
|
if bool(state.get("locked", false)):
|
|
continue
|
|
var entry := state.get("data") as GatherableDataType
|
|
if entry == null or entry.required_tool_id != tool_id:
|
|
continue
|
|
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
|
|
var distance_squared: float = Vector2(
|
|
entity_position.x - position.x,
|
|
entity_position.z - position.z,
|
|
).length_squared()
|
|
if (
|
|
distance_squared <= entry.capture_radius * entry.capture_radius
|
|
and distance_squared <= best_distance_squared
|
|
):
|
|
best_distance_squared = distance_squared
|
|
best_id = entity_id
|
|
return best_id
|
|
|
|
|
|
func get_charge_duration_for_tool(tool_id: StringName) -> float:
|
|
var duration: float = 0.0
|
|
if _catalog == null or tool_id.is_empty():
|
|
return duration
|
|
for entry: GatherableDataType in _catalog.get_available_entries():
|
|
if entry.required_tool_id == tool_id:
|
|
duration = maxf(duration, entry.charge_duration)
|
|
return duration
|
|
|
|
|
|
func get_entry_for_entity(entity_id: String) -> GatherableDataType:
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
return state.get("data") as GatherableDataType
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if (
|
|
_session == null
|
|
or not _session.is_host()
|
|
or not _session.is_gameplay_session_active()
|
|
):
|
|
return
|
|
_begin_population_if_ready()
|
|
_update_pending_capture_timeouts()
|
|
_update_respawns()
|
|
_update_showcase_deadlines()
|
|
_update_host_entities(delta)
|
|
_snapshot_elapsed += delta
|
|
if _snapshot_elapsed >= SNAPSHOT_INTERVAL_SECONDS:
|
|
_snapshot_elapsed = fmod(
|
|
_snapshot_elapsed,
|
|
SNAPSHOT_INTERVAL_SECONDS,
|
|
)
|
|
_broadcast_entity_snapshots()
|
|
|
|
|
|
func _begin_population_if_ready() -> void:
|
|
if (
|
|
_session == null
|
|
or not _session.is_host()
|
|
or not _session.is_gameplay_session_active()
|
|
or _catalog == null
|
|
or _world == null
|
|
or _world_root == null
|
|
):
|
|
return
|
|
var session_id: String = _session.get_session_id()
|
|
if session_id.is_empty() or session_id == _population_session_id:
|
|
return
|
|
_clear_world()
|
|
_population_session_id = session_id
|
|
for entry: GatherableDataType in _catalog.get_available_entries():
|
|
_cache_spawn_surface(entry)
|
|
for _spawn_index: int in entry.population:
|
|
_spawn_entity(entry)
|
|
|
|
|
|
func _cache_spawn_surface(entry: GatherableDataType) -> void:
|
|
if entry == null or _surface_triangles.has(entry.type_id):
|
|
return
|
|
var triangles: Array[PackedVector3Array] = (
|
|
_world.get_spawn_surface_triangles(
|
|
entry.surface_materials,
|
|
entry.minimum_surface_y,
|
|
)
|
|
)
|
|
var areas := PackedFloat32Array()
|
|
var total_area: float = 0.0
|
|
for triangle: PackedVector3Array in triangles:
|
|
var area: float = (
|
|
(triangle[1] - triangle[0]).cross(
|
|
triangle[2] - triangle[0]
|
|
).length() * 0.5
|
|
)
|
|
total_area += area
|
|
areas.append(total_area)
|
|
_surface_triangles[entry.type_id] = triangles
|
|
_surface_areas[entry.type_id] = areas
|
|
_surface_total_areas[entry.type_id] = total_area
|
|
if triangles.is_empty():
|
|
push_warning(
|
|
"No valid spawn surface was found for %s." % entry.type_id
|
|
)
|
|
|
|
|
|
func _spawn_entity(entry: GatherableDataType) -> void:
|
|
var position: Vector3 = _sample_surface_position(entry)
|
|
if not position.is_finite():
|
|
return
|
|
var quality: int = FishQualityType.roll(_rng)
|
|
var entity_id: String = _new_id("world")
|
|
var state: Dictionary = {
|
|
"entity_id": entity_id,
|
|
"type_id": entry.type_id,
|
|
"data": entry,
|
|
"position": position,
|
|
"target": _sample_surface_position(entry, position, entry.roam_radius),
|
|
"yaw": _rng.randf_range(-PI, PI),
|
|
"quality": quality,
|
|
"revision": 1,
|
|
"locked": false,
|
|
}
|
|
if not (state["target"] as Vector3).is_finite():
|
|
state["target"] = position
|
|
_entities[entity_id] = state
|
|
var envelope: Dictionary = _make_envelope(
|
|
&"spawn",
|
|
{"entity": _state_to_network(state)},
|
|
)
|
|
_apply_envelope(envelope)
|
|
receive_world_envelope.rpc(envelope)
|
|
|
|
|
|
func _update_host_entities(delta: float) -> void:
|
|
for entity_id: String in _entities.keys():
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
if state.is_empty() or bool(state.get("locked", false)):
|
|
continue
|
|
var entry := state.get("data") as GatherableDataType
|
|
if entry == null:
|
|
continue
|
|
var quality: int = _get_state_quality(state)
|
|
var position: Vector3 = state["position"]
|
|
var target: Vector3 = state["target"]
|
|
var horizontal_delta := Vector3(
|
|
target.x - position.x,
|
|
0.0,
|
|
target.z - position.z,
|
|
)
|
|
if horizontal_delta.length_squared() <= 0.04:
|
|
target = _sample_surface_position(
|
|
entry,
|
|
position,
|
|
entry.roam_radius,
|
|
)
|
|
if not target.is_finite():
|
|
target = position
|
|
state["target"] = target
|
|
horizontal_delta = Vector3(
|
|
target.x - position.x,
|
|
0.0,
|
|
target.z - position.z,
|
|
)
|
|
if not horizontal_delta.is_zero_approx():
|
|
var step: float = minf(
|
|
entry.get_movement_speed_for_quality(quality) * delta,
|
|
horizontal_delta.length(),
|
|
)
|
|
var direction: Vector3 = horizontal_delta.normalized()
|
|
position.x += direction.x * step
|
|
position.z += direction.z * step
|
|
position.y = lerpf(
|
|
position.y,
|
|
target.y,
|
|
minf(step / maxf(horizontal_delta.length(), 0.001), 1.0),
|
|
)
|
|
state["yaw"] = atan2(-direction.x, -direction.z)
|
|
state["position"] = position
|
|
_entities[entity_id] = state
|
|
if _should_scare(entry, position, quality):
|
|
_despawn_entity(entity_id, &"scared", true, true)
|
|
|
|
|
|
func _should_scare(
|
|
entry: GatherableDataType,
|
|
position: Vector3,
|
|
quality: int,
|
|
) -> bool:
|
|
var scare_radius: float = entry.get_scare_radius_for_quality(quality)
|
|
var radius_squared: float = scare_radius * scare_radius
|
|
for peer_id: int in _spawn_service.get_peer_ids():
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
if (
|
|
avatar == null
|
|
or not avatar.is_moving_horizontally()
|
|
or avatar.is_sneaking()
|
|
):
|
|
continue
|
|
var delta := Vector2(
|
|
avatar.global_position.x - position.x,
|
|
avatar.global_position.z - position.z,
|
|
)
|
|
if delta.length_squared() <= radius_squared:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _sample_surface_position(
|
|
entry: GatherableDataType,
|
|
origin: Vector3 = Vector3(INF, INF, INF),
|
|
maximum_distance: float = INF,
|
|
) -> Vector3:
|
|
_cache_spawn_surface(entry)
|
|
var triangles: Array = _surface_triangles.get(entry.type_id, [])
|
|
var cumulative_areas: PackedFloat32Array = _surface_areas.get(
|
|
entry.type_id,
|
|
PackedFloat32Array(),
|
|
)
|
|
var total_area: float = float(
|
|
_surface_total_areas.get(entry.type_id, 0.0)
|
|
)
|
|
if triangles.is_empty() or total_area <= 0.0:
|
|
return Vector3(INF, INF, INF)
|
|
var attempts: int = 48 if is_finite(maximum_distance) else 1
|
|
var fallback: Vector3 = Vector3(INF, INF, INF)
|
|
for _attempt: int in attempts:
|
|
var roll: float = _rng.randf() * total_area
|
|
var triangle_index: int = cumulative_areas.bsearch(roll)
|
|
triangle_index = clampi(triangle_index, 0, triangles.size() - 1)
|
|
var triangle: PackedVector3Array = triangles[triangle_index]
|
|
var root: float = sqrt(_rng.randf())
|
|
var barycentric_b: float = root * (1.0 - _rng.randf())
|
|
var barycentric_c: float = root - barycentric_b
|
|
var point: Vector3 = (
|
|
triangle[0] * (1.0 - root)
|
|
+ triangle[1] * barycentric_b
|
|
+ triangle[2] * barycentric_c
|
|
)
|
|
fallback = point
|
|
if (
|
|
not is_finite(maximum_distance)
|
|
or Vector2(point.x - origin.x, point.z - origin.z).length()
|
|
<= maximum_distance
|
|
):
|
|
return point
|
|
if origin.is_finite() and is_finite(maximum_distance):
|
|
return origin
|
|
return fallback
|
|
|
|
|
|
func _broadcast_entity_snapshots() -> void:
|
|
if _entities.is_empty():
|
|
return
|
|
var snapshots: Array[Dictionary] = []
|
|
for entity_id: String in _entities:
|
|
var state: Dictionary = _entities[entity_id]
|
|
state["revision"] = int(state["revision"]) + 1
|
|
_entities[entity_id] = state
|
|
snapshots.append(_state_to_network(state))
|
|
while not snapshots.is_empty():
|
|
var chunk: Array[Dictionary] = []
|
|
var chunk_size: int = mini(
|
|
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE,
|
|
snapshots.size(),
|
|
)
|
|
for _index: int in chunk_size:
|
|
chunk.append(snapshots.pop_front())
|
|
var envelope: Dictionary = _make_envelope(
|
|
&"snapshot",
|
|
{"entities": chunk},
|
|
)
|
|
_apply_envelope(envelope)
|
|
receive_world_snapshot_envelope.rpc(envelope)
|
|
|
|
|
|
func _despawn_entity(
|
|
entity_id: String,
|
|
reason: StringName,
|
|
with_dust: bool,
|
|
schedule_respawn: bool,
|
|
) -> void:
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
if state.is_empty():
|
|
return
|
|
var entry := state.get("data") as GatherableDataType
|
|
_entities.erase(entity_id)
|
|
var envelope: Dictionary = _make_envelope(
|
|
&"despawn",
|
|
{
|
|
"entity_id": entity_id,
|
|
"reason": str(reason),
|
|
"with_dust": with_dust,
|
|
},
|
|
)
|
|
_apply_envelope(envelope)
|
|
receive_world_envelope.rpc(envelope)
|
|
if schedule_respawn and entry != null:
|
|
_respawns.append({
|
|
"type_id": entry.type_id,
|
|
"due": _now() + entry.get_respawn_delay(reason, _rng),
|
|
})
|
|
|
|
|
|
func _update_respawns() -> void:
|
|
var now: float = _now()
|
|
var remaining: Array[Dictionary] = []
|
|
for respawn: Dictionary in _respawns:
|
|
var type_id := StringName(str(respawn.get("type_id", "")))
|
|
var next_allowed: float = float(
|
|
_next_respawn_by_type.get(type_id, 0.0)
|
|
)
|
|
if now < maxf(float(respawn.get("due", INF)), next_allowed):
|
|
remaining.append(respawn)
|
|
continue
|
|
var entry: GatherableDataType = _catalog.get_entry(
|
|
type_id
|
|
)
|
|
if entry != null and entry.is_available():
|
|
_spawn_entity(entry)
|
|
_next_respawn_by_type[type_id] = (
|
|
now + entry.minimum_respawn_spacing_seconds
|
|
)
|
|
_respawns = remaining
|
|
|
|
|
|
@rpc(
|
|
"any_peer",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func submit_interaction_begin(data: Dictionary) -> void:
|
|
var peer_id: int = multiplayer.get_remote_sender_id()
|
|
if _session.is_host() and _session.is_authenticated_peer(peer_id):
|
|
_handle_interaction_begin(peer_id, data)
|
|
|
|
|
|
func _handle_interaction_begin(peer_id: int, data: Dictionary) -> void:
|
|
var request_id: String = str(data.get("request_id", ""))
|
|
if (
|
|
str(data.get("session_id", "")) != _session.get_session_id()
|
|
or request_id.is_empty()
|
|
or request_id.length() > MAX_REQUEST_ID_LENGTH
|
|
or _pending_captures.has(peer_id)
|
|
):
|
|
_send_interaction_result(peer_id, request_id, false, "Cannot use the net now.")
|
|
return
|
|
_charge_requests[peer_id] = {
|
|
"request_id": request_id,
|
|
"started": _now(),
|
|
}
|
|
|
|
|
|
@rpc(
|
|
"any_peer",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func submit_interaction_finish(data: Dictionary) -> void:
|
|
var peer_id: int = multiplayer.get_remote_sender_id()
|
|
if _session.is_host() and _session.is_authenticated_peer(peer_id):
|
|
_handle_interaction_finish(peer_id, data)
|
|
|
|
|
|
func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
|
|
var request_id: String = str(data.get("request_id", ""))
|
|
var entity_id: String = str(data.get("entity_id", ""))
|
|
var charge: Dictionary = _charge_requests.get(peer_id, {})
|
|
_charge_requests.erase(peer_id)
|
|
var target_position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3(
|
|
data.get("target_position", [])
|
|
)
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
var entry := state.get("data") as GatherableDataType
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
var error: String = ""
|
|
if (
|
|
str(data.get("session_id", "")) != _session.get_session_id()
|
|
or request_id.is_empty()
|
|
or request_id.length() > MAX_REQUEST_ID_LENGTH
|
|
or str(charge.get("request_id", "")) != request_id
|
|
or not target_position.is_finite()
|
|
):
|
|
error = "The catch attempt was invalid."
|
|
elif state.is_empty() or entry == null or bool(state.get("locked", false)):
|
|
error = "That animal is no longer there."
|
|
elif _now() - float(charge.get("started", _now())) + 0.05 < entry.charge_duration:
|
|
error = "Pull the net all the way back first."
|
|
elif _item_use.get_equipped_item_id(peer_id) != entry.required_tool_id:
|
|
error = "Equip the correct gathering tool."
|
|
elif avatar == null or not avatar.is_sneaking():
|
|
error = "Sneak closer before swinging the net."
|
|
else:
|
|
var entity_position: Vector3 = state["position"]
|
|
var target_distance: float = Vector2(
|
|
entity_position.x - target_position.x,
|
|
entity_position.z - target_position.z,
|
|
).length()
|
|
var player_distance: float = Vector2(
|
|
avatar.global_position.x - entity_position.x,
|
|
avatar.global_position.z - entity_position.z,
|
|
).length()
|
|
if target_distance > entry.capture_radius:
|
|
error = "The net missed."
|
|
elif player_distance > entry.interaction_range:
|
|
error = "Move closer before swinging the net."
|
|
if not error.is_empty():
|
|
_send_interaction_result(peer_id, request_id, false, error)
|
|
return
|
|
var fish_catch: FishCatch = _create_catch_for_state(entry, state)
|
|
if fish_catch == null or not fish_catch.is_valid():
|
|
_send_interaction_result(peer_id, request_id, false, "The catch could not be recorded.")
|
|
return
|
|
state["locked"] = true
|
|
_entities[entity_id] = state
|
|
var capacity_nonce: String = _new_id("capacity")
|
|
_pending_captures[peer_id] = {
|
|
"request_id": request_id,
|
|
"entity_id": entity_id,
|
|
"catch": fish_catch.to_network_dict(),
|
|
"result_id": _new_id("gather_result"),
|
|
"capacity_nonce": capacity_nonce,
|
|
"deadline": _now() + CAPACITY_RESPONSE_TIMEOUT_SECONDS,
|
|
}
|
|
var probe: Dictionary = {
|
|
"session_id": _session.get_session_id(),
|
|
"request_id": request_id,
|
|
"capacity_nonce": capacity_nonce,
|
|
"catch_id": str(fish_catch.catch_id),
|
|
}
|
|
if peer_id == _session.get_local_peer_id():
|
|
_handle_local_capacity_probe(probe)
|
|
else:
|
|
receive_capacity_probe.rpc_id(peer_id, probe)
|
|
|
|
|
|
func _create_catch_for_state(
|
|
entry: GatherableDataType,
|
|
state: Dictionary,
|
|
) -> FishCatch:
|
|
var selector := FishSelectorType.new()
|
|
selector.begin_roll()
|
|
var fish_catch: FishCatch = selector.create_catch(entry.catch_data)
|
|
if fish_catch == null:
|
|
return null
|
|
var quality: int = _get_state_quality(state)
|
|
fish_catch.quality = quality
|
|
fish_catch.sale_value = FishQualityType.apply_sale_value(
|
|
entry.catch_data.get_sale_value_for_weight(fish_catch.weight_lb),
|
|
quality,
|
|
)
|
|
return fish_catch
|
|
|
|
|
|
func _get_state_quality(state: Dictionary) -> int:
|
|
var quality: int = int(
|
|
state.get("quality", FishQualityType.Tier.BORING)
|
|
)
|
|
return (
|
|
quality
|
|
if FishQualityType.is_valid(quality)
|
|
else FishQualityType.Tier.BORING
|
|
)
|
|
|
|
|
|
@rpc(
|
|
"any_peer",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func submit_interaction_cancel(request_id: String) -> void:
|
|
var peer_id: int = multiplayer.get_remote_sender_id()
|
|
if _session.is_host() and _session.is_authenticated_peer(peer_id):
|
|
_handle_interaction_cancel(peer_id, request_id)
|
|
|
|
|
|
func _handle_interaction_cancel(peer_id: int, request_id: String) -> void:
|
|
var charge: Dictionary = _charge_requests.get(peer_id, {})
|
|
if str(charge.get("request_id", "")) == request_id:
|
|
_charge_requests.erase(peer_id)
|
|
|
|
|
|
@rpc(
|
|
"authority",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func receive_capacity_probe(data: Dictionary) -> void:
|
|
_handle_local_capacity_probe(data)
|
|
|
|
|
|
func _handle_local_capacity_probe(data: Dictionary) -> void:
|
|
if str(data.get("session_id", "")) != _session.get_session_id():
|
|
return
|
|
var catch_id := StringName(str(data.get("catch_id", "")))
|
|
var can_accept: bool = (
|
|
_local_inventory != null
|
|
and _local_capacity != null
|
|
and (
|
|
_local_inventory.contains_catch_id(catch_id)
|
|
or _local_inventory.get_all_catches().size()
|
|
< _local_capacity.get_capacity()
|
|
)
|
|
)
|
|
if _session.is_host():
|
|
_handle_capacity_response(
|
|
_session.get_local_peer_id(),
|
|
str(data.get("request_id", "")),
|
|
str(data.get("capacity_nonce", "")),
|
|
can_accept,
|
|
)
|
|
else:
|
|
submit_capacity_response.rpc_id(
|
|
1,
|
|
str(data.get("request_id", "")),
|
|
str(data.get("capacity_nonce", "")),
|
|
can_accept,
|
|
)
|
|
|
|
|
|
@rpc(
|
|
"any_peer",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func submit_capacity_response(
|
|
request_id: String,
|
|
capacity_nonce: String,
|
|
can_accept: bool,
|
|
) -> void:
|
|
var peer_id: int = multiplayer.get_remote_sender_id()
|
|
if _session.is_host() and _session.is_authenticated_peer(peer_id):
|
|
_handle_capacity_response(
|
|
peer_id,
|
|
request_id,
|
|
capacity_nonce,
|
|
can_accept,
|
|
)
|
|
|
|
|
|
func _handle_capacity_response(
|
|
peer_id: int,
|
|
request_id: String,
|
|
capacity_nonce: String,
|
|
can_accept: bool,
|
|
) -> void:
|
|
var pending: Dictionary = _pending_captures.get(peer_id, {})
|
|
if (
|
|
pending.is_empty()
|
|
or str(pending.get("request_id", "")) != request_id
|
|
or str(pending.get("capacity_nonce", "")) != capacity_nonce
|
|
):
|
|
return
|
|
if not can_accept:
|
|
_reject_pending_capture(peer_id, "Cooler is full.")
|
|
return
|
|
_pending_captures.erase(peer_id)
|
|
var entity_id: String = str(pending["entity_id"])
|
|
_despawn_entity(entity_id, &"captured", false, true)
|
|
var outcome: Dictionary = {
|
|
"session_id": _session.get_session_id(),
|
|
"result_id": str(pending["result_id"]),
|
|
"request_id": request_id,
|
|
"target_peer_id": peer_id,
|
|
"catch": (pending["catch"] as Dictionary).duplicate(true),
|
|
}
|
|
if peer_id == _session.get_local_peer_id():
|
|
_apply_capture_result(outcome)
|
|
else:
|
|
receive_capture_result.rpc_id(peer_id, outcome)
|
|
_broadcast_showcase(peer_id, outcome["catch"], true)
|
|
_showcase_deadlines[peer_id] = _now() + 4.5
|
|
|
|
|
|
func _reject_pending_capture(peer_id: int, message: String) -> void:
|
|
var pending: Dictionary = _pending_captures.get(peer_id, {})
|
|
_pending_captures.erase(peer_id)
|
|
if pending.is_empty():
|
|
return
|
|
var entity_id: String = str(pending.get("entity_id", ""))
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
if not state.is_empty():
|
|
state["locked"] = false
|
|
_entities[entity_id] = state
|
|
_send_interaction_result(
|
|
peer_id,
|
|
str(pending.get("request_id", "")),
|
|
false,
|
|
message,
|
|
)
|
|
|
|
|
|
func _update_pending_capture_timeouts() -> void:
|
|
var now: float = _now()
|
|
for peer_id: int in _pending_captures.keys():
|
|
var pending: Dictionary = _pending_captures[peer_id]
|
|
if now >= float(pending.get("deadline", INF)):
|
|
_reject_pending_capture(peer_id, "The catch attempt timed out.")
|
|
|
|
|
|
@rpc(
|
|
"authority",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func receive_capture_result(data: Dictionary) -> void:
|
|
_apply_capture_result(data)
|
|
|
|
|
|
func _apply_capture_result(data: Dictionary) -> void:
|
|
if (
|
|
str(data.get("session_id", "")) != _session.get_session_id()
|
|
or int(data.get("target_peer_id", 0)) != _session.get_local_peer_id()
|
|
or typeof(data.get("catch")) != TYPE_DICTIONARY
|
|
):
|
|
return
|
|
var result_id: String = str(data.get("result_id", ""))
|
|
var request_id: String = str(data.get("request_id", ""))
|
|
if result_id.is_empty() or request_id.is_empty():
|
|
return
|
|
if _received_results.has(result_id):
|
|
local_interaction_finished.emit(true, "Caught.")
|
|
return
|
|
var catch_data: Dictionary = data["catch"]
|
|
var fish: FishDataType = _fish_catalog.get_fish_by_id(
|
|
StringName(str(catch_data.get("fish_id", "")))
|
|
)
|
|
var fish_catch: FishCatch = FishCatchType.from_network_dict(
|
|
catch_data,
|
|
fish,
|
|
)
|
|
if fish_catch == null or not fish_catch.is_valid():
|
|
return
|
|
var already_owned: bool = _local_inventory.contains_catch_id(
|
|
fish_catch.catch_id
|
|
)
|
|
if (
|
|
not already_owned
|
|
and _local_inventory.get_all_catches().size()
|
|
>= _local_capacity.get_capacity()
|
|
):
|
|
return
|
|
if not already_owned:
|
|
var experience: int = FishExperienceType.calculate_for_collection(
|
|
fish_catch,
|
|
_local_collection,
|
|
)
|
|
_local_inventory.add_catch(fish_catch)
|
|
_local_collection.mark_quality_discovered(
|
|
fish_catch.fish_id,
|
|
fish_catch.quality,
|
|
)
|
|
_local_experience.award_experience(experience)
|
|
if not _save_manager.save_if_dirty():
|
|
return
|
|
_received_results[result_id] = true
|
|
_bound(_received_results)
|
|
local_interaction_finished.emit(true, "Caught %s." % fish.display_name)
|
|
local_capture_received.emit(fish_catch)
|
|
|
|
|
|
func _send_interaction_result(
|
|
peer_id: int,
|
|
request_id: String,
|
|
accepted: bool,
|
|
message: String,
|
|
) -> void:
|
|
var result: Dictionary = {
|
|
"session_id": _session.get_session_id(),
|
|
"request_id": request_id if not request_id.is_empty() else "invalid",
|
|
"target_peer_id": peer_id,
|
|
"accepted": accepted,
|
|
"message": message.left(160),
|
|
}
|
|
if peer_id == _session.get_local_peer_id():
|
|
_apply_interaction_result(result)
|
|
else:
|
|
receive_interaction_result.rpc_id(peer_id, result)
|
|
|
|
|
|
@rpc(
|
|
"authority",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func receive_interaction_result(data: Dictionary) -> void:
|
|
_apply_interaction_result(data)
|
|
|
|
|
|
func _apply_interaction_result(data: Dictionary) -> void:
|
|
if (
|
|
str(data.get("session_id", "")) != _session.get_session_id()
|
|
or int(data.get("target_peer_id", 0)) != _session.get_local_peer_id()
|
|
or typeof(data.get("accepted")) != TYPE_BOOL
|
|
):
|
|
return
|
|
local_interaction_finished.emit(
|
|
bool(data["accepted"]),
|
|
str(data.get("message", "")),
|
|
)
|
|
|
|
|
|
func _broadcast_showcase(
|
|
peer_id: int,
|
|
catch_data: Dictionary,
|
|
visible: bool,
|
|
) -> void:
|
|
var envelope: Dictionary = _make_envelope(
|
|
&"showcase",
|
|
{
|
|
"owner_peer_id": peer_id,
|
|
"visible": visible,
|
|
"catch": catch_data.duplicate(true) if visible else {},
|
|
},
|
|
)
|
|
_apply_envelope(envelope)
|
|
receive_world_envelope.rpc(envelope)
|
|
|
|
|
|
func _update_showcase_deadlines() -> void:
|
|
var now: float = _now()
|
|
for peer_id: int in _showcase_deadlines.keys():
|
|
if now < float(_showcase_deadlines[peer_id]):
|
|
continue
|
|
_showcase_deadlines.erase(peer_id)
|
|
_broadcast_showcase(peer_id, {}, false)
|
|
|
|
|
|
func _make_envelope(
|
|
event_id: StringName,
|
|
payload: Dictionary,
|
|
) -> Dictionary:
|
|
_envelope_sequence += 1
|
|
return NetworkWorldSpawnProtocol.make_envelope(
|
|
_session.get_session_id(),
|
|
_envelope_sequence,
|
|
event_id,
|
|
payload,
|
|
)
|
|
|
|
|
|
@rpc(
|
|
"authority",
|
|
"call_remote",
|
|
"reliable",
|
|
NetworkWorldSpawnProtocol.RELIABLE_CHANNEL,
|
|
)
|
|
func receive_world_envelope(envelope: Dictionary) -> void:
|
|
_apply_envelope(envelope)
|
|
|
|
|
|
@rpc(
|
|
"authority",
|
|
"call_remote",
|
|
"unreliable_ordered",
|
|
NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL,
|
|
)
|
|
func receive_world_snapshot_envelope(envelope: Dictionary) -> void:
|
|
_apply_envelope(envelope)
|
|
|
|
|
|
func _apply_envelope(envelope: Dictionary) -> void:
|
|
if (
|
|
not NetworkWorldSpawnProtocol.validate_envelope(envelope)
|
|
or str(envelope["session_id"]) != _session.get_session_id()
|
|
):
|
|
return
|
|
var event_id := StringName(str(envelope["event_id"]))
|
|
var payload: Dictionary = envelope["payload"]
|
|
match event_id:
|
|
&"spawn":
|
|
_apply_entity_state(payload.get("entity", {}), true)
|
|
&"snapshot":
|
|
_apply_entity_states(payload.get("entities", []), false, false)
|
|
&"population":
|
|
_apply_entity_states(payload.get("entities", []), true, true)
|
|
&"despawn":
|
|
_apply_despawn(payload)
|
|
&"showcase":
|
|
_apply_showcase(payload)
|
|
|
|
|
|
func _apply_entity_states(
|
|
values: Variant,
|
|
immediate: bool,
|
|
reconcile: bool,
|
|
) -> void:
|
|
if (
|
|
typeof(values) != TYPE_ARRAY
|
|
or values.size() > NetworkWorldSpawnProtocol.MAX_ENTITIES_PER_SNAPSHOT
|
|
):
|
|
return
|
|
var included: Dictionary = {}
|
|
for value: Variant in values:
|
|
if not NetworkWorldSpawnProtocol.validate_entity_state(value):
|
|
continue
|
|
var state: Dictionary = value
|
|
included[str(state["entity_id"])] = true
|
|
_apply_entity_state(state, immediate)
|
|
if not reconcile:
|
|
return
|
|
for entity_id: String in _presentations.keys():
|
|
if not included.has(entity_id):
|
|
_remove_presentation(entity_id, false)
|
|
|
|
|
|
func _apply_entity_state(value: Variant, immediate: bool) -> void:
|
|
if not NetworkWorldSpawnProtocol.validate_entity_state(value):
|
|
return
|
|
var state: Dictionary = value
|
|
var entity_id: String = str(state["entity_id"])
|
|
var revision: int = int(state["revision"])
|
|
if revision < int(_entity_revisions.get(entity_id, -1)):
|
|
return
|
|
var entry: GatherableDataType = _catalog.get_entry(
|
|
StringName(str(state["type_id"]))
|
|
)
|
|
if entry == null:
|
|
return
|
|
var position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3(
|
|
state["position"]
|
|
)
|
|
var presentation := _presentations.get(entity_id) as WorldGatherableType
|
|
if presentation == null or not is_instance_valid(presentation):
|
|
presentation = WorldGatherableType.new()
|
|
presentation.name = "WorldGatherable_%s" % entity_id.right(12)
|
|
_world_root.add_child(presentation)
|
|
presentation.configure(
|
|
entity_id,
|
|
entry,
|
|
position,
|
|
float(state["yaw"]),
|
|
)
|
|
_presentations[entity_id] = presentation
|
|
else:
|
|
presentation.apply_network_state(
|
|
position,
|
|
float(state["yaw"]),
|
|
immediate,
|
|
)
|
|
_entity_revisions[entity_id] = revision
|
|
if not _session.is_host():
|
|
_entities[entity_id] = {
|
|
"entity_id": entity_id,
|
|
"type_id": entry.type_id,
|
|
"data": entry,
|
|
"position": position,
|
|
"yaw": float(state["yaw"]),
|
|
"revision": revision,
|
|
"locked": false,
|
|
}
|
|
|
|
|
|
func _apply_despawn(payload: Dictionary) -> void:
|
|
var entity_id: String = str(payload.get("entity_id", ""))
|
|
if entity_id.is_empty():
|
|
return
|
|
if not _session.is_host():
|
|
_entities.erase(entity_id)
|
|
_remove_presentation(entity_id, bool(payload.get("with_dust", false)))
|
|
|
|
|
|
func _remove_presentation(entity_id: String, with_dust: bool) -> void:
|
|
var presentation := _presentations.get(entity_id) as WorldGatherableType
|
|
_presentations.erase(entity_id)
|
|
_entity_revisions.erase(entity_id)
|
|
if presentation != null and is_instance_valid(presentation):
|
|
presentation.play_despawn(with_dust)
|
|
|
|
|
|
func _apply_showcase(payload: Dictionary) -> void:
|
|
if (
|
|
typeof(payload.get("owner_peer_id")) != TYPE_INT
|
|
or typeof(payload.get("visible")) != TYPE_BOOL
|
|
):
|
|
return
|
|
var peer_id: int = int(payload["owner_peer_id"])
|
|
if peer_id == _session.get_local_peer_id():
|
|
return
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
if avatar == null:
|
|
return
|
|
if not bool(payload["visible"]):
|
|
avatar.end_catch_showcase()
|
|
return
|
|
if typeof(payload.get("catch")) != TYPE_DICTIONARY:
|
|
return
|
|
var catch_data: Dictionary = payload["catch"]
|
|
var fish: FishDataType = _fish_catalog.get_fish_by_id(
|
|
StringName(str(catch_data.get("fish_id", "")))
|
|
)
|
|
var fish_catch: FishCatch = FishCatchType.from_network_dict(
|
|
catch_data,
|
|
fish,
|
|
)
|
|
if fish_catch != null:
|
|
avatar.begin_remote_catch_showcase(fish_catch)
|
|
|
|
|
|
func _state_to_network(state: Dictionary) -> Dictionary:
|
|
return {
|
|
"entity_id": str(state["entity_id"]),
|
|
"type_id": str(state["type_id"]),
|
|
"position": NetworkWorldSpawnProtocol.vector3_to_array(
|
|
state["position"]
|
|
),
|
|
"yaw": float(state["yaw"]),
|
|
"revision": int(state["revision"]),
|
|
}
|
|
|
|
|
|
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
|
if not _session.is_host():
|
|
return
|
|
var states: Array[Dictionary] = []
|
|
for state: Dictionary in _entities.values():
|
|
states.append(_state_to_network(state))
|
|
var envelope: Dictionary = _make_envelope(
|
|
&"population",
|
|
{"entities": states},
|
|
)
|
|
receive_world_envelope.rpc_id(peer_id, envelope)
|
|
|
|
|
|
func _on_peer_removed(peer_id: int) -> void:
|
|
_charge_requests.erase(peer_id)
|
|
if _pending_captures.has(peer_id):
|
|
_unlock_pending_capture(peer_id)
|
|
_showcase_deadlines.erase(peer_id)
|
|
|
|
|
|
func _unlock_pending_capture(peer_id: int) -> void:
|
|
var pending: Dictionary = _pending_captures.get(peer_id, {})
|
|
_pending_captures.erase(peer_id)
|
|
if pending.is_empty():
|
|
return
|
|
var entity_id: String = str(pending.get("entity_id", ""))
|
|
var state: Dictionary = _entities.get(entity_id, {})
|
|
if state.is_empty():
|
|
return
|
|
state["locked"] = false
|
|
_entities[entity_id] = state
|
|
|
|
|
|
func _on_session_state_changed(_state: NetworkSession.State) -> void:
|
|
if _session.is_gameplay_session_active():
|
|
_begin_population_if_ready.call_deferred()
|
|
return
|
|
_clear_world()
|
|
_population_session_id = ""
|
|
|
|
|
|
func _clear_world() -> void:
|
|
for presentation: WorldGatherableType in _presentations.values():
|
|
if presentation != null and is_instance_valid(presentation):
|
|
presentation.queue_free()
|
|
_entities.clear()
|
|
_presentations.clear()
|
|
_entity_revisions.clear()
|
|
_surface_triangles.clear()
|
|
_surface_areas.clear()
|
|
_surface_total_areas.clear()
|
|
_respawns.clear()
|
|
_next_respawn_by_type.clear()
|
|
_charge_requests.clear()
|
|
_pending_captures.clear()
|
|
_showcase_deadlines.clear()
|
|
_snapshot_elapsed = 0.0
|
|
|
|
|
|
func _bound(values: Dictionary) -> void:
|
|
while values.size() > MAX_LEDGER_ENTRIES:
|
|
values.erase(values.keys().front())
|
|
|
|
|
|
func _new_id(prefix: String) -> String:
|
|
return "%s:%s" % [
|
|
prefix,
|
|
Crypto.new().generate_random_bytes(16).hex_encode(),
|
|
]
|
|
|
|
|
|
func _now() -> float:
|
|
return Time.get_ticks_msec() / 1000.0
|