Add starter RV progression and fishing feedback

This commit is contained in:
Alexander Sellite 2026-08-31 16:03:55 -04:00
parent eed361681a
commit d8bf59bca0
47 changed files with 2268 additions and 467 deletions

View file

@ -684,6 +684,10 @@ func _is_reel_input_pressed() -> bool:
)
func is_reel_input_active() -> bool:
return state == FishingState.FIGHTING and _is_reel_input_pressed()
func _begin_aiming(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null:
return

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b815y7svs3ern"
path="res://.godot/imported/starter_rv_interior.glb-9ad8a345e9e831e82874b4b276341224.scn"
[deps]
source_file="res://homes/assets/starter_rv_interior.glb"
dest_files=["res://.godot/imported/starter_rv_interior.glb-9ad8a345e9e831e82874b4b276341224.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dkfqs051shmxe"
path="res://.godot/imported/tent_interior.glb-576cc47c2c633f7ae635651598663518.scn"
[deps]
source_file="res://homes/assets/tent_interior.glb"
dest_files=["res://.godot/imported/tent_interior.glb-576cc47c2c633f7ae635651598663518.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -130,6 +130,8 @@ func _unhandled_input(event: InputEvent) -> void:
func _process(delta: float) -> void:
if _toolbar != null and _toolbar.visible:
_toolbar.set_interactive(_can_decorate())
if _drag_instance_id.is_empty():
return
# Menus and other gameplay input handoffs must cancel an active drag before
@ -472,9 +474,20 @@ func _on_home_changed() -> void:
func _refresh_toolbar() -> void:
if _toolbar == null:
return
_toolbar.set_active(_can_decorate())
var inside_home: bool = _is_inside_home_space()
_toolbar.set_active(inside_home)
_toolbar.set_interactive(_can_decorate())
if not _toolbar.visible:
_clear_selection()
elif not _can_decorate():
_toolbar.report_status("decor tools pause while another menu is open")
func _is_inside_home_space() -> bool:
return (
_network_home != null
and String(_network_home.get_local_space()).begins_with("rv:")
)
func _can_decorate() -> bool:

View file

@ -9,23 +9,42 @@ const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const STARTER_MOTORCOACH_SCENE: PackedScene = preload(
"res://homes/assets/starter_motorcoach.glb"
)
const TENT_INTERIOR_SCENE: PackedScene = preload(
"res://homes/assets/tent_interior.glb"
)
const STARTER_RV_INTERIOR_SCENE: PackedScene = preload(
"res://homes/assets/starter_rv_interior.glb"
)
const HOME_PROMPT_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf")
const EXTERIOR_BODY_SIZE := Vector3(5.6, 2.5, 2.35)
const EXTERIOR_DOOR_LOCAL := Vector3(-0.2, 0.85, 1.48)
const EXTERIOR_SPAWN_LOCAL := Vector3(-0.2, 0.25, 2.45)
const RV_EXTERIOR_BODY_SIZE := Vector3(11.2, 5.0, 4.7)
const RV_EXTERIOR_BODY_CENTER_Y: float = 2.7
const RV_EXTERIOR_DOOR_LOCAL := Vector3(-0.4, 1.7, 2.96)
const RV_EXTERIOR_SPAWN_LOCAL := Vector3(-0.4, 0.25, 3.95)
const TENT_EXTERIOR_BODY_SIZE := Vector3(4.6, 3.0, 4.6)
const TENT_EXTERIOR_BODY_CENTER_Y: float = 1.5
const TENT_EXTERIOR_DOOR_LOCAL := Vector3(0.0, 1.0, 2.3)
const TENT_EXTERIOR_SPAWN_LOCAL := Vector3(0.0, 0.25, 3.3)
const RV_EXTERIOR_PROMPT_LOCAL := Vector3(-0.4, 3.72, 3.02)
const TENT_EXTERIOR_PROMPT_LOCAL := Vector3(0.0, 2.48, 2.36)
const HOME_PROMPT_SIZE := Vector2(0.95, 0.28)
const HOME_PROMPT_CORNER_RADIUS: float = 0.08
const HOME_PROMPT_TEXT_PIXEL_SIZE: float = 0.0045
const HOME_PROMPT_TEXT_COLOR := Color("c3dfe6")
const HOME_PROMPT_BACKGROUND_COLOR := Color("0a2d3a")
const EXTERIOR_INTERACTION_DISTANCE: float = 2.7
const INTERIOR_EXIT_DISTANCE: float = 2.1
const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0)
const INTERIOR_SPACING: float = 14.0
const INTERIOR_MAX_SPAWN_SLOTS: int = 64
const INTERIOR_FLOOR_SIZE := Vector3(8.0, 0.18, 4.5)
const INTERIOR_WALL_HEIGHT: float = 2.7
const TENT_INTERIOR_SIZE := Vector2(4.0, 4.0)
const STARTER_RV_INTERIOR_SIZE := Vector2(4.0, 6.0)
const MIN_EXTERIOR_TRIANGLE_AREA: float = 1.0
const MIN_EXTERIOR_SEPARATION: float = 9.0
const EXTERIOR_FOOTPRINT_HALF_EXTENTS := Vector2(3.85, 2.55)
const MIN_EXTERIOR_SEPARATION: float = 14.6
const EXTERIOR_FOOTPRINT_HALF_EXTENTS := Vector2(6.65, 3.73)
const EXTERIOR_SURFACE_INDEX_CELL_SIZE: float = 5.0
const EXTERIOR_MAX_SURFACE_HEIGHT_DELTA: float = 0.24
const EXTERIOR_CLEARANCE_HEIGHT: float = 2.7
const EXTERIOR_CLEARANCE_HEIGHT: float = 5.5
const WORLD_GEOMETRY_COLLISION_LAYER: int = 1
const DECOR_SELECTION_COLLISION_LAYER: int = 1 << 8
# Includes the player's 0.45 m capsule radius plus enough movement margin for
@ -113,7 +132,9 @@ func apply_manifest(data: Dictionary) -> bool:
var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
)
var exterior := _build_exterior(fingerprint, exterior_transform)
var exterior := _build_exterior(
fingerprint, exterior_transform, home_data
)
var interior := _build_interior(fingerprint, interior_index, home_data)
var presentation: Dictionary = {
"owner_peer_id": int(data["owner_peer_id"]),
@ -191,19 +212,27 @@ func choose_exterior_transform(
)
if dry_result != Transform3D.IDENTITY:
push_warning(
"RV exterior used a dry fallback with obstructed clearance."
"Home exterior used a dry fallback with obstructed clearance."
)
return dry_result
push_error("No dry, level RV exterior footprint was available.")
var fallback: Transform3D = spawn_transform
var column: int = posmod(owner_peer_id - 1, 4)
var row: int = maxi(floori(float(owner_peer_id - 1) / 4.0), 0)
fallback.origin += Vector3(
-12.0 + float(column) * 8.0,
0.0,
8.0 + float(row) * 7.0,
)
return fallback
var emergency_result: Transform3D = _find_exterior_transform(
owner_peer_id,
triangles,
surface_index,
spawn_transform,
[],
false,
false,
)
if emergency_result != Transform3D.IDENTITY:
push_warning(
"Home exterior used an accessible emergency placement."
)
return emergency_result
push_error("No dry, level home exterior footprint was available.")
# The authored spawn is the only universally reachable point. Never strand
# a home on an unchecked elevation when malformed terrain offers no site.
return spawn_transform
func _find_exterior_transform(
@ -229,7 +258,7 @@ func _find_exterior_transform(
) / 3.0
if (
require_spawn_clearance
and position.distance_to(spawn_transform.origin) < 6.0
and position.distance_to(spawn_transform.origin) < 9.0
):
continue
if _too_close_to_reserved(position, reserved_transforms):
@ -240,6 +269,13 @@ func _find_exterior_transform(
)
if not grounded_position.is_finite():
continue
if (
_world != null
and not _world.is_home_exterior_position_accessible(
grounded_position
)
):
continue
if (
require_obstacle_clearance
and not _exterior_volume_is_clear(grounded_position, yaw)
@ -444,16 +480,48 @@ func get_exterior_transform(owner_fingerprint: String) -> Transform3D:
func get_exterior_prompt_position(owner_fingerprint: String) -> Vector3:
return get_exterior_transform(owner_fingerprint) * EXTERIOR_DOOR_LOCAL
return get_exterior_transform(owner_fingerprint) * (
_exterior_prompt_local(owner_fingerprint)
)
func set_exterior_prompt_visible(
owner_fingerprint: String,
requested_visible: bool,
action_text: String = "enter home",
) -> void:
for fingerprint: String in _presentations_by_fingerprint:
var presentation: Dictionary = _presentations_by_fingerprint[fingerprint]
var exterior: Node3D = presentation.get("exterior")
if not is_instance_valid(exterior):
continue
var prompt := exterior.get_node_or_null("DoorPrompt") as Node3D
if prompt == null:
continue
prompt.visible = (
requested_visible
and fingerprint == owner_fingerprint
and not _dedicated_runtime
)
if prompt.visible:
var label := prompt.get_node_or_null("Message") as Label3D
if label != null:
label.text = action_text
func get_exterior_spawn_transform(owner_fingerprint: String) -> Transform3D:
var exterior_transform: Transform3D = get_exterior_transform(
owner_fingerprint
)
# Exterior local +Z leads away from the doorway. Player visuals face local
# -Z, so turn the authoritative spawn basis around to start the day facing
# outward instead of looking back into the home.
var outward_spawn_basis: Basis = (
exterior_transform.basis * Basis(Vector3.UP, PI)
)
return Transform3D(
exterior_transform.basis,
exterior_transform * EXTERIOR_SPAWN_LOCAL,
outward_spawn_basis,
exterior_transform * _exterior_spawn_local(owner_fingerprint),
)
@ -461,17 +529,26 @@ func get_interior_spawn_transform(
owner_fingerprint: String,
visitor_offset: int = 0,
) -> Transform3D:
var anchor: Vector3 = get_interior_anchor(owner_fingerprint)
# `visitor_offset` is allocated by the host as a compact room-local slot.
# Keep it bounded as a last line of defense if a future caller accidentally
# supplies an engine peer ID instead of a slot.
var visitor_slot: int = posmod(visitor_offset, INTERIOR_MAX_SPAWN_SLOTS)
# Build the origin as a value rather than mutating Transform3D.origin.
var spawn_origin: Vector3 = anchor + Vector3(
-1.0 + float(posmod(visitor_slot, 4)) * 0.65,
0.25,
0.4 + float(floori(float(visitor_slot) / 4.0)) * 0.55,
var exit_direction: Vector3 = get_interior_exit_direction(owner_fingerprint)
var lane_offset: float = (
float(posmod(visitor_slot, 4)) - 1.5
) * 0.28
var row_offset: float = (
float(floori(float(visitor_slot) / 4.0)) * 0.28
)
# Spawn just through the authored doorway. Local presentation then walks the
# avatar farther into the room before returning movement control.
var spawn_origin: Vector3 = (
get_interior_exit_position(owner_fingerprint)
+ exit_direction * 0.42
- exit_direction * row_offset
+ Vector3(0.0, 0.0, lane_offset)
)
spawn_origin.y = get_interior_anchor(owner_fingerprint).y
return Transform3D(Basis.IDENTITY, spawn_origin)
@ -487,7 +564,9 @@ func is_avatar_near_exterior(avatar: Player, owner_fingerprint: String) -> bool:
if avatar == null or not _presentations_by_fingerprint.has(owner_fingerprint):
return false
var transform: Transform3D = get_exterior_transform(owner_fingerprint)
var door_position: Vector3 = transform * EXTERIOR_DOOR_LOCAL
var door_position: Vector3 = (
transform * _exterior_door_local(owner_fingerprint)
)
return avatar.global_position.distance_to(door_position) <= (
EXTERIOR_INTERACTION_DISTANCE
)
@ -504,11 +583,46 @@ func is_avatar_near_interior_exit(
) <= INTERIOR_EXIT_DISTANCE
func has_avatar_crossed_interior_exit_threshold(
avatar: Player,
owner_fingerprint: String,
) -> bool:
if avatar == null:
return false
var local_position: Vector3 = interior_global_to_local(
owner_fingerprint,
avatar.global_position,
)
var threshold_position: Vector3 = interior_global_to_local(
owner_fingerprint,
get_interior_exit_position(owner_fingerprint),
)
var exit_direction: Vector3 = get_interior_exit_direction(
owner_fingerprint
)
return (
(local_position - threshold_position).dot(exit_direction) >= 0.0
and absf(local_position.z - threshold_position.z) <= 0.85
and local_position.y >= -0.25
and local_position.y <= 2.25
)
func get_interior_exit_direction(owner_fingerprint: String) -> Vector3:
return (
Vector3.LEFT
if _home_tier(owner_fingerprint)
>= PlayerHomeStateType.STARTER_RV_TIER
else Vector3.RIGHT
)
func get_interior_exit_position(owner_fingerprint: String) -> Vector3:
return get_interior_anchor(owner_fingerprint) + Vector3(
-INTERIOR_FLOOR_SIZE.x * 0.5 + 0.6,
0.35,
0.0,
var tier: int = _home_tier(owner_fingerprint)
return get_interior_anchor(owner_fingerprint) + (
Vector3(-STARTER_RV_INTERIOR_SIZE.x * 0.5 + 0.6, 0.35, 0.0)
if tier >= PlayerHomeStateType.STARTER_RV_TIER
else Vector3(TENT_INTERIOR_SIZE.x * 0.5 - 0.55, 0.35, 0.0)
)
@ -705,7 +819,7 @@ func find_nearby_exterior_owner(avatar: Player) -> String:
for fingerprint: String in _presentations_by_fingerprint:
var transform: Transform3D = get_exterior_transform(fingerprint)
var distance: float = avatar.global_position.distance_to(
transform * EXTERIOR_DOOR_LOCAL
transform * _exterior_door_local(fingerprint)
)
if distance <= closest_distance:
closest = fingerprint
@ -713,6 +827,41 @@ func find_nearby_exterior_owner(avatar: Player) -> String:
return closest
func _home_tier(owner_fingerprint: String) -> int:
var presentation: Dictionary = _presentations_by_fingerprint.get(
owner_fingerprint, {}
)
var home_data: Dictionary = presentation.get("home", {})
return int(home_data.get("tier", PlayerHomeStateType.STARTER_TIER))
func _exterior_door_local(owner_fingerprint: String) -> Vector3:
return (
RV_EXTERIOR_DOOR_LOCAL
if _home_tier(owner_fingerprint)
>= PlayerHomeStateType.STARTER_RV_TIER
else TENT_EXTERIOR_DOOR_LOCAL
)
func _exterior_spawn_local(owner_fingerprint: String) -> Vector3:
return (
RV_EXTERIOR_SPAWN_LOCAL
if _home_tier(owner_fingerprint)
>= PlayerHomeStateType.STARTER_RV_TIER
else TENT_EXTERIOR_SPAWN_LOCAL
)
func _exterior_prompt_local(owner_fingerprint: String) -> Vector3:
return (
RV_EXTERIOR_PROMPT_LOCAL
if _home_tier(owner_fingerprint)
>= PlayerHomeStateType.STARTER_RV_TIER
else TENT_EXTERIOR_PROMPT_LOCAL
)
func set_local_space(space_id: StringName) -> void:
_local_space = space_id
_refresh_local_visibility()
@ -822,24 +971,44 @@ func _ensure_roots() -> void:
func _build_exterior(
fingerprint: String,
world_transform: Transform3D,
home_data: Dictionary,
) -> Node3D:
var root := Node3D.new()
root.name = "RV_%s" % fingerprint.left(8)
root.name = "Home_%s" % fingerprint.left(8)
_exteriors_root.add_child(root)
root.global_transform = world_transform
var tier: int = int(home_data.get(
"tier", PlayerHomeStateType.STARTER_TIER
))
if tier >= PlayerHomeStateType.STARTER_RV_TIER:
if not _dedicated_runtime:
var motorcoach: Node3D = (
STARTER_MOTORCOACH_SCENE.instantiate() as Node3D
)
if motorcoach != null:
motorcoach.name = "StarterMotorcoach"
root.add_child(motorcoach)
else:
push_error(
"The starter motorcoach exterior could not be instantiated."
)
_add_box_collision(
root,
"Body",
RV_EXTERIOR_BODY_SIZE,
Vector3(0.0, RV_EXTERIOR_BODY_CENTER_Y, 0.0),
)
else:
if not _dedicated_runtime:
_add_tent_exterior(root)
_add_box_collision(
root,
"Body",
TENT_EXTERIOR_BODY_SIZE,
Vector3(0.0, TENT_EXTERIOR_BODY_CENTER_Y, 0.0),
)
if not _dedicated_runtime:
var motorcoach: Node3D = STARTER_MOTORCOACH_SCENE.instantiate() as Node3D
if motorcoach != null:
motorcoach.name = "StarterMotorcoach"
root.add_child(motorcoach)
else:
push_error("The starter motorcoach exterior could not be instantiated.")
_add_box_collision(
root,
"Body",
EXTERIOR_BODY_SIZE,
Vector3(0.0, 1.35, 0.0),
)
_add_exterior_prompt(root, tier)
root.visible = not _dedicated_runtime
return root
@ -850,59 +1019,26 @@ func _build_interior(
home_data: Dictionary,
) -> Node3D:
var root := Node3D.new()
root.name = "RVInterior_%s" % fingerprint.left(8)
root.name = "HomeInterior_%s" % fingerprint.left(8)
root.position = INTERIOR_ORIGIN + Vector3(
float(interior_index) * INTERIOR_SPACING, 0.0, 0.0
)
var half_width: float = INTERIOR_FLOOR_SIZE.x * 0.5
var half_depth: float = INTERIOR_FLOOR_SIZE.z * 0.5
var wall_center_y: float = INTERIOR_WALL_HEIGHT * 0.5
_interiors_root.add_child(root)
_add_box(
root, "Floor", INTERIOR_FLOOR_SIZE, Vector3.ZERO,
Color("a6754e"), true,
)
_add_wall(
root, "WallNorth",
Vector3(INTERIOR_FLOOR_SIZE.x, INTERIOR_WALL_HEIGHT, 0.18),
Vector3(0.0, wall_center_y, -half_depth),
)
_add_wall(
root, "WallSouth",
Vector3(INTERIOR_FLOOR_SIZE.x, INTERIOR_WALL_HEIGHT, 0.18),
Vector3(0.0, wall_center_y, half_depth),
)
_add_wall(
root, "WallWest",
Vector3(0.18, INTERIOR_WALL_HEIGHT, INTERIOR_FLOOR_SIZE.z),
Vector3(-half_width, wall_center_y, 0.0),
)
_add_wall(
root, "WallEast",
Vector3(0.18, INTERIOR_WALL_HEIGHT, INTERIOR_FLOOR_SIZE.z),
Vector3(half_width, wall_center_y, 0.0),
)
for x_position: float in [-1.75, 1.75]:
_add_box(
root, "WallNorthWindow%s" % ("Left" if x_position < 0.0 else "Right"),
Vector3(1.65, 0.95, 0.04),
Vector3(x_position, 1.55, -half_depth + 0.11),
Color("153640"), false,
)
_add_box(
root, "WallSouthWindow%s" % ("Left" if x_position < 0.0 else "Right"),
Vector3(1.65, 0.95, 0.04),
Vector3(x_position, 1.55, half_depth - 0.11),
Color("153640"), false,
)
_add_box(
root, "WallEastWindow", Vector3(0.04, 0.95, 1.55),
Vector3(half_width - 0.11, 1.55, 0.0), Color("153640"), false,
)
_add_box(
root, "WallWestDoor", Vector3(0.04, 1.9, 1.25),
Vector3(-half_width + 0.11, 1.0, 0.0), Color("2897a8"), false,
var tier: int = int(home_data.get(
"tier", PlayerHomeStateType.STARTER_TIER
))
var scene: PackedScene = (
STARTER_RV_INTERIOR_SCENE
if tier >= PlayerHomeStateType.STARTER_RV_TIER
else TENT_INTERIOR_SCENE
)
var shell: Node3D = scene.instantiate() as Node3D
if shell == null:
push_error("The home interior shell could not be instantiated.")
else:
shell.name = "InteriorShell"
root.add_child(shell)
_prepare_imported_interior(shell)
var decor := Node3D.new()
decor.name = "Decor"
root.add_child(decor)
@ -913,6 +1049,153 @@ func _build_interior(
return root
func _add_tent_exterior(parent: Node3D) -> void:
var tent := MeshInstance3D.new()
tent.name = "StarterTent"
var mesh := CylinderMesh.new()
mesh.top_radius = 0.0
mesh.bottom_radius = 3.05
mesh.height = 3.0
mesh.radial_segments = 4
mesh.rings = 1
mesh.cap_top = false
mesh.material = _material(Color("9a7049"))
tent.mesh = mesh
tent.position.y = 1.5
tent.rotation.y = PI * 0.25
parent.add_child(tent)
_add_box(
parent,
"TentDoor",
Vector3(1.25, 1.85, 0.04),
Vector3(0.0, 0.95, 2.18),
Color("173b32"),
false,
)
func _add_exterior_prompt(parent: Node3D, tier: int) -> void:
var prompt := Node3D.new()
prompt.name = "DoorPrompt"
prompt.position = (
RV_EXTERIOR_PROMPT_LOCAL
if tier >= PlayerHomeStateType.STARTER_RV_TIER
else TENT_EXTERIOR_PROMPT_LOCAL
)
prompt.visible = false
parent.add_child(prompt)
var background := MeshInstance3D.new()
background.name = "Background"
background.mesh = _rounded_rectangle_mesh(
HOME_PROMPT_SIZE,
HOME_PROMPT_CORNER_RADIUS,
)
background.material_override = _prompt_material(
HOME_PROMPT_BACKGROUND_COLOR
)
prompt.add_child(background)
var label := Label3D.new()
label.name = "Message"
label.text = "enter home"
label.position.z = 0.012
label.font = HOME_PROMPT_FONT
label.font_size = 28
label.pixel_size = HOME_PROMPT_TEXT_PIXEL_SIZE
label.modulate = HOME_PROMPT_TEXT_COLOR
label.outline_size = 0
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.width = HOME_PROMPT_SIZE.x / HOME_PROMPT_TEXT_PIXEL_SIZE
label.double_sided = true
prompt.add_child(label)
func _rounded_rectangle_mesh(size: Vector2, radius: float) -> ArrayMesh:
var half_size: Vector2 = size * 0.5
var clamped_radius: float = minf(
radius,
minf(half_size.x, half_size.y),
)
var corners: Array[Vector2] = [
Vector2(half_size.x - clamped_radius, half_size.y - clamped_radius),
Vector2(-half_size.x + clamped_radius, half_size.y - clamped_radius),
Vector2(-half_size.x + clamped_radius, -half_size.y + clamped_radius),
Vector2(half_size.x - clamped_radius, -half_size.y + clamped_radius),
]
var start_angles: Array[float] = [0.0, PI * 0.5, PI, PI * 1.5]
var perimeter: Array[Vector2] = []
const CORNER_SEGMENTS: int = 4
for corner_index: int in corners.size():
for segment_index: int in range(CORNER_SEGMENTS + 1):
var angle: float = start_angles[corner_index] + (
PI * 0.5 * float(segment_index) / float(CORNER_SEGMENTS)
)
perimeter.append(
corners[corner_index]
+ Vector2(cos(angle), sin(angle)) * clamped_radius
)
var surface := SurfaceTool.new()
surface.begin(Mesh.PRIMITIVE_TRIANGLES)
for index: int in perimeter.size():
var first: Vector2 = perimeter[index]
var second: Vector2 = perimeter[(index + 1) % perimeter.size()]
for point: Vector2 in [Vector2.ZERO, first, second]:
surface.set_normal(Vector3.FORWARD)
surface.add_vertex(Vector3(point.x, point.y, 0.0))
return surface.commit()
func _prompt_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.roughness = 1.0
material.metallic = 0.0
material.cull_mode = BaseMaterial3D.CULL_DISABLED
return material
func _prepare_imported_interior(shell: Node3D) -> void:
for value: Node in shell.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := value as MeshInstance3D
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_index: int in mesh_instance.mesh.get_surface_count():
var source_material: Material = mesh_instance.get_active_material(
surface_index
)
var material := source_material as StandardMaterial3D
if material == null:
continue
var flat_material := material.duplicate() as StandardMaterial3D
flat_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
flat_material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
flat_material.roughness = 1.0
flat_material.metallic = 0.0
mesh_instance.set_surface_override_material(
surface_index, flat_material
)
_add_imported_mesh_collision(mesh_instance)
func _add_imported_mesh_collision(mesh_instance: MeshInstance3D) -> void:
var parent: Node = mesh_instance.get_parent()
if parent == null or mesh_instance.mesh == null:
return
var shape: ConcavePolygonShape3D = mesh_instance.mesh.create_trimesh_shape()
if shape == null:
return
var body := StaticBody3D.new()
body.name = "%sCollision" % mesh_instance.name
body.transform = mesh_instance.transform
var collision := CollisionShape3D.new()
collision.shape = shape
body.add_child(collision)
parent.add_child(body)
func _rebuild_decor(
parent: Node3D,
home_data: Dictionary,
@ -1115,9 +1398,9 @@ func _refresh_local_visibility() -> void:
for wall_name: String in [
"WallNorth", "WallSouth", "WallWest", "WallEast",
]:
var body: StaticBody3D = active_interior.get_node_or_null(
"%sCollision" % wall_name
)
var body := active_interior.find_child(
"%sCollision" % wall_name, true, false
) as StaticBody3D
if body != null:
excluded_wall_rids.append(body.get_rid())
local_player.set_rv_interior_camera_mode(
@ -1145,12 +1428,20 @@ func _refresh_nearest_wall() -> void:
).normalized()
var horizontal_view_weight: float = absf(local_view_direction.x)
var depth_view_weight: float = absf(local_view_direction.z)
var tier: int = int((presentation.get("home", {}) as Dictionary).get(
"tier", PlayerHomeStateType.STARTER_TIER
))
var interior_size: Vector2 = (
STARTER_RV_INTERIOR_SIZE
if tier >= PlayerHomeStateType.STARTER_RV_TIER
else TENT_INTERIOR_SIZE
)
var hidden_walls: Dictionary[String, bool] = {}
var outside_horizontal_wall: bool = (
absf(local_camera.x) >= INTERIOR_FLOOR_SIZE.x * 0.5 - 0.05
absf(local_camera.x) >= interior_size.x * 0.5 - 0.05
)
var outside_depth_wall: bool = (
absf(local_camera.z) >= INTERIOR_FLOOR_SIZE.z * 0.5 - 0.05
absf(local_camera.z) >= interior_size.y * 0.5 - 0.05
)
# At a diagonal, both near walls occupy the view. Use the camera's viewing
# direction to hide both instead of choosing only the dominant position
@ -1181,10 +1472,14 @@ func _refresh_nearest_wall() -> void:
for wall_name: String in [
"WallNorth", "WallSouth", "WallWest", "WallEast",
]:
var wall: MeshInstance3D = interior.get_node_or_null(wall_name)
var wall := interior.find_child(
wall_name, true, false
) as MeshInstance3D
if wall != null:
wall.visible = not hidden_walls.has(wall_name)
for child: Node in interior.get_children():
for child: Node in interior.find_children(
"%s*" % wall_name, "GeometryInstance3D", true, false
):
if (
child is GeometryInstance3D
and String(child.name).begins_with(wall_name)

View file

@ -7,8 +7,12 @@ 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 STARTER_EXTERIOR_ID: StringName = &"starter_tent"
const STARTER_RV_TIER: int = 1
const STARTER_RV_EXTERIOR_ID: StringName = &"starter_motorcoach"
const MAX_TIER: int = STARTER_RV_TIER
const HOME_UPGRADE_COSTS: Array[int] = [50]
const STORAGE_CAPACITIES: Array[int] = [9, 18]
const MAX_PLACEMENTS: int = 128
const MAX_INSTANCE_ID_LENGTH: int = 96
const MAX_REVISION: int = 2147483647
@ -16,11 +20,8 @@ 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 TENT_INTERIOR_HALF_EXTENTS := Vector2(1.91, 1.91)
const STARTER_RV_INTERIOR_HALF_EXTENTS := Vector2(1.91, 2.91)
const PLACEMENT_EDGE_PADDING: float = 0.02
enum Privacy {
@ -50,6 +51,51 @@ func get_exterior_id() -> StringName:
return _exterior_id
func get_storage_capacity() -> int:
return storage_capacity_for_tier(_tier)
func get_next_upgrade_cost() -> int:
if _tier >= MAX_TIER:
return -1
return HOME_UPGRADE_COSTS[_tier]
func get_next_storage_capacity() -> int:
if _tier >= MAX_TIER:
return -1
return storage_capacity_for_tier(_tier + 1)
func upgrade_to_next_tier() -> bool:
if _tier >= MAX_TIER:
return false
_tier += 1
_exterior_id = exterior_id_for_tier(_tier)
_touch()
return true
static func exterior_id_for_tier(tier: int) -> StringName:
return (
STARTER_RV_EXTERIOR_ID
if tier >= STARTER_RV_TIER
else STARTER_EXTERIOR_ID
)
static func storage_capacity_for_tier(tier: int) -> int:
return STORAGE_CAPACITIES[clampi(tier, STARTER_TIER, MAX_TIER)]
static func interior_half_extents_for_tier(tier: int) -> Vector2:
return (
STARTER_RV_INTERIOR_HALF_EXTENTS
if tier >= STARTER_RV_TIER
else TENT_INTERIOR_HALF_EXTENTS
)
func get_privacy() -> Privacy:
return _privacy
@ -248,7 +294,7 @@ func get_nearest_valid_placement_transform(
candidate_validator: Callable = Callable(),
) -> Dictionary:
var normalized: Dictionary = normalized_placement_transform(
decor_id, position, yaw_radians, mode
decor_id, position, yaw_radians, mode, _tier
)
if normalized.is_empty():
return {}
@ -322,7 +368,7 @@ func restore_from_save_data(data: Dictionary) -> bool:
if sanitized.is_empty():
return false
_tier = int(sanitized["tier"])
_exterior_id = StringName(str(sanitized["exterior_id"]))
_exterior_id = exterior_id_for_tier(_tier)
_privacy = int(sanitized["privacy"])
_owned_decor.clear()
for key: Variant in (sanitized["owned_decor"] as Dictionary):
@ -384,6 +430,7 @@ static func sanitize_save_data(data: Dictionary) -> Dictionary:
or next_sequence > MAX_REVISION
):
return {}
exterior_id = String(exterior_id_for_tier(tier))
var sanitized_owned: Dictionary = {}
var owned: Dictionary = data["owned_decor"]
for key: Variant in owned:
@ -408,7 +455,7 @@ static func sanitize_save_data(data: Dictionary) -> Dictionary:
for value: Variant in values:
if typeof(value) != TYPE_DICTIONARY:
return {}
var sanitized: Dictionary = sanitize_placement(value)
var sanitized: Dictionary = sanitize_placement(value, tier)
if sanitized.is_empty():
return {}
var instance_id: String = str(sanitized["instance_id"])
@ -440,7 +487,10 @@ static func sanitize_save_data(data: Dictionary) -> Dictionary:
}
static func sanitize_placement(value: Dictionary) -> Dictionary:
static func sanitize_placement(
value: Dictionary,
home_tier: int = STARTER_TIER,
) -> Dictionary:
for key: String in [
"instance_id", "decor_id", "mode", "position", "yaw",
]:
@ -483,6 +533,7 @@ static func sanitize_placement(value: Dictionary) -> Dictionary:
position,
yaw,
mode as PlacementMode,
home_tier,
)
if normalized.is_empty():
return {}
@ -509,6 +560,7 @@ static func normalized_placement_transform(
position: Vector3,
yaw_radians: float,
mode: PlacementMode,
home_tier: int = STARTER_TIER,
) -> Dictionary:
if (
not DecorCatalogType.has_product(decor_id)
@ -530,11 +582,12 @@ static func normalized_placement_transform(
(footprint.x * cosine + footprint.y * sine) * 0.5,
(footprint.x * sine + footprint.y * cosine) * 0.5,
)
var interior_half_extents := interior_half_extents_for_tier(home_tier)
var maximum_x: float = (
INTERIOR_HALF_EXTENTS.x - half_size.x - PLACEMENT_EDGE_PADDING
interior_half_extents.x - half_size.x - PLACEMENT_EDGE_PADDING
)
var maximum_z: float = (
INTERIOR_HALF_EXTENTS.y - half_size.y - PLACEMENT_EDGE_PADDING
interior_half_extents.y - half_size.y - PLACEMENT_EDGE_PADDING
)
if maximum_x < 0.0 or maximum_z < 0.0:
return {}
@ -580,6 +633,7 @@ func _find_nearest_grid_transform(
),
yaw,
PlacementMode.GRID,
_tier,
)
if candidate.is_empty() or not _mark_candidate_unseen(
candidate, seen
@ -630,6 +684,7 @@ func _find_nearest_free_transform(
),
yaw,
PlacementMode.FREE,
_tier,
)
if candidate.is_empty() or not _mark_candidate_unseen(
candidate, seen

View file

@ -40,6 +40,9 @@ const FishingShopInteractionType = preload(
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const RVUpgradeInteractionType = preload(
"res://world/rv_upgrade_interaction.gd"
)
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
@ -148,6 +151,13 @@ const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0)
const SHOP_PATTERN_SCALE: float = 1.75
const DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND: int = 10
const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
const HOME_ENTRY_INPUT_OWNER: StringName = &"home_entry_transition"
const HOME_EXIT_INPUT_OWNER: StringName = &"home_exit_transition"
const HOME_EXIT_FADE_GENERATION: int = -104729
const HOME_ENTRY_WALK_DISTANCE: float = 1.05
const HOME_ENTRY_WALK_DURATION: float = 0.55
const HOME_EXIT_WALK_DISTANCE: float = 1.25
const HOME_EXIT_WALK_DURATION: float = 0.62
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@ -260,6 +270,7 @@ var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _rv_upgrade_shop_interaction: RVUpgradeInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
var _title_music_transition_generation: int = 0
@ -290,6 +301,12 @@ var _pending_existing_root_path := ""
var _local_recovery_attempt_id: String = ""
var _dedicated_runtime: bool = false
var _dedicated_metrics_accumulator: float = 0.0
var _home_exit_transition_active: bool = false
var _home_exit_waiting_for_network: bool = false
var _home_exit_start_position: Vector3 = Vector3.ZERO
var _home_exit_walk_tween: Tween
var _home_entry_transition_active: bool = false
var _home_entry_walk_tween: Tween
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
@ -743,6 +760,7 @@ func _initialize_application(dedicated: bool) -> void:
_player.wallet,
_asset_reservations
)
_player.cooler_capacity.bind_home_state(_home_state)
_player.bag.setup(item_catalog)
_player.inventory_layout.setup(
_player.bag,
@ -760,19 +778,24 @@ func _initialize_application(dedicated: bool) -> void:
)
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_storage_interaction = _test_world.get_player_storage()
if not dedicated:
if not _home_state.changed.is_connected(_on_home_state_changed):
_home_state.changed.connect(_on_home_state_changed)
_shop_interaction.setup_local_player(_player)
_shop_interaction.local_player_range_changed.connect(
_on_shop_range_changed
)
_game_ui.set_shop_npc_player_in_range(
_shop_interaction.is_local_player_in_range()
)
_refresh_shop_npc_speech_state()
_decor_shop_interaction.setup_local_player(_player)
_decor_shop_interaction.local_player_range_changed.connect(
_on_decor_shop_range_changed
)
_rv_upgrade_shop_interaction.setup_local_player(_player)
_rv_upgrade_shop_interaction.local_player_range_changed.connect(
_on_rv_upgrade_shop_range_changed
)
_storage_interaction.setup_local_player(_player)
_storage_interaction.local_player_range_changed.connect(
_on_storage_range_changed
@ -947,6 +970,7 @@ func _initialize_application(dedicated: bool) -> void:
_home_state,
_network_home,
_decor_shop_interaction,
_rv_upgrade_shop_interaction,
)
_fishing_spot.setup(
_player,
@ -987,6 +1011,7 @@ func _initialize_application(dedicated: bool) -> void:
_shop_interaction,
_storage_interaction,
_decor_shop_interaction,
_rv_upgrade_shop_interaction,
_player.item_effects,
_player.cooler_capacity,
_network_session,
@ -1024,6 +1049,12 @@ func _initialize_application(dedicated: bool) -> void:
_home_decor_controller.begin_decorating
)
_network_home.local_space_changed.connect(_on_local_home_space_changed)
_network_home.local_transition_finished.connect(
_on_local_home_transition_finished
)
_game_ui.get_screen_fade().transition_completed.connect(
_on_home_exit_fade_transition_completed
)
_game_ui.rv_storage_requested.connect(_on_rv_storage_requested)
_game_ui.setup_data_and_identity(
_data_root,
@ -1539,6 +1570,9 @@ func _input(event: InputEvent) -> void:
if _game_ui.get_decor_shop().consume_escape():
get_viewport().set_input_as_handled()
return
if _game_ui.get_rv_upgrade_shop().consume_escape():
get_viewport().set_input_as_handled()
return
if fishing_shop.consume_escape():
get_viewport().set_input_as_handled()
return
@ -1653,17 +1687,12 @@ func _unhandled_input(event: InputEvent) -> void:
return
var home_owner: String = _network_home.get_local_home_owner_fingerprint()
if not home_owner.is_empty():
if _home_world.is_avatar_near_interior_exit(_player, home_owner):
if _network_home.request_exit():
_game_ui.set_storage_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_network_home.can_local_decorate()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_player_storage().open_storage_from_home(true)
):
_game_ui.set_storage_prompt_visible(false)
_refresh_home_door_prompt({})
get_viewport().set_input_as_handled()
return
var nearby_home_owner: String = _home_world.find_nearby_exterior_owner(
@ -1671,9 +1700,18 @@ func _unhandled_input(event: InputEvent) -> void:
)
if not nearby_home_owner.is_empty():
if _network_home.request_enter(nearby_home_owner):
_game_ui.set_storage_prompt_visible(false)
_refresh_home_door_prompt({})
get_viewport().set_input_as_handled()
return
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
and _fishing_spot.can_open_fishing_shop()
and _game_ui.get_rv_upgrade_shop().open_shop()
):
_game_ui.set_shop_prompt_visible(false)
get_viewport().set_input_as_handled()
return
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
@ -1703,14 +1741,24 @@ func _process(delta: float) -> void:
)
_print_dedicated_metrics()
return
_maybe_begin_home_exit_transition()
var show_rv_upgrade_prompt := _can_show_rv_upgrade_shop_prompt()
var show_decor_shop_prompt := _can_show_decor_shop_prompt()
var show_shop_prompt := show_decor_shop_prompt or _can_show_shop_prompt()
var show_shop_prompt := (
show_rv_upgrade_prompt
or show_decor_shop_prompt
or _can_show_shop_prompt()
)
var shop_prompt_anchor := (
_decor_shop_interaction.get_prompt_anchor_position()
if show_decor_shop_prompt
_rv_upgrade_shop_interaction.get_prompt_anchor_position()
if show_rv_upgrade_prompt
else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
_decor_shop_interaction.get_prompt_anchor_position()
if show_decor_shop_prompt
else (
_shop_interaction.get_prompt_anchor_position()
if _shop_interaction != null else Vector3.ZERO
)
)
)
_game_ui.set_shop_prompt_visible(
@ -1718,11 +1766,7 @@ func _process(delta: float) -> void:
shop_prompt_anchor,
)
var home_prompt := _get_home_prompt()
_game_ui.set_storage_prompt_visible(
not home_prompt.is_empty(),
home_prompt.get("anchor", Vector3.ZERO),
str(home_prompt.get("label", "enter RV")),
)
_refresh_home_door_prompt(home_prompt)
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
)
@ -2577,12 +2621,23 @@ func _refresh_active_world_bindings() -> void:
_decor_shop_interaction.local_player_range_changed.disconnect(
_on_decor_shop_range_changed
)
if is_instance_valid(_rv_upgrade_shop_interaction):
if _rv_upgrade_shop_interaction.local_player_range_changed.is_connected(
_on_rv_upgrade_shop_range_changed
):
_rv_upgrade_shop_interaction.local_player_range_changed.disconnect(
_on_rv_upgrade_shop_range_changed
)
_shop_interaction = _test_world.get_fishing_shop()
_decor_shop_interaction = _test_world.get_decor_shop()
_rv_upgrade_shop_interaction = _test_world.get_rv_upgrade_shop()
_storage_interaction = _test_world.get_player_storage()
_network_sale.set_shop_interaction(_shop_interaction)
_network_shop.set_shop_interaction(_shop_interaction)
_network_shop.set_decor_shop_interaction(_decor_shop_interaction)
_network_shop.set_rv_upgrade_shop_interaction(
_rv_upgrade_shop_interaction
)
if _dedicated_runtime:
return
if _shop_interaction != null:
@ -2600,10 +2655,16 @@ func _refresh_active_world_bindings() -> void:
_decor_shop_interaction.local_player_range_changed.connect(
_on_decor_shop_range_changed
)
if _rv_upgrade_shop_interaction != null:
_rv_upgrade_shop_interaction.setup_local_player(_player)
_rv_upgrade_shop_interaction.local_player_range_changed.connect(
_on_rv_upgrade_shop_range_changed
)
_game_ui.set_world_interactions(
_shop_interaction,
_storage_interaction,
_decor_shop_interaction,
_rv_upgrade_shop_interaction,
)
_on_shop_range_changed(
_shop_interaction != null
@ -2617,6 +2678,10 @@ func _refresh_active_world_bindings() -> void:
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
_on_rv_upgrade_shop_range_changed(
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
)
func _on_water_recovery_starting() -> void:
@ -2631,6 +2696,7 @@ func _on_water_recovery_starting() -> void:
_game_ui.get_pause_menu().close_for_water_recovery()
_game_ui.get_fishing_shop().close_for_water_recovery()
_game_ui.get_decor_shop().close_for_water_recovery()
_game_ui.get_rv_upgrade_shop().close_for_water_recovery()
_game_ui.get_player_storage().close_for_water_recovery()
@ -2679,6 +2745,7 @@ func _on_active_hotbar_item_changed(
and item != null
and item.is_available()
and _player.bag.owns_item(item_id)
and not String(_network_home.get_local_space()).begins_with("rv:")
)
var active_is_catching_net: bool = (
item_id == FishingShopStockType.CRAB_NET_ID
@ -2828,6 +2895,8 @@ func _finish_quit() -> void:
func _exit_tree() -> void:
_cancel_home_entry_transition()
_cancel_home_exit_transition(true)
if _dedicated_runtime and _network_session != null:
_network_session.disconnect_session("Dedicated server stopping.")
if _title_music_tween != null:
@ -2845,52 +2914,46 @@ func _exit_tree() -> void:
func _on_shop_range_changed(in_range: bool) -> void:
_game_ui.set_shop_npc_player_in_range(
in_range
or (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
)
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_fishing_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_shop_prompt())
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
func _on_storage_range_changed(in_range: bool) -> void:
if not in_range:
_game_ui.get_player_storage().close_for_range_exit()
var prompt := _get_home_prompt()
_game_ui.set_storage_prompt_visible(
not prompt.is_empty(),
prompt.get("anchor", Vector3.ZERO),
str(prompt.get("label", "enter RV")),
)
_refresh_home_door_prompt(prompt)
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
)
func _on_decor_shop_range_changed(in_range: bool) -> void:
_game_ui.set_shop_npc_player_in_range(
in_range
or (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
)
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_decor_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(
_can_show_shop_prompt() or _can_show_decor_shop_prompt()
)
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
func _on_rv_upgrade_shop_range_changed(in_range: bool) -> void:
_refresh_shop_npc_speech_state()
if not in_range:
_game_ui.get_rv_upgrade_shop().close_for_range_exit()
_game_ui.set_shop_prompt_visible(_can_show_any_shop_prompt())
func _on_home_state_changed() -> void:
_refresh_shop_npc_speech_state()
func _on_local_home_space_changed(
space_id: StringName,
_owner_fingerprint: String,
owner_fingerprint: String,
) -> void:
var inside_rv: bool = String(space_id).begins_with("rv:")
_refresh_active_hotbar_item()
_fishing_spot.set_fishing_environment_enabled(not inside_rv)
_game_ui.set_rv_interior_camera_mode(
inside_rv
@ -2898,6 +2961,171 @@ func _on_local_home_space_changed(
_game_ui.set_rv_storage_button_visible(
_can_show_rv_storage_button()
)
if inside_rv and not _home_exit_transition_active:
_begin_home_entry_transition(owner_fingerprint)
elif not inside_rv and _home_entry_transition_active:
_cancel_home_entry_transition()
func _begin_home_entry_transition(owner_fingerprint: String) -> void:
if owner_fingerprint.is_empty() or _home_entry_transition_active:
return
_home_entry_transition_active = true
_refresh_home_door_prompt({})
_player.set_local_input_suppressed(HOME_ENTRY_INPUT_OWNER, true)
_player.set_camera_input_suppressed(HOME_ENTRY_INPUT_OWNER, true)
var inward_direction: Vector3 = (
-_home_world.get_interior_exit_direction(owner_fingerprint)
)
_player.face_world_direction(inward_direction)
_player.begin_animation_action(PlayerType.CHARACTER_WALKING_ANIMATION)
_home_entry_walk_tween = create_tween()
_home_entry_walk_tween.set_trans(Tween.TRANS_SINE)
_home_entry_walk_tween.set_ease(Tween.EASE_IN_OUT)
_home_entry_walk_tween.tween_property(
_player,
"global_position",
_player.global_position + inward_direction * HOME_ENTRY_WALK_DISTANCE,
HOME_ENTRY_WALK_DURATION,
)
_home_entry_walk_tween.finished.connect(
_cancel_home_entry_transition,
CONNECT_ONE_SHOT,
)
func _cancel_home_entry_transition() -> void:
if _home_entry_walk_tween != null and _home_entry_walk_tween.is_valid():
_home_entry_walk_tween.kill()
_home_entry_walk_tween = null
if is_instance_valid(_player):
_player.end_animation_action()
_player.set_local_input_suppressed(HOME_ENTRY_INPUT_OWNER, false)
_player.set_camera_input_suppressed(HOME_ENTRY_INPUT_OWNER, false)
_home_entry_transition_active = false
func _maybe_begin_home_exit_transition() -> void:
if (
_home_exit_transition_active
or _home_entry_transition_active
or not _gameplay_started
or _water_recovery.is_recovery_active()
or not _fishing_spot.can_open_fishing_shop()
or _game_ui.get_player_storage().visible
or _game_ui.get_fishing_shop().visible
or _game_ui.get_decor_shop().visible
or _game_ui.get_rv_upgrade_shop().visible
):
return
var owner_fingerprint: String = (
_network_home.get_local_home_owner_fingerprint()
)
if (
owner_fingerprint.is_empty()
or not _home_world.has_avatar_crossed_interior_exit_threshold(
_player,
owner_fingerprint,
)
):
return
_begin_home_exit_transition(owner_fingerprint)
func _begin_home_exit_transition(owner_fingerprint: String) -> void:
_home_exit_transition_active = true
_home_exit_waiting_for_network = false
_home_exit_start_position = _player.global_position
_refresh_home_door_prompt({})
_player.set_local_input_suppressed(HOME_EXIT_INPUT_OWNER, true)
_player.set_camera_input_suppressed(HOME_EXIT_INPUT_OWNER, true)
var exit_direction: Vector3 = _home_world.get_interior_exit_direction(
owner_fingerprint
)
_player.face_world_direction(exit_direction)
_player.begin_animation_action(PlayerType.CHARACTER_WALKING_ANIMATION)
_home_exit_walk_tween = create_tween()
_home_exit_walk_tween.set_trans(Tween.TRANS_SINE)
_home_exit_walk_tween.set_ease(Tween.EASE_IN_OUT)
_home_exit_walk_tween.tween_property(
_player,
"global_position",
_player.global_position + exit_direction * HOME_EXIT_WALK_DISTANCE,
HOME_EXIT_WALK_DURATION,
)
_home_exit_walk_tween.finished.connect(
_on_home_exit_walk_finished,
CONNECT_ONE_SHOT,
)
func _on_home_exit_walk_finished() -> void:
_home_exit_walk_tween = null
if not _home_exit_transition_active:
return
# End the walking action on the same frame translation ends. The fade and
# authoritative relocation may take longer, but a stationary avatar must
# never keep cycling its walk animation during that wait.
_player.end_animation_action()
_game_ui.get_screen_fade().fade_to_black(HOME_EXIT_FADE_GENERATION)
func _on_home_exit_fade_transition_completed(
generation: int,
faded_to_black: bool,
) -> void:
if (
generation != HOME_EXIT_FADE_GENERATION
or not _home_exit_transition_active
):
return
if faded_to_black:
# A local host can apply the authoritative transition synchronously from
# request_exit(). Mark this first so its completion signal is not lost.
_home_exit_waiting_for_network = true
var request_started: bool = _network_home.request_exit()
if not request_started:
_home_exit_waiting_for_network = false
_player.global_position = _home_exit_start_position
_game_ui.get_screen_fade().fade_from_black(
HOME_EXIT_FADE_GENERATION
)
return
_cancel_home_exit_transition(false)
func _on_local_home_transition_finished(
accepted: bool,
_message: String,
) -> void:
if (
not _home_exit_transition_active
or not _home_exit_waiting_for_network
):
return
_home_exit_waiting_for_network = false
if not accepted:
_player.global_position = _home_exit_start_position
_game_ui.get_screen_fade().fade_from_black(HOME_EXIT_FADE_GENERATION)
func _cancel_home_exit_transition(reset_fade: bool) -> void:
if _home_exit_walk_tween != null and _home_exit_walk_tween.is_valid():
_home_exit_walk_tween.kill()
_home_exit_walk_tween = null
if is_instance_valid(_player):
_player.end_animation_action()
_player.set_local_input_suppressed(HOME_EXIT_INPUT_OWNER, false)
_player.set_camera_input_suppressed(HOME_EXIT_INPUT_OWNER, false)
if (
reset_fade
and is_instance_valid(_game_ui)
and is_instance_valid(_game_ui.get_screen_fade())
):
_game_ui.get_screen_fade().reset_immediately()
_home_exit_transition_active = false
_home_exit_waiting_for_network = false
_home_exit_start_position = Vector3.ZERO
func _on_rv_storage_requested() -> void:
@ -2913,6 +3141,7 @@ func _can_show_shop_prompt() -> bool:
and _shop_interaction.is_local_player_in_range()
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)
@ -2925,15 +3154,96 @@ func _can_show_decor_shop_prompt() -> bool:
and _decor_shop_interaction.is_local_player_in_range()
and not _game_ui.get_decor_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_rv_upgrade_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)
func _can_show_rv_upgrade_shop_prompt() -> bool:
return (
_gameplay_started
and _rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
and not _game_ui.get_rv_upgrade_shop().visible
and not _game_ui.get_fishing_shop().visible
and not _game_ui.get_decor_shop().visible
and not _water_recovery.is_recovery_active()
and _fishing_spot.can_open_fishing_shop()
)
func _can_show_any_shop_prompt() -> bool:
return (
_can_show_shop_prompt()
or _can_show_decor_shop_prompt()
or _can_show_rv_upgrade_shop_prompt()
)
func _is_any_shop_npc_in_range() -> bool:
return (
(
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
)
or (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
)
or (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
)
)
func _refresh_shop_npc_speech_state() -> void:
var interaction := _get_active_shop_npc_interaction()
var dialogue_text: String = ""
if interaction != null:
dialogue_text = interaction.get_dialogue_text(_home_state.get_tier())
_game_ui.set_shop_npc_player_in_range(
interaction != null,
dialogue_text,
)
func _get_active_shop_npc_interaction() -> FishingShopInteractionType:
# Match the interaction and prompt priority used above: RV, decor, fishing.
if (
_rv_upgrade_shop_interaction != null
and _rv_upgrade_shop_interaction.is_local_player_in_range()
):
return _rv_upgrade_shop_interaction
if (
_decor_shop_interaction != null
and _decor_shop_interaction.is_local_player_in_range()
):
return _decor_shop_interaction
if (
_shop_interaction != null
and _shop_interaction.is_local_player_in_range()
):
return _shop_interaction
return null
func _can_show_storage_prompt() -> bool:
return not _get_home_prompt().is_empty()
func _refresh_home_door_prompt(prompt: Dictionary) -> void:
# Home entry belongs to the doorway in world space. Keep the retired HUD
# prompt hidden so it cannot overlap the compact door-mounted sign.
_game_ui.set_storage_prompt_visible(false)
_home_world.set_exterior_prompt_visible(
str(prompt.get("owner_fingerprint", "")),
not prompt.is_empty(),
str(prompt.get("label", "enter home")),
)
func _can_show_rv_storage_button() -> bool:
return (
_gameplay_started
@ -2950,6 +3260,7 @@ func _get_home_prompt() -> Dictionary:
or _game_ui.get_player_storage().visible
or _game_ui.get_fishing_shop().visible
or _game_ui.get_decor_shop().visible
or _game_ui.get_rv_upgrade_shop().visible
or _water_recovery.is_recovery_active()
or not _fishing_spot.can_open_fishing_shop()
):
@ -2958,20 +3269,12 @@ func _get_home_prompt() -> Dictionary:
_network_home.get_local_home_owner_fingerprint()
)
if not owner_fingerprint.is_empty():
if _home_world.is_avatar_near_interior_exit(
_player, owner_fingerprint
):
return {
"label": "leave RV",
"anchor": _home_world.get_interior_prompt_position(
owner_fingerprint
),
}
return {}
var nearby_owner: String = _home_world.find_nearby_exterior_owner(_player)
if nearby_owner.is_empty():
return {}
return {
"label": "enter RV",
"label": "enter home",
"anchor": _home_world.get_exterior_prompt_position(nearby_owner),
"owner_fingerprint": nearby_owner,
}

View file

@ -27,6 +27,8 @@ var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {}
var _fingerprint_by_peer: Dictionary[int, String] = {}
var _authorized_owned_counts: Dictionary[int, Dictionary] = {}
var _authorization_result_owner: Dictionary[String, int] = {}
var _authorized_home_tiers: Dictionary[int, int] = {}
var _home_upgrade_result_owner: Dictionary[String, int] = {}
var _interior_slots_by_space: Dictionary[StringName, Dictionary] = {}
var _next_interior_index: int = 0
var _suppress_local_submission: bool = false
@ -103,6 +105,17 @@ func get_authoritative_owned_count(
return int(owned.get(String(decor_id), 0))
func get_authoritative_home_tier(peer_id: int) -> int:
if _authorized_home_tiers.has(peer_id):
return int(_authorized_home_tiers[peer_id])
var fingerprint: String = _fingerprint_for_peer(peer_id)
var manifest: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
if manifest.is_empty():
return -1
var home: Dictionary = manifest.get("home", {})
return int(home.get("tier", -1))
func place_local_decor(
decor_id: StringName,
position: Vector3,
@ -337,6 +350,35 @@ func finalize_decor_purchase(
_authorized_owned_counts.erase(peer_id)
func authorize_home_upgrade(
peer_id: int,
resulting_tier: int,
result_id: String,
) -> void:
if (
not _session.is_host()
or peer_id <= 0
or result_id.is_empty()
or resulting_tier < PlayerHomeStateType.STARTER_TIER
or resulting_tier > PlayerHomeStateType.MAX_TIER
):
return
_authorized_home_tiers[peer_id] = resulting_tier
_home_upgrade_result_owner[result_id] = peer_id
func finalize_home_upgrade(
peer_id: int,
result_id: String,
applied: bool,
) -> void:
if _home_upgrade_result_owner.get(result_id, 0) != peer_id:
return
_home_upgrade_result_owner.erase(result_id)
if not applied:
_authorized_home_tiers.erase(peer_id)
func refresh_world_presentations() -> void:
if _world_service == null:
return
@ -442,7 +484,7 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
not NetworkHomeProtocolType.validate_manifest_request(data)
or str(data["session_id"]) != _session.get_session_id()
):
_reject_manifest(peer_id, "The RV update was invalid.")
_reject_manifest(peer_id, "The home update was invalid.")
return
var record := _session.get_peer_record(peer_id)
var fingerprint: String = str(data["owner_fingerprint"])
@ -456,7 +498,7 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
data["sender_signature"],
)
):
_reject_manifest(peer_id, "The RV owner could not be verified.")
_reject_manifest(peer_id, "The home owner could not be verified.")
return
var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
@ -464,7 +506,7 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
var prior: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
var is_first_manifest: bool = prior.is_empty()
if not _valid_manifest_revision(peer_id, prior, home_data):
_reject_manifest(peer_id, "The RV update was rejected by the host.")
_reject_manifest(peer_id, "The home update was rejected by the host.")
return
if not _moved_decor_clear_of_players(fingerprint, prior, home_data):
_reject_manifest(peer_id, "A player is in the way of that decor.")
@ -472,7 +514,7 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
var manifest: Dictionary
if prior.is_empty():
if _manifests_by_fingerprint.size() >= MAX_MANIFESTS:
_reject_manifest(peer_id, "This room cannot place another RV.")
_reject_manifest(peer_id, "This room cannot place another home.")
return
var reserved: Array[Transform3D] = []
for existing: Dictionary in _manifests_by_fingerprint.values():
@ -523,10 +565,17 @@ func _valid_manifest_revision(
# no prior room ledger with which to compare its first manifest.
return true
var prior_home: Dictionary = prior_manifest["home"]
var prior_tier: int = int(prior_home["tier"])
var next_tier: int = int(home_data["tier"])
var tier_is_authorized: bool = (
next_tier != prior_tier
and int(_authorized_home_tiers.get(peer_id, -1)) == next_tier
)
if (
int(home_data["revision"]) <= int(prior_home["revision"])
or int(home_data["tier"]) != int(prior_home["tier"])
or str(home_data["exterior_id"]) != str(prior_home["exterior_id"])
or (next_tier != prior_tier and not tier_is_authorized)
or str(home_data["exterior_id"])
!= str(PlayerHomeStateType.exterior_id_for_tier(next_tier))
):
return false
var prior_owned: Dictionary = prior_home["owned_decor"]
@ -619,6 +668,11 @@ static func _placement_position(placement: Dictionary) -> Vector3:
func _consume_owned_authorizations(peer_id: int, home_data: Dictionary) -> void:
if (
_authorized_home_tiers.has(peer_id)
and int(home_data["tier"]) == int(_authorized_home_tiers[peer_id])
):
_authorized_home_tiers.erase(peer_id)
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
if authorized.is_empty():
return
@ -1027,7 +1081,7 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void:
data["sender_signature"],
)
):
_send_transition_rejection(peer_id, "The RV transition was invalid.")
_send_transition_rejection(peer_id, "The home transition was invalid.")
return
var owner_fingerprint: String = str(data["owner_fingerprint"])
var action: int = int(data["action"])
@ -1036,7 +1090,7 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void:
owner_fingerprint, {}
)
if avatar == null or manifest.is_empty():
_send_transition_rejection(peer_id, "That RV is unavailable.")
_send_transition_rejection(peer_id, "That home is unavailable.")
return
if action == NetworkHomeProtocolType.TransitionAction.ENTER:
var record := _session.get_peer_record(peer_id)
@ -1052,7 +1106,7 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void:
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "You cannot enter that RV.")
_send_transition_rejection(peer_id, "You cannot enter that home.")
return
var interior_space: StringName = (
NetworkHomeProtocolType.make_space_id(owner_fingerprint)
@ -1076,7 +1130,7 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void:
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "Move closer to the RV door.")
_send_transition_rejection(peer_id, "Move closer to the home door.")
return
_apply_host_transition(
peer_id,
@ -1282,6 +1336,10 @@ func _on_peer_removed(peer_id: int) -> void:
var fingerprint: String = _fingerprint_by_peer.get(peer_id, "")
_fingerprint_by_peer.erase(peer_id)
_authorized_owned_counts.erase(peer_id)
_authorized_home_tiers.erase(peer_id)
for result_id: String in _home_upgrade_result_owner.keys():
if int(_home_upgrade_result_owner[result_id]) == peer_id:
_home_upgrade_result_owner.erase(result_id)
if fingerprint.is_empty():
return
if _session.is_host():
@ -1336,6 +1394,8 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_fingerprint_by_peer.clear()
_authorized_owned_counts.clear()
_authorization_result_owner.clear()
_authorized_home_tiers.clear()
_home_upgrade_result_owner.clear()
_publish_queued = false
_last_submitted_local_home_revision = -1
_pending_local_decor_transactions.clear()

View file

@ -47,6 +47,7 @@ const FRIENDS_CAPABILITY: String = "friends_v1"
const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2"
const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2"
const HOME_CAPABILITY: String = "portable_home_v1"
const HOME_UPGRADE_CAPABILITY: String = "home_upgrade_v1"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
@ -205,6 +206,7 @@ static func make_client_hello(
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
HOME_UPGRADE_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -346,6 +348,7 @@ static func make_server_hello(
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
HOME_UPGRADE_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -340,6 +340,7 @@ func _register_player_host() -> void:
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -668,6 +669,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -1638,6 +1640,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)

View file

@ -5,6 +5,7 @@ const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_shop_v1"
const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1"
const DECOR_CAPABILITY: StringName = &"portable_home_v1"
const HOME_UPGRADE_CAPABILITY: StringName = &"home_upgrade_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
@ -15,7 +16,7 @@ enum ProductCategory {
ROD,
REEL_SPEED_UPGRADE,
BARRIER_POWER_UPGRADE,
COOLER_CAPACITY_UPGRADE,
HOME_UPGRADE,
ART_KIT,
ART_UPGRADE,
BACKPACK_CAPACITY_UPGRADE,

View file

@ -12,9 +12,10 @@ const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const FISHING_SHOP_ID: StringName = &"main_fishing_shop"
const DECOR_SHOP_ID: StringName = &"rv_decor_shop"
const RV_UPGRADE_SHOP_ID: StringName = &"rv_upgrade_shop"
const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade"
const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
const HOME_UPGRADE_PRODUCT_ID: StringName = &"starter_rv_upgrade"
const BACKPACK_PRODUCT_ID: StringName = &"backpack_capacity_upgrade"
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
@ -34,6 +35,7 @@ var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _interaction: FishingShopInteraction
var _decor_interaction: DecorShopInteraction
var _rv_upgrade_interaction: FishingShopInteraction
var _wallet: PlayerWallet
var _bag: PlayerBag
var _item_catalog: ItemCatalog
@ -71,6 +73,7 @@ func setup(
home_state: PlayerHomeState = null,
network_home: NetworkHomeService = null,
decor_interaction: DecorShopInteraction = null,
rv_upgrade_interaction: FishingShopInteraction = null,
) -> void:
_session = session
_spawn_service = spawn_service
@ -88,6 +91,7 @@ func setup(
_home_state = home_state
_network_home = network_home
_decor_interaction = decor_interaction
_rv_upgrade_interaction = rv_upgrade_interaction
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -104,6 +108,12 @@ func set_decor_shop_interaction(
_decor_interaction = interaction
func set_rv_upgrade_shop_interaction(
interaction: FishingShopInteraction,
) -> void:
_rv_upgrade_interaction = interaction
func can_request_purchase() -> bool:
return (
_session != null
@ -154,6 +164,19 @@ func can_request_decor_purchase() -> bool:
)
func can_request_home_upgrade() -> bool:
return (
can_request_purchase()
and _home_state != null
and (
_session.is_host()
or _session.supports_server_capability(
NetworkShopProtocol.HOME_UPGRADE_CAPABILITY
)
)
)
func is_local_purchase_pending() -> bool:
return not _pending_local_request.is_empty()
@ -204,12 +227,12 @@ func request_barrier_power_upgrade() -> String:
)
func request_cooler_capacity_upgrade() -> String:
func request_home_upgrade() -> String:
return _request_purchase(
COOLER_PRODUCT_ID,
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE,
HOME_UPGRADE_PRODUCT_ID,
NetworkShopProtocol.ProductCategory.HOME_UPGRADE,
1,
_cooler_capacity.get_level() if _cooler_capacity != null else 0
_home_state.get_tier() if _home_state != null else 0
)
@ -308,6 +331,15 @@ func _request_purchase(
product_id, category, 0, 0,
)
return ""
if (
category == NetworkShopProtocol.ProductCategory.HOME_UPGRADE
and not can_request_home_upgrade()
):
local_purchase_finished.emit(
"", false, "RV upgrades require a newer server.",
product_id, category, 0, 0,
)
return ""
if (
category == NetworkShopProtocol.ProductCategory.DECOR
and not can_request_decor_purchase()
@ -318,11 +350,7 @@ func _request_purchase(
)
return ""
var request_id: String = _new_id("shop")
var shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
var shop_id: StringName = _shop_id_for_category(category)
var request: Dictionary = {
"request_id": request_id,
"session_id": _session.get_session_id(),
@ -402,9 +430,7 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
if not _is_shop_available_for_peer(peer_id, requested_shop_id):
var avatar: Player = _spawn_service.get_avatar(peer_id)
var requested_interaction: FishingShopInteraction = (
_decor_interaction
if requested_shop_id == DECOR_SHOP_ID
else _interaction
_interaction_for_shop(requested_shop_id)
)
var message: String = (
"The shop is unavailable."
@ -416,16 +442,22 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
var result: Dictionary = _build_authoritative_result(peer_id, data)
if (
bool(result.get("accepted", false))
and int(result.get("category", -1))
== NetworkShopProtocol.ProductCategory.DECOR
and _network_home != null
):
_network_home.authorize_decor_purchase(
peer_id,
StringName(str(result["product_id"])),
int(result["resulting_state"]),
str(result["result_id"]),
)
match int(result.get("category", -1)):
NetworkShopProtocol.ProductCategory.DECOR:
_network_home.authorize_decor_purchase(
peer_id,
StringName(str(result["product_id"])),
int(result["resulting_state"]),
str(result["result_id"]),
)
NetworkShopProtocol.ProductCategory.HOME_UPGRADE:
_network_home.authorize_home_upgrade(
peer_id,
int(result["resulting_state"]),
str(result["result_id"]),
)
_pending_by_peer[peer_id] = request_id
_record_and_send(peer_id, result)
@ -448,14 +480,19 @@ func _build_authoritative_result(
)
if authoritative_count >= 0:
current_state = authoritative_count
elif (
category == NetworkShopProtocol.ProductCategory.HOME_UPGRADE
and _network_home != null
):
var authoritative_tier: int = (
_network_home.get_authoritative_home_tier(peer_id)
)
if authoritative_tier >= 0:
current_state = authoritative_tier
var cost: int = -1
var resulting_state: int = current_state
var rejection: String = ""
var expected_shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
var expected_shop_id: StringName = _shop_id_for_category(category)
if StringName(str(request["shop_id"])) != expected_shop_id:
rejection = "The shop is unavailable."
elif quantity < 1:
@ -569,18 +606,14 @@ func _build_authoritative_result(
]
)
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
NetworkShopProtocol.ProductCategory.HOME_UPGRADE:
if (
product_id != COOLER_PRODUCT_ID
or current_state >= PlayerCoolerCapacity.MAX_LEVEL
product_id != HOME_UPGRADE_PRODUCT_ID
or current_state >= PlayerHomeState.MAX_TIER
):
rejection = "Upgrade is already at maximum."
else:
cost = (
PlayerCoolerCapacity.EXPANSION_COSTS[
current_state
]
)
cost = PlayerHomeState.HOME_UPGRADE_COSTS[current_state]
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
@ -874,10 +907,12 @@ func _validate_local_result(data: Dictionary) -> String:
return "Purchase could not be completed."
if _upgrades.get_next_barrier_power_cost() != cost:
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
if _cooler_capacity.get_level() != expected_state:
return "Purchase could not be completed."
if _cooler_capacity.get_next_cost() != cost:
NetworkShopProtocol.ProductCategory.HOME_UPGRADE:
if (
product_id != HOME_UPGRADE_PRODUCT_ID
or _home_state.get_tier() != expected_state
or _home_state.get_next_upgrade_cost() != cost
):
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
@ -947,8 +982,11 @@ func _apply_local_product(data: Dictionary) -> bool:
return _upgrades.purchase_reel_speed(_wallet)
NetworkShopProtocol.ProductCategory.BARRIER_POWER_UPGRADE:
return _upgrades.purchase_barrier_power(_wallet)
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
return _cooler_capacity.purchase(_wallet)
NetworkShopProtocol.ProductCategory.HOME_UPGRADE:
return (
_wallet.debit(int(data["total_cost"]))
and _home_state.upgrade_to_next_tier()
)
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
return (
_inventory_layout != null
@ -1061,6 +1099,9 @@ func _handle_acknowledgement(
_network_home.finalize_decor_purchase(
peer_id, result_id, applied
)
_network_home.finalize_home_upgrade(
peer_id, result_id, applied
)
_acknowledged_results[result_id] = true
_bound_dictionary(_acknowledged_results)
if _pending_by_peer.get(peer_id, "") == request_id:
@ -1071,11 +1112,7 @@ func _is_shop_available_for_peer(
peer_id: int,
shop_id: StringName,
) -> bool:
var interaction: FishingShopInteraction = (
_decor_interaction
if shop_id == DECOR_SHOP_ID
else _interaction
)
var interaction: FishingShopInteraction = _interaction_for_shop(shop_id)
if (
_session == null
or not _session.is_host()
@ -1097,6 +1134,24 @@ func _is_shop_available_for_peer(
)
func _shop_id_for_category(category: int) -> StringName:
match category:
NetworkShopProtocol.ProductCategory.DECOR:
return DECOR_SHOP_ID
NetworkShopProtocol.ProductCategory.HOME_UPGRADE:
return RV_UPGRADE_SHOP_ID
return FISHING_SHOP_ID
func _interaction_for_shop(shop_id: StringName) -> FishingShopInteraction:
match shop_id:
DECOR_SHOP_ID:
return _decor_interaction
RV_UPGRADE_SHOP_ID:
return _rv_upgrade_interaction
return _interaction
func _on_peer_removed(peer_id: int) -> void:
_request_ledgers.erase(peer_id)
_pending_by_peer.erase(peer_id)

View file

@ -90,6 +90,7 @@ const CATCH_SHOWCASE_INPUT_OWNER: StringName = &"catch_showcase"
# safe IDs so newer clients can extend it, but Player only presents actions
# explicitly approved by this catalog.
const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = [
CHARACTER_WALKING_ANIMATION,
CHARACTER_CASTING_ANIMATION,
CHARACTER_NET_DRAW_ANIMATION,
CHARACTER_NET_STRIKE_ANIMATION,
@ -436,6 +437,11 @@ var _rv_previous_camera_environment: Environment
@export var maximum_zoom: float = 8.0
@export var zoom_step: float = 0.75
@export var zoom_smoothing: float = 12.0
@export_range(0.0, 1.0, 0.01) var fishing_reel_zoom_amount: float = 0.22
@export_range(0.0, 1.5, 0.01) var fishing_barrier_break_zoom: float = 0.38
@export_range(0.05, 1.0, 0.01) var fishing_break_zoom_decay: float = 0.34
@export_range(0.0, 0.2, 0.005) var fishing_barrier_hit_shake: float = 0.018
@export_range(0.0, 0.3, 0.005) var fishing_barrier_break_shake: float = 0.045
@export_range(0.1, 12.0, 0.1) var controller_zoom_speed: float = 4.0
@export_range(0.0, 1.0, 0.01) var controller_trigger_threshold: float = 0.55
@export_range(1.0, 40.0, 0.5) var free_camera_speed: float = 10.0
@ -497,6 +503,12 @@ var _remote_recovery_presentation_active: bool = false
var _remote_recovery_visual_origin: Vector3
var _remote_recovery_elapsed: float = 0.0
var _target_zoom: float = 5.0
var _fishing_reel_camera_active: bool = false
var _fishing_break_zoom_offset: float = 0.0
var _fishing_camera_shake_remaining: float = 0.0
var _fishing_camera_shake_duration: float = 0.0
var _fishing_camera_shake_strength: float = 0.0
var _fishing_camera_shake_phase: float = 0.0
var _showcase_rod_visibility: bool = true
var _showcase_net_visibility: bool = false
var _showcase_shovel_visibility: bool = false
@ -1162,6 +1174,7 @@ func _process(delta: float) -> void:
)
if not local_control_enabled:
return
_update_fishing_camera_effects(delta)
if _free_camera_active:
if _is_camera_input_enabled():
_rotate_free_camera(
@ -1190,13 +1203,68 @@ func _process(delta: float) -> void:
)
var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta)
var fishing_zoom_offset: float = (
fishing_reel_zoom_amount
if _fishing_reel_camera_active
else 0.0
) + _fishing_break_zoom_offset
var effective_target_zoom: float = clampf(
_target_zoom - fishing_zoom_offset,
1.25 if _rv_interior_camera_mode else minimum_zoom,
16.0 if _rv_interior_camera_mode else maximum_zoom,
)
_spring_arm.spring_length = lerpf(
_spring_arm.spring_length,
_target_zoom,
effective_target_zoom,
zoom_weight
)
func set_fishing_reel_camera_active(active: bool) -> void:
_fishing_reel_camera_active = active
func play_fishing_barrier_camera_feedback(barrier_broken: bool) -> void:
if not local_control_enabled or _free_camera_active:
return
_fishing_camera_shake_strength = (
fishing_barrier_break_shake
if barrier_broken
else fishing_barrier_hit_shake
)
_fishing_camera_shake_duration = 0.20 if barrier_broken else 0.10
_fishing_camera_shake_remaining = _fishing_camera_shake_duration
if barrier_broken:
_fishing_break_zoom_offset = fishing_barrier_break_zoom
func _update_fishing_camera_effects(delta: float) -> void:
_fishing_break_zoom_offset = move_toward(
_fishing_break_zoom_offset,
0.0,
(
fishing_barrier_break_zoom
/ maxf(fishing_break_zoom_decay, 0.05)
) * delta,
)
if _fishing_camera_shake_remaining <= 0.0:
_camera.h_offset = 0.0
_camera.v_offset = 0.0
return
_fishing_camera_shake_remaining = maxf(
_fishing_camera_shake_remaining - delta,
0.0,
)
_fishing_camera_shake_phase += delta * 92.0
var decay: float = (
_fishing_camera_shake_remaining
/ maxf(_fishing_camera_shake_duration, 0.001)
)
var strength: float = _fishing_camera_shake_strength * decay
_camera.h_offset = sin(_fishing_camera_shake_phase) * strength
_camera.v_offset = cos(_fishing_camera_shake_phase * 1.31) * strength * 0.7
func _update_blink(delta: float) -> void:
if _fighting_visual_active:
return
@ -3052,6 +3120,19 @@ func set_camera_active(active: bool) -> void:
_camera.current = active
func face_world_direction(direction: Vector3) -> void:
var horizontal_direction := Vector3(direction.x, 0.0, direction.z)
if horizontal_direction.is_zero_approx() or _visuals == null:
return
horizontal_direction = horizontal_direction.normalized()
var target_world_yaw: float = atan2(
-horizontal_direction.x,
-horizontal_direction.z,
)
_visuals.rotation.y = target_world_yaw - global_rotation.y
_network_target_visual_yaw = _visuals.rotation.y
func apply_camera_settings(
new_mouse_sensitivity: float,
new_controller_sensitivity: float,

View file

@ -2,62 +2,77 @@ class_name PlayerCoolerCapacity
extends Node
const PlayerWalletType = preload("res://economy/player_wallet.gd")
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
signal capacity_changed(level: int, capacity: int)
## Kept under the historical class name so existing saves and network product
## identifiers migrate without losing their purchased level. This progression
## now controls the player's private storage box rather than carried catches.
## Compatibility adapter for saves and systems that still refer to the old
## cooler-capacity object. Storage is now derived exclusively from the player's
## home tier; this object is not independently purchasable.
const CAPACITIES: Array[int] = [9, 18, 27, 36, 45, 54, 63, 72]
const EXPANSION_COSTS: Array[int] = [75, 175, 400, 850, 1500, 2500, 4000]
const MAX_LEVEL: int = 7
const MAX_CAPACITY: int = 72
var _capacity_level: int = 0
var _home_state: PlayerHomeStateType
var _legacy_capacity_level: int = 0
func bind_home_state(home_state: PlayerHomeStateType) -> void:
if _home_state == home_state:
return
if (
_home_state != null
and _home_state.changed.is_connected(_on_home_changed)
):
_home_state.changed.disconnect(_on_home_changed)
_home_state = home_state
if (
_home_state != null
and not _home_state.changed.is_connected(_on_home_changed)
):
_home_state.changed.connect(_on_home_changed)
_on_home_changed()
func get_level() -> int:
return _capacity_level
return _legacy_capacity_level
func get_capacity() -> int:
return CAPACITIES[_capacity_level]
if _home_state != null:
# A new home upgrade grants its authored capacity. Older saves retain any
# separately purchased storage level so reducing the first RV tier cannot
# hide or discard items that were already placed in later slots.
return maxi(
_home_state.get_storage_capacity(),
CAPACITIES[clampi(_legacy_capacity_level, 0, MAX_LEVEL)],
)
return CAPACITIES[clampi(_legacy_capacity_level, 0, MAX_LEVEL)]
func get_next_capacity() -> int:
if _capacity_level >= MAX_LEVEL:
return -1
return CAPACITIES[_capacity_level + 1]
return -1
func get_next_cost() -> int:
if _capacity_level >= MAX_LEVEL:
return -1
return EXPANSION_COSTS[_capacity_level]
return -1
func can_purchase(wallet: PlayerWalletType) -> bool:
var cost: int = get_next_cost()
return wallet != null and cost >= 0 and wallet.can_afford(cost)
func can_purchase(_wallet: PlayerWalletType) -> bool:
return false
func purchase(wallet: PlayerWalletType) -> bool:
if not can_purchase(wallet):
return false
var cost: int = get_next_cost()
if not wallet.debit(cost):
return false
_capacity_level += 1
capacity_changed.emit(_capacity_level, get_capacity())
return true
func purchase(_wallet: PlayerWalletType) -> bool:
return false
func restore_level(level: int) -> bool:
var validated: int = clampi(level, 0, MAX_LEVEL)
var changed: bool = _capacity_level != validated
_capacity_level = validated
var changed: bool = _legacy_capacity_level != validated
_legacy_capacity_level = validated
if changed:
capacity_changed.emit(_capacity_level, get_capacity())
capacity_changed.emit(get_level(), get_capacity())
return true
@ -66,4 +81,8 @@ func reset_to_defaults() -> void:
func to_save_data() -> Dictionary:
return {"capacity_level": _capacity_level}
return {"capacity_level": get_level()}
func _on_home_changed() -> void:
capacity_changed.emit(get_level(), get_capacity())

View file

@ -338,6 +338,11 @@ func load_player_data() -> bool:
snapshot.reel_speed_level,
snapshot.barrier_power_level
)
# Home tier is now the authority for private-storage capacity, so restore it
# before the compatibility capacity adapter and inventory layout.
var home_restored: bool = _home_state.restore_from_save_data(
snapshot.home_data
)
var cooler_restored: bool = _cooler_capacity.restore_level(
snapshot.cooler_capacity_level
)
@ -368,9 +373,6 @@ func load_player_data() -> bool:
snapshot.world_weather_seconds_remaining,
)
var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data)
var home_restored: bool = _home_state.restore_from_save_data(
snapshot.home_data
)
_is_restoring = false
if (
not inventory_restored
@ -1324,6 +1326,26 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
PlayerCoolerCapacityType.MAX_LEVEL,
"cooler.capacity_level"
)
# Before homes had upgrade tiers, storage was purchased separately. Preserve
# those saves by granting the starter RV to anybody who owned at least one
# legacy expansion. PlayerCoolerCapacity also retains the purchased legacy
# capacity so entries beyond the new RV's first 18 slots remain accessible.
if (
snapshot.cooler_capacity_level > 0
and int(snapshot.home_data.get("tier", 0))
== PlayerHomeStateType.STARTER_TIER
):
var migrated_home: Dictionary = home_data.duplicate(true)
migrated_home["tier"] = PlayerHomeStateType.STARTER_RV_TIER
migrated_home["exterior_id"] = String(
PlayerHomeStateType.STARTER_RV_EXTERIOR_ID
)
var sanitized_migrated_home := (
PlayerHomeStateType.sanitize_save_data(migrated_home)
)
if sanitized_migrated_home.is_empty():
return null
snapshot.home_data = sanitized_migrated_home
snapshot.art_unlock_mask = _read_integer(
art_data.get("unlock_mask"),
0,

View file

@ -53,7 +53,7 @@ func _run() -> void:
)
assert(player != null)
assert(catalog != null and catalog.candidates.size() == 316)
assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 313)
assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 314)
assert(sale_service != null)
assert(shop_service != null)
assert(session != null and session.is_host())
@ -82,7 +82,7 @@ func _run() -> void:
main, player, catalog, sale_service, reservations
)
_test_host_backpack_purchase(player, shop_service)
_test_host_storage_purchase(player, shop_service)
await _test_host_home_upgrade(main, player, shop_service)
assert(not sale_service.is_local_sale_pending())
assert(not shop_service.is_local_purchase_pending())
@ -175,7 +175,6 @@ func _run_multiplayer_client() -> void:
assert(player.inventory_layout.restore_backpack_level(
PlayerInventoryLayout.MAX_BACKPACK_LEVEL
))
assert(player.cooler_capacity.restore_level(PlayerCoolerCapacity.MAX_LEVEL))
player.inventory.set_inventory_layout(null)
var catch_ids: Array[StringName] = []
for fish: FishData in catalog.candidates:
@ -202,7 +201,6 @@ func _run_multiplayer_client() -> void:
assert(not player.inventory.contains_catch_id(catch_id))
assert(not sale_service.is_local_sale_pending())
assert(player.inventory_layout.restore_backpack_level(0))
assert(player.cooler_capacity.restore_level(0))
var interaction := main.get("_shop_interaction") as FishingShopInteraction
player.global_position = interaction.global_position
@ -567,27 +565,49 @@ func _test_host_backpack_purchase(
assert(layout.get_next_backpack_cost() == -1)
func _test_host_storage_purchase(
func _test_host_home_upgrade(
main: Node,
player: Player,
shop_service: NetworkShopService,
) -> void:
var interaction := main.get(
"_rv_upgrade_shop_interaction"
) as RVUpgradeInteraction
var home_state := main.get("_home_state") as PlayerHomeState
var capacity := player.cooler_capacity
assert(interaction != null and home_state != null)
player.global_position = interaction.global_position
for _frame: int in 4:
await physics_frame
assert(interaction.is_avatar_in_range(player))
assert(home_state.get_tier() == PlayerHomeState.STARTER_TIER)
assert(
interaction.get_dialogue_text(home_state.get_tier())
== RVUpgradeInteraction.TENT_DIALOGUE_TEXT
)
assert(capacity.get_capacity() == 9)
var total_cost: int = 0
for cost: int in PlayerCoolerCapacity.EXPANSION_COSTS:
total_cost += cost
if player.wallet.get_balance() < total_cost:
assert(player.wallet.credit(total_cost - player.wallet.get_balance()))
var upgrade_cost: int = home_state.get_next_upgrade_cost()
assert(upgrade_cost == PlayerHomeState.HOME_UPGRADE_COSTS.front())
assert(upgrade_cost == 50)
if player.wallet.get_balance() < upgrade_cost:
assert(player.wallet.credit(
upgrade_cost - player.wallet.get_balance()
))
var balance_before := player.wallet.get_balance()
for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]:
_shop_result.clear()
assert(not shop_service.request_cooler_capacity_upgrade().is_empty())
assert(
not _shop_result.is_empty() and bool(_shop_result[1]),
"Cooler purchase failed: %s" % [_shop_result],
)
assert(capacity.get_capacity() == expected_capacity)
assert(player.wallet.get_balance() == balance_before - total_cost)
_shop_result.clear()
assert(not shop_service.request_home_upgrade().is_empty())
assert(
not _shop_result.is_empty() and bool(_shop_result[1]),
"Home purchase failed: %s" % [_shop_result],
)
assert(home_state.get_tier() == PlayerHomeState.STARTER_RV_TIER)
assert(
interaction.get_dialogue_text(home_state.get_tier())
== RVUpgradeInteraction.RV_DIALOGUE_TEXT
)
assert(capacity.get_capacity() == 18)
assert(player.wallet.get_balance() == balance_before - upgrade_cost)
assert(home_state.get_next_upgrade_cost() == -1)
assert(capacity.get_next_cost() == -1)

View file

@ -49,6 +49,7 @@ func _run_host() -> void:
host_fingerprint
)
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
assert(_faces_away_from_home(host_avatar, home_world, host_fingerprint))
assert(network_home.request_enter(host_fingerprint))
await physics_frame
host_avatar.global_position = home_world.get_interior_exit_position(
@ -57,6 +58,7 @@ func _run_host() -> void:
assert(network_home.request_exit())
await physics_frame
assert(_same_door_position(host_avatar.global_position, host_spawn.origin))
assert(_faces_away_from_home(host_avatar, home_world, host_fingerprint))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
@ -70,6 +72,9 @@ func _run_host() -> void:
assert(_same_door_position(
remote_avatar.global_position, remote_spawn.origin
))
assert(_faces_away_from_home(
remote_avatar, home_world, remote_fingerprint
))
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
while (
@ -118,6 +123,7 @@ func _run_client() -> void:
assert(_same_door_position(
player.global_position, exterior_spawn.origin
))
assert(_faces_away_from_home(player, home_world, fingerprint))
print("Home spawn multiplayer client validation: PASS")
await _cleanup(main, session)
@ -151,6 +157,23 @@ func _wait_for_home(
return false
func _faces_away_from_home(
avatar: Player,
home_world: HomeWorldService,
fingerprint: String,
) -> bool:
var exterior_basis: Basis = home_world.get_exterior_transform(
fingerprint
).basis
var outward_direction := exterior_basis.z
outward_direction.y = 0.0
if outward_direction.is_zero_approx():
return false
return avatar.get_facing_direction().dot(
outward_direction.normalized()
) >= 0.99
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:

View file

@ -143,7 +143,7 @@ func _validate_save_round_trip(
assert(catalog != null)
assert(catalog.candidates.size() == 316)
var active_species := LogbookCatalog.ordered_species(catalog.candidates)
assert(active_species.size() == 313)
assert(active_species.size() == 314)
# Keep this runtime save fixture within maximum player capacity while the
# focused catalog validation exercises every active species individually.
var round_trip_species: Array[FishData] = []
@ -164,12 +164,14 @@ func _validate_save_round_trip(
player.inventory_layout.restore_backpack_level(
PlayerInventoryLayout.MAX_BACKPACK_LEVEL
)
player.cooler_capacity.restore_level(PlayerCoolerCapacity.MAX_LEVEL)
var home_state := main.get("_home_state") as PlayerHomeState
assert(home_state != null)
assert(home_state.upgrade_to_next_tier())
assert(
player.inventory_layout.get_backpack_level()
== PlayerInventoryLayout.MAX_BACKPACK_LEVEL
)
assert(player.cooler_capacity.get_level() == PlayerCoolerCapacity.MAX_LEVEL)
assert(player.cooler_capacity.get_capacity() == 18)
player.inventory.set_inventory_layout(null)
for index: int in 4:
_add_test_catch(player, round_trip_species[index])

View file

@ -0,0 +1,10 @@
shader_type canvas_item;
uniform float intensity : hint_range(0.0, 1.0) = 0.0;
void fragment() {
vec2 centered = UV * 2.0 - 1.0;
centered.x *= 0.56;
float edge = smoothstep(0.42, 1.02, length(centered));
COLOR = vec4(0.16, 0.018, 0.025, edge * intensity * 0.72);
}

View file

@ -0,0 +1 @@
uid://c1qcj3m4b10x4

View file

@ -15,9 +15,6 @@ const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
)
const PlayerType = preload("res://player/player.gd")
const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd"
)
const PlayerArtUnlocksType = preload(
"res://progression/player_art_unlocks.gd"
)
@ -154,9 +151,6 @@ const ROD_PRICE_ICON_SIZE: float = 22.0
@onready var _barrier_price_bubble: PanelContainer = %BarrierPriceBubble
@onready var _barrier_purchase: Button = %BarrierPurchase
@onready var _supplies_list: VBoxContainer = %SuppliesList
@onready var _cooler_cost: CurrencyAmount = %CoolerCost
@onready var _cooler_price_bubble: PanelContainer = %CoolerPriceBubble
@onready var _cooler_purchase: Button = %CoolerPurchase
@onready var _backpack_cost: CurrencyAmount = %BackpackCost
@onready var _backpack_price_bubble: PanelContainer = %BackpackPriceBubble
@onready var _backpack_purchase: Button = %BackpackPurchase
@ -172,7 +166,6 @@ var _inventory: FishInventory
var _hotbar: PlayerHotbar
var _inventory_layout: PlayerInventoryLayout
var _item_catalog: ItemCatalogType
var _cooler_capacity: PlayerCoolerCapacityType
var _art_unlocks: PlayerArtUnlocksType
var _network_shop: NetworkShopService
var _network_sale: NetworkSaleService
@ -202,7 +195,6 @@ func _ready() -> void:
%CloseButton.pressed.connect(close_shop)
_reel_purchase.pressed.connect(_purchase_reel_speed)
_barrier_purchase.pressed.connect(_purchase_barrier_power)
_cooler_purchase.pressed.connect(_purchase_cooler_capacity)
_backpack_purchase.pressed.connect(_purchase_backpack_capacity)
@ -353,7 +345,6 @@ func _focus_shop_section() -> void:
for upgrade_button: Button in [
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
_backpack_purchase,
]:
if not upgrade_button.disabled:
@ -509,7 +500,6 @@ func _apply_shop_styles() -> void:
for panel: PanelContainer in [
_reel_price_bubble,
_barrier_price_bubble,
_cooler_price_bubble,
_backpack_price_bubble,
]:
var price_style := UtilityPageStyleType.rounded_style(
@ -526,7 +516,6 @@ func _apply_shop_styles() -> void:
for price: CurrencyAmount in [
_reel_cost,
_barrier_cost,
_cooler_cost,
_backpack_cost,
]:
var amount_label := price.get_amount_label()
@ -538,7 +527,6 @@ func _apply_shop_styles() -> void:
%CloseButton,
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
_backpack_purchase,
]:
UtilityPageStyleType.apply_ocean_button(button)
@ -559,7 +547,6 @@ func setup(
hotbar: PlayerHotbar,
inventory_layout: PlayerInventoryLayout,
item_catalog: ItemCatalogType,
cooler_capacity: PlayerCoolerCapacityType,
art_unlocks: PlayerArtUnlocksType,
network_shop: NetworkShopService,
network_sale: NetworkSaleService,
@ -576,7 +563,6 @@ func setup(
_hotbar = hotbar
_inventory_layout = inventory_layout
_item_catalog = item_catalog
_cooler_capacity = cooler_capacity
_art_unlocks = art_unlocks
_network_shop = network_shop
_network_sale = network_sale
@ -612,10 +598,6 @@ func setup(
_upgrades.upgrades_changed.connect(_on_upgrades_changed)
if not _bag.contents_changed.is_connected(_on_bag_changed):
_bag.contents_changed.connect(_on_bag_changed)
if not _cooler_capacity.capacity_changed.is_connected(
_on_cooler_capacity_changed
):
_cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed)
if not _inventory_layout.backpack_capacity_changed.is_connected(
_on_backpack_capacity_changed
):
@ -836,7 +818,6 @@ func _refresh_all() -> void:
_refresh_wallet()
_refresh_upgrades()
_refresh_supplies()
_refresh_cooler_capacity()
_refresh_backpack_capacity()
@ -1587,41 +1568,6 @@ func _add_supply_icon_tile(
return tile_host
func _refresh_cooler_capacity() -> void:
if _cooler_capacity == null:
return
var level: int = _cooler_capacity.get_level()
var cost: int = _cooler_capacity.get_next_cost()
var next_capacity: int = _cooler_capacity.get_next_capacity()
_cooler_purchase.disabled = (
cost < 0
or _transaction_in_progress
or _closing
or _network_shop == null
or not _network_shop.can_request_purchase()
or not _cooler_capacity.can_purchase(_wallet)
)
var cooler_effect: String
if cost < 0:
cooler_effect = "%d slots · maximum level" % (
_cooler_capacity.get_capacity()
)
_cooler_price_bubble.hide()
else:
_cooler_price_bubble.show()
cooler_effect = "%d%d slots" % [
_cooler_capacity.get_capacity(),
next_capacity,
]
_cooler_cost.set_amount(cost)
_cooler_purchase.tooltip_text = _upgrade_tooltip(
"storage capacity",
level,
cooler_effect,
)
_cooler_purchase.accessibility_name = _cooler_purchase.tooltip_text
func _refresh_backpack_capacity() -> void:
if _inventory_layout == null:
return
@ -1723,23 +1669,6 @@ func _purchase_art_upgrade(product_id: StringName) -> void:
_network_shop.request_art_upgrade(product_id)
func _purchase_cooler_capacity() -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
_set_feedback("unable to complete purchase.")
return
var cost: int = _cooler_capacity.get_next_cost()
if cost < 0:
_set_feedback("Upgrade is already at maximum.")
return
if not _wallet.can_afford(cost):
_set_feedback("Insufficient funds.")
return
if _network_shop == null:
_set_feedback("Purchase could not be completed.")
return
_network_shop.request_cooler_capacity_upgrade()
func _purchase_backpack_capacity() -> void:
if _transaction_in_progress or not _is_transaction_context_valid():
_set_feedback("unable to complete purchase.")
@ -1860,7 +1789,6 @@ func _on_wallet_changed(_balance: int, _delta: int) -> void:
_refresh_wallet()
_refresh_upgrades()
_refresh_supplies()
_refresh_cooler_capacity()
_refresh_backpack_capacity()
@ -1875,10 +1803,6 @@ func _on_bag_changed() -> void:
_refresh_supplies()
func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void:
_refresh_cooler_capacity()
func _on_backpack_capacity_changed(_level: int, _capacity: int) -> void:
_refresh_backpack_capacity()

View file

@ -257,35 +257,6 @@ layout_mode = 2
amount = 100
icon_size = 24.0
[node name="CoolerTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
custom_minimum_size = Vector2(144, 168)
layout_mode = 2
mouse_filter = 2
[node name="CoolerPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"]
unique_name_in_owner = true
offset_right = 144.0
offset_bottom = 144.0
tooltip_text = "storage capacity"
icon = ExtResource("7_close")
expand_icon = true
icon_max_width = 92
[node name="CoolerPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"]
unique_name_in_owner = true
z_index = 2
offset_left = 22.0
offset_top = 128.0
offset_right = 122.0
offset_bottom = 160.0
mouse_filter = 2
[node name="CoolerCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile/CoolerPriceBubble" instance=ExtResource("8_currency")]
unique_name_in_owner = true
layout_mode = 2
amount = 75
icon_size = 24.0
[node name="BackpackTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"]
custom_minimum_size = Vector2(144, 168)
layout_mode = 2

View file

@ -21,6 +21,7 @@ const PauseMenuType = preload("res://ui/pause_menu.gd")
const NetworkSessionType = preload("res://network/network_session.gd")
const FishingShopType = preload("res://ui/fishing_shop.gd")
const DecorShopType = preload("res://ui/decor_shop.gd")
const RVUpgradeShopType = preload("res://ui/rv_upgrade_shop.gd")
const SettingsPanelType = preload("res://ui/settings_panel.gd")
const PlayerFishingUpgradesType = preload(
"res://progression/player_fishing_upgrades.gd"
@ -31,6 +32,9 @@ const ShopInteractionType = preload(
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const RVUpgradeInteractionType = preload(
"res://world/rv_upgrade_interaction.gd"
)
const PlayerStorageType = preload("res://ui/player_storage.gd")
const HomeDecorToolbarType = preload("res://ui/home_decor_toolbar.gd")
const PlayerStorageInteractionType = preload(
@ -129,6 +133,7 @@ const FISHING_PANEL_SHOWCASE_BOTTOM_OFFSET: float = -104.0
@onready var _barrier_health: Label = %BarrierHealth
@onready var _showcase_details: Label = %ShowcaseDetails
@onready var _fishing_panel: PanelContainer = %FishingPanel
@onready var _fishing_danger_vignette: ColorRect = %FishingDangerVignette
@onready var _experience_panel: PanelContainer = %ExperienceProgressPanel
@onready var _experience_level_label: Label = %ExperienceLevelLabel
@onready var _experience_award_label: Label = %ExperienceAwardLabel
@ -144,6 +149,8 @@ const SHOP_ANIMALESE_VOICE_ID: String = "natural"
const SHOP_ANIMALESE_BASE_PITCH: float = 1.08
const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0
const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
const FISHING_DANGER_START_GAP: float = 0.15
const FISHING_DANGER_FULL_GAP: float = 0.025
@onready var _canonical_stage: Control = %CanonicalStage
@ -155,6 +162,7 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000
@onready var _hotbar_ui: HotbarUIType = %Hotbar
@onready var _fishing_shop: FishingShopType = %FishingShop
@onready var _decor_shop: DecorShopType = %DecorShop
@onready var _rv_upgrade_shop: RVUpgradeShopType = %RVUpgradeShop
@onready var _player_storage: PlayerStorageType = %PlayerStorage
@onready var _home_decor_toolbar: HomeDecorToolbarType = %HomeDecorToolbar
@onready var _rv_storage_button: Button = %RVStorageButton
@ -208,6 +216,7 @@ var _player_menu_hotbar_visible: bool = false
var _main_shop_buyer: FishBuyerProfileType
var _shop_interaction: ShopInteractionType
var _decor_shop_interaction: DecorShopInteractionType
var _rv_upgrade_shop_interaction: RVUpgradeInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _surface_drawing: NetworkSurfaceDrawingService
var _surface_drawing_hotbar_selected: bool = false
@ -235,6 +244,12 @@ var _controller_text_entry_is_open: Callable
var _controller_text_entry_close: Callable
var _restore_chat_keyboard_after_fishing: bool = false
var _rv_storage_button_requested_visible: bool = false
var _previous_barrier_health := PackedInt32Array()
var _barrier_feedback_initialized: bool = false
var _fishing_danger_target: float = 0.0
var _fishing_danger_intensity: float = 0.0
var _fishing_danger_pulse_time: float = 0.0
var _catch_track_shake_tween: Tween
func _ready() -> void:
@ -363,6 +378,7 @@ func setup(
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
rv_upgrade_shop_interaction: RVUpgradeInteractionType,
item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType,
@ -506,7 +522,6 @@ func setup(
hotbar,
inventory_layout,
item_catalog,
cooler_capacity,
art_unlocks,
network_shop_service,
network_sale_service,
@ -521,6 +536,14 @@ func setup(
home_state,
network_shop_service,
)
_rv_upgrade_shop.setup(
player,
wallet,
fishing_spot,
rv_upgrade_shop_interaction,
home_state,
network_shop_service,
)
_player_storage.setup(
player,
fishing_spot,
@ -538,6 +561,8 @@ func setup(
_fishing_shop.menu_exit_started.connect(_on_shop_exit_started)
_decor_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_decor_shop.menu_exit_started.connect(_on_shop_exit_started)
_rv_upgrade_shop.menu_visibility_changed.connect(_on_shop_visibility_changed)
_rv_upgrade_shop.menu_exit_started.connect(_on_shop_exit_started)
_fishing_shop.sell_fish_requested.connect(_on_shop_sell_fish_requested)
_fishing_shop.shop_cooler_return_requested.connect(
_on_shop_cooler_return_requested
@ -548,6 +573,7 @@ func setup(
_main_shop_buyer = main_shop_buyer
_shop_interaction = shop_interaction
_decor_shop_interaction = decor_shop_interaction
_rv_upgrade_shop_interaction = rv_upgrade_shop_interaction
_storage_interaction = storage_interaction
_surface_drawing = surface_drawing
if (
@ -571,13 +597,16 @@ func set_world_interactions(
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
decor_shop_interaction: DecorShopInteractionType,
rv_upgrade_shop_interaction: RVUpgradeInteractionType,
) -> void:
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_decor_shop_interaction = decor_shop_interaction
_rv_upgrade_shop_interaction = rv_upgrade_shop_interaction
_fishing_shop.set_world_interaction(shop_interaction)
_player_storage.set_world_interaction(storage_interaction)
_decor_shop.set_world_interaction(decor_shop_interaction)
_rv_upgrade_shop.set_world_interaction(rv_upgrade_shop_interaction)
func _prioritize_surface_drawing_pointer_input() -> void:
@ -1539,6 +1568,13 @@ func _process(delta: float) -> void:
_update_virtual_mouse(delta)
_update_controller_menu_scroll(delta)
_update_experience_bubble_position()
_update_fishing_danger_vignette(delta)
_try_start_shop_npc_speech()
if _player != null:
_player.set_fishing_reel_camera_active(
_fishing_spot != null
and _fishing_spot.is_reel_input_active()
)
func _update_controller_menu_scroll(delta: float) -> void:
@ -1793,6 +1829,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void:
close_player_menu_for_session_end()
_fishing_shop.close_for_session_end()
_decor_shop.close_for_session_end()
_rv_upgrade_shop.close_for_session_end()
_player_storage.close_for_session_end()
_fishing_panel.visible = false
_shop_prompt.hide()
@ -1933,6 +1970,10 @@ func get_decor_shop() -> DecorShopType:
return _decor_shop
func get_rv_upgrade_shop() -> RVUpgradeShopType:
return _rv_upgrade_shop
func get_player_storage() -> PlayerStorageType:
return _player_storage
@ -2027,8 +2068,19 @@ func set_shop_prompt_visible(
_position_shop_prompt(world_anchor)
func set_shop_npc_player_in_range(in_range: bool) -> void:
if _shop_npc_player_in_range == in_range:
func set_shop_npc_player_in_range(
in_range: bool,
dialogue_text: String = "",
) -> void:
var dialogue_changed: bool = (
not dialogue_text.is_empty()
and _shop_prompt_message.text != dialogue_text
)
if dialogue_changed:
_shop_prompt_message.text = dialogue_text
_shop_prompt_message.visible_characters = -1
_shop_npc_spoken_for_current_visit = false
if _shop_npc_player_in_range == in_range and not dialogue_changed:
return
_shop_npc_player_in_range = in_range
if not in_range:
@ -2044,10 +2096,10 @@ func _try_start_shop_npc_speech() -> void:
or not _gameplay_ui_enabled
):
return
_shop_npc_spoken_for_current_visit = true
var now_msec: int = Time.get_ticks_msec()
if now_msec < _shop_npc_next_speech_msec:
return
_shop_npc_spoken_for_current_visit = true
_shop_npc_next_speech_msec = (
now_msec + SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS
)
@ -2310,6 +2362,12 @@ func _on_catch_display_changed(
and _fishing_spot != null
and _fishing_spot.is_fighting()
)
_update_fishing_feedback(
progress,
chase_progress,
barrier_health,
encounter_visible,
)
_green_catch_progress.value = progress * 100.0
_red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0
_catch_track.visible = encounter_visible
@ -2346,6 +2404,93 @@ func _on_catch_display_changed(
_refresh_fishing_panel_visibility()
func _update_fishing_feedback(
progress: float,
chase_progress: float,
barrier_health: PackedInt32Array,
encounter_visible: bool,
) -> void:
if not encounter_visible:
_previous_barrier_health.clear()
_barrier_feedback_initialized = false
_fishing_danger_target = 0.0
return
if _barrier_feedback_initialized:
var comparison_count: int = mini(
_previous_barrier_health.size(),
barrier_health.size(),
)
var barrier_hit: bool = false
var barrier_broken: bool = false
for barrier_index: int in range(comparison_count):
if (
barrier_health[barrier_index]
>= _previous_barrier_health[barrier_index]
):
continue
barrier_hit = true
barrier_broken = (
barrier_broken
or barrier_health[barrier_index] <= 0
)
if barrier_hit:
_play_barrier_feedback(barrier_broken)
_previous_barrier_health = barrier_health.duplicate()
_barrier_feedback_initialized = true
var chase_gap: float = progress - maxf(chase_progress, 0.0)
_fishing_danger_target = clampf(
inverse_lerp(
FISHING_DANGER_START_GAP,
FISHING_DANGER_FULL_GAP,
chase_gap,
),
0.0,
1.0,
)
func _play_barrier_feedback(barrier_broken: bool) -> void:
if _player != null:
_player.play_fishing_barrier_camera_feedback(barrier_broken)
if _catch_track_shake_tween != null:
_catch_track_shake_tween.kill()
_catch_track.rotation = 0.0
var tilt: float = 0.018 if barrier_broken else 0.008
var beat: float = 0.038 if barrier_broken else 0.028
_catch_track_shake_tween = create_tween()
_catch_track_shake_tween.tween_property(
_catch_track, "rotation", -tilt, beat
)
_catch_track_shake_tween.tween_property(
_catch_track, "rotation", tilt, beat
)
_catch_track_shake_tween.tween_property(
_catch_track, "rotation", -tilt * 0.45, beat
)
_catch_track_shake_tween.tween_property(
_catch_track, "rotation", 0.0, beat
)
func _update_fishing_danger_vignette(delta: float) -> void:
_fishing_danger_pulse_time += delta
_fishing_danger_intensity = lerpf(
_fishing_danger_intensity,
_fishing_danger_target,
1.0 - exp(-8.0 * delta),
)
var pulse: float = (
0.88 + sin(_fishing_danger_pulse_time * 5.5) * 0.12
)
var shown_intensity: float = _fishing_danger_intensity * pulse
_fishing_danger_vignette.visible = shown_intensity > 0.01
var vignette_material := (
_fishing_danger_vignette.material as ShaderMaterial
)
if vignette_material != null:
vignette_material.set_shader_parameter("intensity", shown_intensity)
func _update_barrier_markers(
positions: PackedFloat32Array,
health: PackedInt32Array,

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=24 format=3]
[gd_scene load_steps=27 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"]
@ -17,6 +17,11 @@
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="15_social_prompt"]
[ext_resource type="PackedScene" path="res://ui/decor_shop.tscn" id="16_decor_shop"]
[ext_resource type="PackedScene" path="res://ui/home_decor_toolbar.tscn" id="17_decor_toolbar"]
[ext_resource type="PackedScene" path="res://ui/rv_upgrade_shop.tscn" id="18_rv_upgrade_shop"]
[ext_resource type="Shader" path="res://ui/fishing_danger_vignette.gdshader" id="19_fishing_vignette"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_fishing_vignette"]
shader = ExtResource("19_fishing_vignette")
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
bg_color = Color(0.032, 0.118, 0.15, 1)
@ -86,6 +91,20 @@ grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="FishingDangerVignette" type="ColorRect" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
visible = false
z_index = 50
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
material = SubResource("ShaderMaterial_fishing_vignette")
color = Color(1, 1, 1, 1)
[node name="ActiveBaitIndicator" type="Control" parent="UIRoot/CanonicalStage/GameplayTransientHUD"]
unique_name_in_owner = true
z_index = 54
@ -312,6 +331,7 @@ offset_right = 420.0
offset_bottom = -124.0
grow_horizontal = 2
grow_vertical = 0
pivot_offset = Vector2(420, 18)
mouse_filter = 2
[node name="GreenCatchProgress" type="ProgressBar" parent="UIRoot/CanonicalStage/GameplayTransientHUD/CatchTrack"]
@ -440,7 +460,7 @@ theme_override_constants/margin_bottom = 8
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Hey, if you've got any fish I've got cash! OwO"
text = "Listen, you may suck at fishing but I've got some things that might help."
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
@ -477,6 +497,9 @@ unique_name_in_owner = true
[node name="DecorShop" parent="UIRoot/CanonicalStage" instance=ExtResource("16_decor_shop")]
unique_name_in_owner = true
[node name="RVUpgradeShop" parent="UIRoot/CanonicalStage" instance=ExtResource("18_rv_upgrade_shop")]
unique_name_in_owner = true
[node name="HomeDecorToolbar" parent="UIRoot/CanonicalStage" instance=ExtResource("17_decor_toolbar")]
unique_name_in_owner = true

View file

@ -10,7 +10,10 @@ var _mode_button: Button
var _selection_label: Label
var _rotate_button: Button
var _store_button: Button
var _clear_button: Button
var _mode: PlayerHomeState.PlacementMode = PlayerHomeState.PlacementMode.GRID
var _interactive: bool = true
var _selection_active: bool = false
func _ready() -> void:
@ -22,31 +25,33 @@ func set_active(active: bool) -> void:
if visible == active:
return
visible = active
if active and _mode_button != null:
_mode_button.call_deferred("grab_focus")
func set_interactive(interactive: bool) -> void:
_interactive = interactive
_refresh_button_interactivity()
func set_mode(mode: PlayerHomeState.PlacementMode) -> void:
_mode = mode
if _mode_button != null:
_mode_button.text = (
"placement: grid"
"grid"
if mode == PlayerHomeState.PlacementMode.GRID
else "placement: free"
else "free"
)
_mode_button.tooltip_text = "placement mode: %s" % _mode_button.text
func set_selection(instance_id: String, display_name: String = "") -> void:
var selected: bool = not instance_id.is_empty()
_selection_active = selected
if _selection_label != null:
_selection_label.text = (
"selected: %s" % display_name
if selected else "click decor to select; drag to move"
if selected else "use held decor to place; click or drag to edit"
)
if _rotate_button != null:
_rotate_button.disabled = not selected
if _store_button != null:
_store_button.disabled = not selected
_refresh_button_interactivity()
func report_status(message: String) -> void:
@ -55,65 +60,92 @@ func report_status(message: String) -> void:
func _build_ui() -> void:
custom_minimum_size = Vector2(330.0, 0.0)
position = Vector2(925.0, 540.0)
z_index = 58
set_anchors_preset(Control.PRESET_TOP_RIGHT)
offset_left = -500.0
offset_top = 0.0
offset_right = 0.0
offset_bottom = 106.0
custom_minimum_size = Vector2(500.0, 0.0)
z_index = 85
mouse_filter = Control.MOUSE_FILTER_STOP
add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
Color(UtilityPageStyle.OCEAN_PANEL_DEEP, 0.96), 16
),
)
var panel_style: StyleBoxFlat = UtilityPageStyle.panel_style()
panel_style.set_corner_radius_all(10)
panel_style.content_margin_left = 6.0
panel_style.content_margin_top = 6.0
panel_style.content_margin_right = 6.0
panel_style.content_margin_bottom = 6.0
add_theme_stylebox_override("panel", panel_style)
var margin := MarginContainer.new()
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom",
]:
margin.add_theme_constant_override(side, 12)
margin.add_theme_constant_override(side, 6)
add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 8)
layout.add_theme_constant_override("separation", 5)
margin.add_child(layout)
var title := Label.new()
title.text = "RV decor"
title.add_theme_font_size_override("font_size", 24)
layout.add_child(title)
_selection_label = Label.new()
_selection_label.text = "click decor to select; drag to move"
_selection_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_selection_label.add_theme_font_size_override("font_size", 17)
layout.add_child(_selection_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.add_theme_constant_override("separation", 5)
layout.add_child(row)
_mode_button = Button.new()
_mode_button.text = "placement: grid"
_mode_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(_mode_button)
var title := Label.new()
title.text = "decor"
title.custom_minimum_size = Vector2(70.0, 46.0)
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 19)
row.add_child(title)
_mode_button = _add_tool_button(row, "grid", "switch placement mode", 82.0)
_mode_button.pressed.connect(_toggle_mode)
row.add_child(_mode_button)
_rotate_button = Button.new()
_rotate_button.text = "rotate"
_rotate_button.disabled = true
UtilityPageStyle.apply_ocean_button(_rotate_button)
_rotate_button = _add_tool_button(row, "rotate", "rotate selected decor", 92.0)
_rotate_button.pressed.connect(rotate_requested.emit)
row.add_child(_rotate_button)
var action_row := HBoxContainer.new()
action_row.add_theme_constant_override("separation", 8)
layout.add_child(action_row)
_store_button = Button.new()
_store_button.text = "pack up"
_store_button.disabled = true
_store_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(_store_button)
_store_button = _add_tool_button(row, "pack", "pack selected decor", 82.0)
_store_button.pressed.connect(store_requested.emit)
action_row.add_child(_store_button)
var cancel_button := Button.new()
cancel_button.text = "clear selection"
cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(cancel_button)
cancel_button.pressed.connect(cancel_requested.emit)
action_row.add_child(cancel_button)
_clear_button = _add_tool_button(row, "clear", "clear selection", 82.0)
_clear_button.pressed.connect(cancel_requested.emit)
_selection_label = Label.new()
_selection_label.text = "use held decor to place; click or drag to edit"
_selection_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_selection_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_selection_label.add_theme_font_size_override("font_size", 15)
layout.add_child(_selection_label)
_refresh_button_interactivity()
func _add_tool_button(
parent: Container,
button_text: String,
button_tooltip: String,
minimum_width: float,
) -> Button:
var button := Button.new()
button.text = button_text
button.tooltip_text = button_tooltip
button.accessibility_name = button_tooltip
button.custom_minimum_size = Vector2(minimum_width, 46.0)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_button(button)
for style_name: StringName in [
&"normal", &"hover", &"pressed", &"focus", &"disabled",
]:
var existing: StyleBox = button.get_theme_stylebox(style_name)
var rounded := existing.duplicate() as StyleBoxFlat
if rounded == null:
continue
rounded.set_corner_radius_all(20)
button.add_theme_stylebox_override(style_name, rounded)
parent.add_child(button)
return button
func _refresh_button_interactivity() -> void:
if _mode_button != null:
_mode_button.disabled = not _interactive
if _rotate_button != null:
_rotate_button.disabled = not _interactive or not _selection_active
if _store_button != null:
_store_button.disabled = not _interactive or not _selection_active
if _clear_button != null:
_clear_button.disabled = not _interactive or not _selection_active
func _toggle_mode() -> void:

View file

@ -2575,7 +2575,10 @@ func _on_tackle_button_gui_input(
call_deferred("_release_pointer_focus", button)
if (
mouse_event == null
or mouse_event.button_index != MOUSE_BUTTON_RIGHT
or mouse_event.button_index not in [
MOUSE_BUTTON_LEFT,
MOUSE_BUTTON_RIGHT,
]
or not mouse_event.pressed
):
return

View file

@ -105,7 +105,7 @@ func _open_storage(
):
return false
_home_access = home_access
_title.text = "RV storage" if home_access else "private storage"
_title.text = "home storage" if home_access else "private storage"
_decorate_button.visible = home_access and allow_decorating
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled()
@ -290,8 +290,8 @@ func _build_ui() -> void:
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(_title)
_decorate_button = Button.new()
_decorate_button.text = "decorate RV"
_decorate_button.tooltip_text = "Place decor inside your RV"
_decorate_button.text = "decorate home"
_decorate_button.tooltip_text = "Place decor inside your home"
_decorate_button.custom_minimum_size = Vector2(160.0, 48.0)
UtilityPageStyle.apply_ocean_button(_decorate_button)
_decorate_button.hide()

330
ui/rv_upgrade_shop.gd Normal file
View file

@ -0,0 +1,330 @@
class_name RVUpgradeShop
extends Control
const INPUT_OWNER: StringName = &"rv_upgrade_shop"
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
const CurrencyPresentationType = preload("res://ui/currency_presentation.gd")
signal menu_visibility_changed(is_open: bool)
signal menu_exit_started
var _player: Player
var _wallet: PlayerWallet
var _fishing_spot: FishingSpot
var _interaction: RVUpgradeInteraction
var _home_state: PlayerHomeState
var _network_shop: NetworkShopService
var _wallet_amount: CurrencyAmount
var _price_amount: CurrencyAmount
var _home_label: Label
var _storage_label: Label
var _feedback: Label
var _upgrade_button: Button
var _close_button: Button
var _transaction_pending: bool = false
var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
func _ready() -> void:
UtilityPageStyleType.apply_page(self)
_build_ui()
hide()
func setup(
player: Player,
wallet: PlayerWallet,
fishing_spot: FishingSpot,
interaction: RVUpgradeInteraction,
home_state: PlayerHomeState,
network_shop: NetworkShopService,
) -> void:
_player = player
_wallet = wallet
_fishing_spot = fishing_spot
_interaction = interaction
_home_state = home_state
_network_shop = network_shop
if not _wallet.balance_changed.is_connected(_on_wallet_changed):
_wallet.balance_changed.connect(_on_wallet_changed)
if not _home_state.changed.is_connected(_refresh):
_home_state.changed.connect(_refresh)
if (
_network_shop != null
and not _network_shop.local_purchase_pending.is_connected(
_on_purchase_pending
)
):
_network_shop.local_purchase_pending.connect(_on_purchase_pending)
_network_shop.local_purchase_finished.connect(_on_purchase_finished)
_refresh()
func set_world_interaction(interaction: RVUpgradeInteraction) -> void:
_interaction = interaction
func open_shop() -> bool:
if (
visible
or _player == null
or _wallet == null
or _fishing_spot == null
or _interaction == null
or not _interaction.is_local_player_in_range()
or not _fishing_spot.can_open_fishing_shop()
):
return false
_prior_movement_enabled = _player.is_movement_enabled()
_prior_camera_enabled = _player.is_camera_input_enabled()
_player.set_movement_enabled(false)
_player.set_camera_input_enabled(false)
_prior_mouse_mode = _restorable_mouse_mode(Input.mouse_mode)
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_transaction_pending = (
_network_shop != null
and _network_shop.is_local_purchase_pending()
)
_feedback.text = ""
_refresh()
show()
_upgrade_button.call_deferred("grab_focus")
menu_visibility_changed.emit(true)
return true
func close_shop(restore_controls: bool = true) -> void:
if not visible:
return
menu_exit_started.emit()
get_viewport().gui_release_focus()
hide()
if _fishing_spot != null:
_fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false)
if restore_controls and _player != null:
_player.set_movement_enabled(_prior_movement_enabled)
_player.set_camera_input_enabled(_prior_camera_enabled)
Input.mouse_mode = _prior_mouse_mode
menu_visibility_changed.emit(false)
func consume_escape() -> bool:
if not visible:
return false
close_shop()
return true
func close_for_range_exit() -> void:
close_shop()
func close_for_water_recovery() -> void:
close_shop(false)
func close_for_session_end() -> void:
close_shop(false)
func _unhandled_input(event: InputEvent) -> void:
if not visible or not event.is_action_pressed("ui_cancel"):
return
close_shop()
get_viewport().set_input_as_handled()
func _purchase_upgrade() -> void:
if _network_shop == null or _transaction_pending:
return
var request_id: String = _network_shop.request_home_upgrade()
if request_id.is_empty():
_refresh()
func _on_purchase_pending(_request_id: String) -> void:
_transaction_pending = true
_feedback.text = "upgrading…"
_refresh()
func _on_purchase_finished(
_request_id: String,
accepted: bool,
message: String,
product_id: StringName,
category: int,
_quantity: int,
_total_cost: int,
) -> void:
if (
category != NetworkShopProtocol.ProductCategory.HOME_UPGRADE
or product_id != NetworkShopService.HOME_UPGRADE_PRODUCT_ID
):
return
_transaction_pending = false
_feedback.text = "starter RV unlocked" if accepted else message.to_lower()
_refresh()
func _on_wallet_changed(_balance: int, _delta: int) -> void:
_refresh()
func _refresh() -> void:
if _wallet_amount == null:
return
var balance: int = _wallet.get_balance() if _wallet != null else 0
var tier: int = _home_state.get_tier() if _home_state != null else 0
var current_capacity: int = (
_home_state.get_storage_capacity() if _home_state != null else 9
)
var next_capacity: int = (
_home_state.get_next_storage_capacity() if _home_state != null else -1
)
var cost: int = (
_home_state.get_next_upgrade_cost() if _home_state != null else -1
)
_wallet_amount.set_amount(balance)
_price_amount.set_amount(maxi(cost, 0))
if tier >= PlayerHomeState.MAX_TIER:
_home_label.text = "starter RV"
_storage_label.text = "storage · %d slots" % current_capacity
_upgrade_button.text = "unlocked"
_upgrade_button.disabled = true
_price_amount.visible = false
else:
_home_label.text = "tent → starter RV"
_storage_label.text = "storage · %d%d slots" % [
current_capacity, next_capacity,
]
_upgrade_button.text = "upgrade"
_upgrade_button.disabled = (
_transaction_pending
or _wallet == null
or not _wallet.can_afford(cost)
or _network_shop == null
or not _network_shop.can_request_home_upgrade()
)
_price_amount.visible = true
func _restorable_mouse_mode(mouse_mode: Input.MouseMode) -> Input.MouseMode:
return (
Input.MOUSE_MODE_VISIBLE
if mouse_mode == Input.MOUSE_MODE_CAPTURED
else mouse_mode
)
func _build_ui() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
z_index = 83
mouse_filter = Control.MOUSE_FILTER_STOP
var blocker := ColorRect.new()
blocker.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
blocker.color = Color(0.02, 0.08, 0.10, 0.55)
blocker.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(blocker)
var panel := PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.position = Vector2(-390.0, -225.0)
panel.size = Vector2(780.0, 450.0)
panel.add_theme_stylebox_override(
"panel",
UtilityPageStyleType.rounded_style(
UtilityPageStyleType.OCEAN_PANEL_DEEP, 24
),
)
add_child(panel)
var margin := MarginContainer.new()
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom",
]:
margin.add_theme_constant_override(side, 28)
panel.add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 18)
margin.add_child(layout)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 12)
layout.add_child(header)
var title := Label.new()
title.text = "home upgrades"
title.add_theme_font_size_override("font_size", 38)
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(title)
_wallet_amount = CurrencyPresentationType.instantiate_amount(0, 24.0)
header.add_child(_wallet_amount)
var offer := PanelContainer.new()
offer.size_flags_vertical = Control.SIZE_EXPAND_FILL
offer.add_theme_stylebox_override(
"panel",
UtilityPageStyleType.rounded_style(
UtilityPageStyleType.OCEAN_FIELD, 18
),
)
layout.add_child(offer)
var offer_margin := MarginContainer.new()
for side: StringName in [
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom",
]:
offer_margin.add_theme_constant_override(side, 28)
offer.add_child(offer_margin)
var details := VBoxContainer.new()
details.alignment = BoxContainer.ALIGNMENT_CENTER
details.add_theme_constant_override("separation", 14)
offer_margin.add_child(details)
_home_label = Label.new()
_home_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_home_label.add_theme_font_size_override("font_size", 34)
details.add_child(_home_label)
var description := Label.new()
description.text = (
"A weatherproof home with a larger interior and built-in storage."
)
description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.add_theme_font_size_override("font_size", 20)
details.add_child(description)
_storage_label = Label.new()
_storage_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_storage_label.add_theme_font_size_override("font_size", 22)
details.add_child(_storage_label)
var purchase_row := HBoxContainer.new()
purchase_row.alignment = BoxContainer.ALIGNMENT_CENTER
purchase_row.add_theme_constant_override("separation", 10)
details.add_child(purchase_row)
_upgrade_button = Button.new()
_upgrade_button.custom_minimum_size = Vector2(220.0, 60.0)
UtilityPageStyleType.apply_ocean_button(_upgrade_button)
_upgrade_button.pressed.connect(_purchase_upgrade)
purchase_row.add_child(_upgrade_button)
_price_amount = CurrencyPresentationType.instantiate_amount(0, 24.0)
purchase_row.add_child(_price_amount)
var footer := HBoxContainer.new()
footer.add_theme_constant_override("separation", 12)
layout.add_child(footer)
_feedback = Label.new()
_feedback.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_feedback.add_theme_font_size_override("font_size", 18)
footer.add_child(_feedback)
_close_button = Button.new()
_close_button.text = "close"
_close_button.custom_minimum_size = Vector2(150.0, 50.0)
UtilityPageStyleType.apply_ocean_button(_close_button)
_close_button.pressed.connect(close_shop)
footer.add_child(_close_button)
_upgrade_button.focus_neighbor_bottom = _upgrade_button.get_path_to(
_close_button
)
_close_button.focus_neighbor_top = _close_button.get_path_to(
_upgrade_button
)

View file

@ -0,0 +1 @@
uid://b5pnsuph7fxrd

14
ui/rv_upgrade_shop.tscn Normal file
View file

@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/rv_upgrade_shop.gd" id="1_script"]
[node name="RVUpgradeShop" type="Control"]
unique_name_in_owner = true
visible = false
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_script")

View file

@ -1,2 +1,10 @@
class_name DecorShopInteraction
extends FishingShopInteraction
const DECOR_DIALOGUE_TEXT: String = (
"You gonna decorate that hot box or what? I've got some new decor in, take a look."
)
func get_dialogue_text(_home_tier: int = -1) -> String:
return DECOR_DIALOGUE_TEXT

View file

@ -3,6 +3,10 @@ extends Area3D
signal local_player_range_changed(in_range: bool)
const DIALOGUE_TEXT: String = (
"Listen, you may suck at fishing but I've got some things that might help."
)
@export_node_path("Node3D") var look_character_path: NodePath = ^"../Shopkeeper"
var _local_player: Player
@ -37,6 +41,10 @@ func is_avatar_in_range(avatar: Player) -> bool:
)
func get_dialogue_text(_home_tier: int = -1) -> String:
return DIALOGUE_TEXT
func get_prompt_anchor_position() -> Vector3:
if _look_character != null:
return _look_character.get_head_anchor_position()

View file

@ -12,6 +12,9 @@ const PlayerStorageInteractionType = preload(
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const RVUpgradeInteractionType = preload(
"res://world/rv_upgrade_interaction.gd"
)
const PrecipitationOcclusionType = preload(
"res://world/environment/precipitation_occlusion.gd"
)
@ -52,6 +55,8 @@ const WATER_RECOVERY_FOOTPRINT_RADIUS := 0.6
const WATER_RECOVERY_INSET_STEP := 0.25
const WATER_RECOVERY_MAXIMUM_INSET := 1.5
const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
const HOME_GROUND_HEIGHT_TOLERANCE := 0.5
const HOME_ELEVATION_HEIGHT_TOLERANCE := 0.6
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
Vector2(1.0, 0.0),
Vector2(0.70710678, 0.70710678),
@ -86,9 +91,13 @@ const PROCEDURAL_PROP_GROUPS: Array[StringName] = [
@onready var _decor_shop: DecorShopInteractionType = (
$Interactables/DecorShopWorld/InteractionArea
)
@onready var _rv_upgrade_shop: RVUpgradeInteractionType = (
$Interactables/RVUpgradeShopWorld/InteractionArea
)
@onready var _shop_root: Node3D = %FishingShopWorld
@onready var _storage_root: Node3D = %PlayerStorageBox
@onready var _decor_shop_root: Node3D = %DecorShopWorld
@onready var _rv_upgrade_shop_root: Node3D = %RVUpgradeShopWorld
@onready var _decorations: Node3D = %Decorations
@onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks
@onready var _diggable_beach: DiggableArea3D = %DiggableBeach
@ -104,6 +113,7 @@ var _placed_prop_groups: Array[StringName] = []
var _placed_group_coordinates: Dictionary[StringName, Array] = {}
var _biome_assignments: Dictionary[Vector2i, StringName] = {}
var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {}
var _home_accessible_elevations_by_coordinate: Dictionary[Vector2i, Array] = {}
func _ready() -> void:
@ -159,6 +169,10 @@ func get_decor_shop() -> DecorShopInteractionType:
return _decor_shop
func get_rv_upgrade_shop() -> RVUpgradeInteractionType:
return _rv_upgrade_shop
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference
@ -184,6 +198,7 @@ func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
minimum_up_dot: float = 0.6,
maximum_global_y: float = 100.0,
) -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
if material_names.is_empty():
@ -204,10 +219,27 @@ func get_spawn_surface_triangles(
mesh.surface_get_arrays(surface_index),
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
return triangles
func is_home_exterior_position_accessible(position: Vector3) -> bool:
var local_position := _generator.to_local(position)
if local_position.y <= HOME_GROUND_HEIGHT_TOLERANCE:
return true
var coordinate := _generator.coordinate_for_local_position(local_position)
for value: Variant in _home_accessible_elevations_by_coordinate.get(
coordinate,
[],
):
if absf(local_position.y - float(value)) <= (
HOME_ELEVATION_HEIGHT_TOLERANCE
):
return true
return false
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
@ -242,6 +274,7 @@ func get_water_recovery_position(
func _on_generation_completed(summary: Dictionary) -> void:
_diggable_beach.invalidate_surface_cache()
var records: Array[Dictionary] = _generator.placement_records()
_cache_home_accessible_elevations(records)
_assign_biomes(records, summary)
var spawn_position := Vector3.ZERO
_clear_children(_decorations)
@ -388,6 +421,97 @@ func _on_generation_completed(summary: Dictionary) -> void:
world_generated.emit(_current_seed, summary)
func _cache_home_accessible_elevations(
records: Array[Dictionary],
) -> void:
_home_accessible_elevations_by_coordinate.clear()
var records_by_coordinate: Dictionary[Vector2i, Dictionary] = {}
var elevation_tiers: Dictionary[int, bool] = {}
for record: Dictionary in records:
var coordinate: Vector2i = record.get(
"coordinate",
Vector2i(-1, -1),
)
records_by_coordinate[coordinate] = record
for tier: int in _elevation_tiers_for_tags(
record.get("tags", PackedStringArray())
):
elevation_tiers[tier] = true
var visited: Dictionary[String, bool] = {}
for tier: int in elevation_tiers:
var elevation_tag := "elevation_%d" % tier
for coordinate: Vector2i in records_by_coordinate:
var visit_key := _home_elevation_visit_key(coordinate, tier)
if visited.has(visit_key):
continue
var record: Dictionary = records_by_coordinate[coordinate]
var tags: PackedStringArray = record.get(
"tags",
PackedStringArray(),
)
if elevation_tag not in tags:
continue
var component: Array[Vector2i] = []
var pending: Array[Vector2i] = [coordinate]
var has_ramp := false
while not pending.is_empty():
var current: Vector2i = pending.pop_back()
var current_key := _home_elevation_visit_key(current, tier)
if visited.has(current_key):
continue
var current_record: Dictionary = records_by_coordinate.get(
current,
{},
)
var current_tags: PackedStringArray = current_record.get(
"tags",
PackedStringArray(),
)
if elevation_tag not in current_tags:
continue
visited[current_key] = true
component.append(current)
has_ramp = has_ramp or "ramp" in current_tags
for offset: Vector2i in [
Vector2i.LEFT,
Vector2i.RIGHT,
Vector2i.UP,
Vector2i.DOWN,
]:
pending.append(current + offset)
if not has_ramp:
continue
var surface_height := (
float(tier - 1) * _generator.elevated_cliff_level_height
)
for component_coordinate: Vector2i in component:
var heights: Array = (
_home_accessible_elevations_by_coordinate.get(
component_coordinate,
[],
)
)
heights.append(surface_height)
_home_accessible_elevations_by_coordinate[
component_coordinate
] = heights
func _elevation_tiers_for_tags(tags: PackedStringArray) -> Array[int]:
var tiers: Array[int] = []
for tag_value: String in tags:
if not tag_value.begins_with("elevation_"):
continue
var suffix := tag_value.trim_prefix("elevation_")
if suffix.is_valid_int():
tiers.append(suffix.to_int())
return tiers
func _home_elevation_visit_key(coordinate: Vector2i, tier: int) -> String:
return "%d:%d:%d" % [tier, coordinate.x, coordinate.y]
func _mark_precipitation_occluders() -> void:
for prop: Node in _decorations.get_children():
var prop_group := prop.get_meta(&"terrain_prop_group", &"") as StringName
@ -441,6 +565,8 @@ func _place_spawn_amenities(center: Vector3) -> void:
_storage_root.rotation.y = PI * 0.5
_decor_shop_root.position = center + Vector3(-2.4, 0.0, 1.2)
_decor_shop_root.rotation.y = PI * 0.5
_rv_upgrade_shop_root.position = center + Vector3(-2.35, 0.0, -1.35)
_rv_upgrade_shop_root.rotation.y = PI
func _configure_static_water() -> void:
@ -1304,6 +1430,7 @@ func _terrain_surface_triangles_by_coordinate(
mesh_instance.mesh.surface_get_arrays(surface_index),
-INF,
0.35,
100.0,
)
if triangles.is_empty():
continue
@ -1651,6 +1778,7 @@ func _append_surface_triangles(
arrays: Array,
minimum_global_y: float,
minimum_up_dot: float,
maximum_global_y: float,
) -> void:
if arrays.size() <= Mesh.ARRAY_INDEX:
return
@ -1660,10 +1788,28 @@ func _append_surface_triangles(
return
if indices.is_empty():
for index: int in range(0, vertices.size() - 2, 3):
_append_triangle(result, mesh_instance, vertices[index], vertices[index + 1], vertices[index + 2], minimum_global_y, minimum_up_dot)
_append_triangle(
result,
mesh_instance,
vertices[index],
vertices[index + 1],
vertices[index + 2],
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
return
for index: int in range(0, indices.size() - 2, 3):
_append_triangle(result, mesh_instance, vertices[indices[index]], vertices[indices[index + 1]], vertices[indices[index + 2]], minimum_global_y, minimum_up_dot)
_append_triangle(
result,
mesh_instance,
vertices[indices[index]],
vertices[indices[index + 1]],
vertices[indices[index + 2]],
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
func _append_triangle(
@ -1674,11 +1820,15 @@ func _append_triangle(
local_c: Vector3,
minimum_global_y: float,
minimum_up_dot: float,
maximum_global_y: float,
) -> void:
var a := mesh_instance.to_global(local_a)
var b := mesh_instance.to_global(local_b)
var c := mesh_instance.to_global(local_c)
if minf(a.y, minf(b.y, c.y)) <= minimum_global_y:
if (
maxf(a.y, maxf(b.y, c.y)) <= minimum_global_y
or minf(a.y, minf(b.y, c.y)) >= maximum_global_y
):
return
var cross := (b - a).cross(c - a)
if cross.length_squared() <= 0.0000001:

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=15 format=3]
[gd_scene load_steps=16 format=3]
[ext_resource type="Script" path="res://world/generation/generated_world_region.gd" id="1_region"]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"]
@ -14,6 +14,7 @@
[ext_resource type="Resource" path="res://world/generation/props/terrain_prop_catalog.tres" id="12_prop_catalog"]
[ext_resource type="Resource" path="res://world/generation/biomes/terrain_biome_catalog.tres" id="13_biome_catalog"]
[ext_resource type="PackedScene" path="res://world/interactables/decor_shop_world.tscn" id="14_decor_shop"]
[ext_resource type="PackedScene" path="res://world/interactables/rv_upgrade_shop_world.tscn" id="15_rv_upgrade"]
[node name="GeneratedWorldRegion" type="Node3D"]
script = ExtResource("1_region")
@ -102,6 +103,9 @@ unique_name_in_owner = true
[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("14_decor_shop")]
unique_name_in_owner = true
[node name="RVUpgradeShopWorld" parent="Interactables" instance=ExtResource("15_rv_upgrade")]
unique_name_in_owner = true
[node name="Decorations" type="Node3D" parent="."]
unique_name_in_owner = true

View file

@ -4414,6 +4414,19 @@ func chunk_position(coordinate: Vector2i) -> Vector3:
)
func coordinate_for_local_position(position: Vector3) -> Vector2i:
if catalog == null or catalog.chunk_size <= 0.0:
return Vector2i(-1, -1)
var half_grid := Vector2(
float(grid_size.x - 1) * 0.5,
float(grid_size.y - 1) * 0.5,
)
return Vector2i(
roundi(position.x / catalog.chunk_size + half_grid.x),
roundi(position.z / catalog.chunk_size + half_grid.y),
)
func get_generated_chunks_root() -> Node3D:
return _generated_chunks

View file

@ -6,19 +6,19 @@
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 3.0
radius = 2.1
[node name="DecorShopWorld" type="Node3D"]
[node name="Shopkeeper" parent="." instance=ExtResource("2_character")]
script = ExtResource("3_display")
species_id = "round"
fur_color_id = "orange"
ears_id = "pointy_short"
tail_id = "cat"
eyes_id = "simple_shine"
nose_id = "dog_round"
mouth_id = "three"
fur_color_id = "brown"
ears_id = "bear"
tail_id = "bear"
eyes_id = "tired"
nose_id = "triangle_pink"
mouth_id = "simple_smile"
head_look_enabled = true
[node name="AnimationPlayer" parent="Shopkeeper"]

View file

@ -6,7 +6,7 @@
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 3.0
radius = 2.1
[node name="FishingShopWorld" type="Node3D"]

View file

@ -0,0 +1,36 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://world/rv_upgrade_interaction.gd" id="1_shop"]
[ext_resource type="PackedScene" path="res://art/exported/characters/base/straywild_base_character.glb" id="2_character"]
[ext_resource type="Script" path="res://world/world_character_display.gd" id="3_display"]
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 2.1
[node name="RVUpgradeShopWorld" type="Node3D"]
[node name="Shopkeeper" parent="." instance=ExtResource("2_character")]
script = ExtResource("3_display")
species_id = "pointy"
fur_color_id = "teal"
ears_id = "pointy_long"
tail_id = "fox"
eyes_id = "interested"
nose_id = "triangle_small"
mouth_id = "cheeky_smile"
head_look_enabled = true
[node name="AnimationPlayer" parent="Shopkeeper"]
autoplay = "idle"
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")
[node name="InteractionShape" type="CollisionShape3D" parent="InteractionArea"]
shape = SubResource("InteractionShape")
vertical_icon_alignment = 1

View file

@ -11,6 +11,9 @@ const PlayerStorageInteractionType = preload(
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const RVUpgradeInteractionType = preload(
"res://world/rv_upgrade_interaction.gd"
)
const FOLIAGE_WIND_SHADER: Shader = preload(
"res://world/materials/foliage_wind.gdshader"
)
@ -56,6 +59,10 @@ var player_storage_path: NodePath = (
var decor_shop_path: NodePath = (
^"Interactables/DecorShopWorld/InteractionArea"
)
@export_node_path("Area3D")
var rv_upgrade_shop_path: NodePath = (
^"Interactables/RVUpgradeShopWorld/InteractionArea"
)
@export_group("Terrain Collision")
# The imported GLB is the collision authority. Rebuild once per region load so
# newly authored terrain and props cannot retain a stale saved fallback shape.
@ -95,6 +102,10 @@ func get_decor_shop() -> DecorShopInteractionType:
return get_node_or_null(decor_shop_path) as DecorShopInteractionType
func get_rv_upgrade_shop() -> RVUpgradeInteractionType:
return get_node_or_null(rv_upgrade_shop_path) as RVUpgradeInteractionType
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D
@ -150,6 +161,7 @@ func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
minimum_up_dot: float = 0.6,
maximum_global_y: float = 100.0,
) -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
if material_names.is_empty():
@ -188,6 +200,7 @@ func get_spawn_surface_triangles(
vertices[vertex_index + 2],
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
else:
for index_offset: int in range(0, indices.size() - 2, 3):
@ -199,6 +212,7 @@ func get_spawn_surface_triangles(
vertices[indices[index_offset + 2]],
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
return triangles
@ -211,14 +225,14 @@ func _append_spawn_triangle(
local_c: Vector3,
minimum_global_y: float,
minimum_up_dot: float,
maximum_global_y: float,
) -> void:
var a: Vector3 = mesh_instance.to_global(local_a)
var b: Vector3 = mesh_instance.to_global(local_b)
var c: Vector3 = mesh_instance.to_global(local_c)
if (
a.y <= minimum_global_y
or b.y <= minimum_global_y
or c.y <= minimum_global_y
maxf(a.y, maxf(b.y, c.y)) <= minimum_global_y
or minf(a.y, minf(b.y, c.y)) >= maximum_global_y
):
return
var cross: Vector3 = (b - a).cross(c - a)

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,10 @@ func get_decor_shop() -> DecorShopInteraction:
return null
func get_rv_upgrade_shop() -> RVUpgradeInteraction:
return null
func get_player_storage() -> PlayerStorageInteraction:
return null
@ -42,10 +46,24 @@ func get_spawn_surface_triangles(
_material_names: Array[StringName],
_minimum_global_y: float,
_minimum_up_dot: float = 0.6,
_maximum_global_y: float = 100.0,
) -> Array[PackedVector3Array]:
return []
func get_saltwater_surface_height() -> float:
for water_region: FishableWaterRegion in get_fishable_water_regions():
if water_region.water_type == WaterType.Type.SALT_WATER:
return water_region.get_surface_height()
return NAN
## Authored regions are assumed to expose only deliberately reachable home
## sites. Procedural regions override this to reject isolated elevations.
func is_home_exterior_position_accessible(_position: Vector3) -> bool:
return true
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var regions: Array[FishableWaterRegion] = []
var root: Node = get_node_or_null(fishable_water_root)

View file

@ -0,0 +1,15 @@
class_name RVUpgradeInteraction
extends FishingShopInteraction
const TENT_DIALOGUE_TEXT: String = (
"That tent is looking awful cramped. Can I talk you into an upgrade?"
)
const RV_DIALOGUE_TEXT: String = "How's the new RV treating you?"
func get_dialogue_text(home_tier: int = -1) -> String:
return (
RV_DIALOGUE_TEXT
if home_tier >= PlayerHomeState.STARTER_RV_TIER
else TENT_DIALOGUE_TEXT
)

View file

@ -0,0 +1 @@
uid://c0h08goorv2of

View file

@ -10,6 +10,9 @@ const PlayerStorageInteractionType = preload(
const DecorShopInteractionType = preload(
"res://world/decor_shop_interaction.gd"
)
const RVUpgradeInteractionType = preload(
"res://world/rv_upgrade_interaction.gd"
)
const WorldLayoutType = preload("res://world/world_layout.gd")
const GENERATED_REGION_SCENE: PackedScene = preload(
"res://world/generation/generated_world_region.tscn"
@ -75,6 +78,10 @@ func get_decor_shop() -> DecorShopInteractionType:
return _active_region.get_decor_shop()
func get_rv_upgrade_shop() -> RVUpgradeInteractionType:
return _active_region.get_rv_upgrade_shop()
func get_player_spawn_transform() -> Transform3D:
return _active_region.get_player_spawn_transform()
@ -170,10 +177,29 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D:
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
minimum_up_dot: float = 0.6,
maximum_global_y: float = 100.0,
) -> Array[PackedVector3Array]:
return _active_region.get_spawn_surface_triangles(
material_names,
minimum_global_y,
minimum_up_dot,
maximum_global_y,
)
func get_saltwater_surface_height() -> float:
return (
_active_region.get_saltwater_surface_height()
if _active_region != null
else NAN
)
func is_home_exterior_position_accessible(position: Vector3) -> bool:
return (
_active_region != null
and _active_region.is_home_exterior_position_accessible(position)
)