Add minimal Bluegill fishing loop

This commit is contained in:
Alexander Sellite 2026-07-25 11:05:37 -04:00
parent dd2563c0d8
commit bba93eb3e5
16 changed files with 461 additions and 3 deletions

8
fish/bluegill.tres Normal file
View file

@ -0,0 +1,8 @@
[gd_resource type="Resource" load_steps=2 format=3]
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
[resource]
script = ExtResource("1_fish_data")
id = &"bluegill"
display_name = "Bluegill"

6
fish/fish_data.gd Normal file
View file

@ -0,0 +1,6 @@
class_name FishData
extends Resource
@export var id: StringName
@export var display_name: String
@export var icon: Texture2D

1
fish/fish_data.gd.uid Normal file
View file

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

207
fishing/fishing_spot.gd Normal file
View file

@ -0,0 +1,207 @@
class_name FishingSpot
extends Area3D
const FishDataType = preload("res://fish/fish_data.gd")
const PlayerType = preload("res://player/player.gd")
signal status_changed(status: String)
signal reel_progress_changed(progress: float, visible: bool)
enum FishingState {
READY,
WAITING_FOR_BITE,
BITE_ACTIVE,
REELING,
COOLDOWN,
}
@export_category("Timing")
@export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0
@export_range(0.1, 10.0, 0.1) var bite_window_duration: float = 1.5
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
@export_range(0.01, 5.0, 0.01) var reel_gain_rate: float = 0.67
@export_range(0.01, 5.0, 0.01) var reel_decay_rate: float = 0.2
@export_range(0.0, 0.95, 0.05) var hooked_starting_progress: float = 0.25
@export_category("Catch")
@export var catchable_fish: FishDataType
var state: FishingState = FishingState.READY
var _nearby_player: PlayerType
var _active_player: PlayerType
var _state_time_remaining: float = 0.0
var _reel_progress: float = 0.0
var _reel_input_held: bool = false
var _cooldown_status: String = ""
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _process(delta: float) -> void:
match state:
FishingState.WAITING_FOR_BITE:
_state_time_remaining -= delta
if _state_time_remaining <= 0.0:
_activate_bite()
FishingState.BITE_ACTIVE:
_state_time_remaining -= delta
if _state_time_remaining <= 0.0:
_miss_bite()
FishingState.REELING:
if _reel_input_held and not Input.is_action_pressed("fish_primary"):
_reel_input_held = false
_update_reel_progress(delta)
FishingState.COOLDOWN:
_state_time_remaining -= delta
if _state_time_remaining <= 0.0:
_return_to_ready()
func _unhandled_input(event: InputEvent) -> void:
if _nearby_player == null or not _nearby_player.is_local_control_enabled():
return
if not event.is_action("fish_primary"):
return
if event.is_pressed():
match state:
FishingState.READY:
_begin_fishing(_nearby_player)
FishingState.BITE_ACTIVE:
_confirm_hook()
FishingState.REELING:
_reel_input_held = true
_:
return
get_viewport().set_input_as_handled()
elif state == FishingState.REELING:
_reel_input_held = false
get_viewport().set_input_as_handled()
func _begin_fishing(player: PlayerType) -> void:
if state != FishingState.READY or _active_player != null:
return
_active_player = player
_active_player.set_movement_enabled(false)
state = FishingState.WAITING_FOR_BITE
_state_time_remaining = wait_time
status_changed.emit("Waiting for a bite...")
func _activate_bite() -> void:
if state != FishingState.WAITING_FOR_BITE:
return
state = FishingState.BITE_ACTIVE
_state_time_remaining = bite_window_duration
status_changed.emit("Bite! Left click to hook")
func _confirm_hook() -> void:
if state != FishingState.BITE_ACTIVE or _active_player == null:
return
state = FishingState.REELING
_state_time_remaining = 0.0
_reel_progress = clampf(hooked_starting_progress, 0.0, 0.95)
_reel_input_held = true
status_changed.emit("Hold left click to reel")
reel_progress_changed.emit(_reel_progress, true)
func _update_reel_progress(delta: float) -> void:
if state != FishingState.REELING:
return
if _reel_input_held:
_reel_progress = minf(_reel_progress + reel_gain_rate * delta, 1.0)
else:
_reel_progress = maxf(_reel_progress - reel_decay_rate * delta, 0.0)
reel_progress_changed.emit(_reel_progress, true)
if is_equal_approx(_reel_progress, 1.0):
_resolve_catch()
elif is_zero_approx(_reel_progress):
_resolve_escape()
func _resolve_catch() -> void:
if state != FishingState.REELING or _active_player == null or catchable_fish == null:
_cancel_attempt()
return
_active_player.inventory.add_fish(catchable_fish)
_cleanup_attempt("Caught %s!" % catchable_fish.display_name)
func _resolve_escape() -> void:
if state != FishingState.REELING:
return
var fish_name: String = "The fish"
if catchable_fish != null and not catchable_fish.display_name.is_empty():
fish_name = "The %s" % catchable_fish.display_name
_cleanup_attempt("%s got away!" % fish_name)
func _miss_bite() -> void:
if state != FishingState.BITE_ACTIVE:
return
_cleanup_attempt("The fish got away.")
func _cleanup_attempt(cooldown_message: String = "") -> void:
if _active_player != null:
_active_player.set_movement_enabled(true)
_active_player = null
_reel_input_held = false
_reel_progress = 0.0
reel_progress_changed.emit(0.0, false)
if cooldown_message.is_empty():
state = FishingState.READY
_state_time_remaining = 0.0
_cooldown_status = ""
if _nearby_player != null:
status_changed.emit("Left click to cast")
else:
status_changed.emit("")
else:
state = FishingState.COOLDOWN
_state_time_remaining = cooldown_duration
_cooldown_status = cooldown_message
status_changed.emit(_cooldown_status)
func _return_to_ready() -> void:
_cleanup_attempt()
func _cancel_attempt() -> void:
_cleanup_attempt()
func _on_body_entered(body: Node3D) -> void:
if body is not PlayerType or not body.is_local_control_enabled():
return
if _nearby_player != null:
return
_nearby_player = body
if state == FishingState.READY:
status_changed.emit("Left click to cast")
func _on_body_exited(body: Node3D) -> void:
if body != _nearby_player:
return
_nearby_player = null
if body == _active_player:
_cancel_attempt()
else:
status_changed.emit("")

View file

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

44
fishing/fishing_spot.tscn Normal file
View file

@ -0,0 +1,44 @@
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"]
[ext_resource type="Resource" path="res://fish/bluegill.tres" id="2_bluegill"]
[sub_resource type="SphereShape3D" id="RangeShape"]
radius = 3.5
[sub_resource type="CylinderMesh" id="MarkerPostMesh"]
top_radius = 0.12
bottom_radius = 0.12
height = 2.0
[sub_resource type="TorusMesh" id="RangeMarkerMesh"]
inner_radius = 3.35
outer_radius = 3.5
rings = 32
[sub_resource type="StandardMaterial3D" id="MarkerMaterial"]
albedo_color = Color(0.95, 0.82, 0.18, 1)
emission_enabled = true
emission = Color(0.3, 0.2, 0.01, 1)
emission_energy_multiplier = 0.7
[node name="FishingSpot" type="Area3D"]
collision_layer = 0
collision_mask = 2
monitoring = true
monitorable = false
script = ExtResource("1_spot")
catchable_fish = ExtResource("2_bluegill")
[node name="RangeCollision" type="CollisionShape3D" parent="."]
shape = SubResource("RangeShape")
[node name="MarkerPost" type="MeshInstance3D" parent="."]
position = Vector3(0, 1, 0)
mesh = SubResource("MarkerPostMesh")
material_override = SubResource("MarkerMaterial")
[node name="RangeMarker" type="MeshInstance3D" parent="."]
position = Vector3(0, 0.03, 0)
mesh = SubResource("RangeMarkerMesh")
material_override = SubResource("MarkerMaterial")

View file

@ -0,0 +1,21 @@
class_name FishInventory
extends Node
const FishDataType = preload("res://fish/fish_data.gd")
signal contents_changed(fish_id: StringName, count: int)
var _fish_counts: Dictionary[StringName, int] = {}
func add_fish(fish: FishDataType, amount: int = 1) -> void:
if fish == null or fish.id.is_empty() or amount <= 0:
return
var new_count: int = get_count(fish.id) + amount
_fish_counts[fish.id] = new_count
contents_changed.emit(fish.id, new_count)
func get_count(fish_id: StringName) -> int:
return _fish_counts.get(fish_id, 0)

View file

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

13
main/main.gd Normal file
View file

@ -0,0 +1,13 @@
extends Node3D
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
const GameUIType = preload("res://ui/game_ui.gd")
const PlayerType = preload("res://player/player.gd")
@onready var _player: PlayerType = %Player
@onready var _fishing_spot: FishingSpotType = %FishingSpot
@onready var _game_ui: GameUIType = %GameUI
func _ready() -> void:
_game_ui.setup(_player.inventory, _fishing_spot)

1
main/main.gd.uid Normal file
View file

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

View file

@ -1,13 +1,25 @@
[gd_scene load_steps=3 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
[ext_resource type="Script" path="res://main/main.gd" id="3_main"]
[ext_resource type="PackedScene" path="res://fishing/fishing_spot.tscn" id="4_spot"]
[ext_resource type="PackedScene" path="res://ui/game_ui.tscn" id="5_ui"]
[node name="Main" type="Node3D"] [node name="Main" type="Node3D"]
script = ExtResource("3_main")
[node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="TestWorld" parent="." instance=ExtResource("1_world")]
[node name="Players" type="Node3D" parent="."] [node name="Players" type="Node3D" parent="."]
[node name="Player" parent="Players" instance=ExtResource("2_player")] [node name="Player" parent="Players" instance=ExtResource("2_player")]
unique_name_in_owner = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 5)
[node name="FishingSpot" parent="." instance=ExtResource("4_spot")]
unique_name_in_owner = true
position = Vector3(0, 1.02, -18)
[node name="GameUI" parent="." instance=ExtResource("5_ui")]
unique_name_in_owner = true

View file

@ -1,6 +1,8 @@
class_name Player class_name Player
extends CharacterBody3D extends CharacterBody3D
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
@export_category("Movement") @export_category("Movement")
@export var walk_speed: float = 5.0 @export var walk_speed: float = 5.0
@export var sprint_speed: float = 8.0 @export var sprint_speed: float = 8.0
@ -9,6 +11,9 @@ extends CharacterBody3D
@export var jump_velocity: float = 6.0 @export var jump_velocity: float = 6.0
@export var body_rotation_speed: float = 12.0 @export var body_rotation_speed: float = 12.0
@export_category("Control")
@export var local_control_enabled: bool = true
@export_category("Camera") @export_category("Camera")
@export var mouse_sensitivity: float = 0.005 @export var mouse_sensitivity: float = 0.005
@export var controller_camera_speed: float = 2.5 @export var controller_camera_speed: float = 2.5
@ -24,23 +29,37 @@ extends CharacterBody3D
@onready var _camera_yaw: Node3D = %CameraYaw @onready var _camera_yaw: Node3D = %CameraYaw
@onready var _camera_pitch: Node3D = %CameraPitch @onready var _camera_pitch: Node3D = %CameraPitch
@onready var _spring_arm: SpringArm3D = %SpringArm3D @onready var _spring_arm: SpringArm3D = %SpringArm3D
@onready var _camera: Camera3D = %Camera3D
@onready var inventory: FishInventoryType = %Inventory
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity"))
var _camera_dragging: bool = false var _camera_dragging: bool = false
var _movement_enabled: bool = true
var _target_zoom: float = 5.0 var _target_zoom: float = 5.0
func _ready() -> void: func _ready() -> void:
_target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom) _target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom)
_spring_arm.spring_length = _target_zoom _spring_arm.spring_length = _target_zoom
_camera.current = local_control_enabled
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if not is_on_floor(): if not is_on_floor():
velocity.y -= _gravity * delta velocity.y -= _gravity * delta
elif Input.is_action_just_pressed("jump"): elif (
local_control_enabled
and _movement_enabled
and Input.is_action_just_pressed("jump")
):
velocity.y = jump_velocity velocity.y = jump_velocity
if not local_control_enabled or not _movement_enabled:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
return
var input_vector: Vector2 = Input.get_vector( var input_vector: Vector2 = Input.get_vector(
"move_left", "move_left",
"move_right", "move_right",
@ -69,6 +88,9 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not local_control_enabled:
return
if _camera_dragging and not Input.is_action_pressed("camera_drag"): if _camera_dragging and not Input.is_action_pressed("camera_drag"):
_camera_dragging = false _camera_dragging = false
@ -88,6 +110,9 @@ func _process(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not local_control_enabled:
return
if event.is_action("camera_drag"): if event.is_action("camera_drag"):
_camera_dragging = event.is_pressed() _camera_dragging = event.is_pressed()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@ -127,3 +152,22 @@ func _rotate_camera(delta_rotation: Vector2) -> void:
func _set_target_zoom(value: float) -> void: func _set_target_zoom(value: float) -> void:
_target_zoom = clampf(value, minimum_zoom, maximum_zoom) _target_zoom = clampf(value, minimum_zoom, maximum_zoom)
func set_local_control(enabled: bool) -> void:
local_control_enabled = enabled
if is_node_ready():
_camera.current = enabled
if not enabled:
_camera_dragging = false
func is_local_control_enabled() -> bool:
return local_control_enabled
func set_movement_enabled(enabled: bool) -> void:
_movement_enabled = enabled
if not enabled:
velocity.x = 0.0
velocity.z = 0.0

View file

@ -1,6 +1,7 @@
[gd_scene load_steps=7 format=3] [gd_scene load_steps=8 format=3]
[ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://player/player.gd" id="1_script"]
[ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"]
[sub_resource type="CapsuleShape3D" id="PlayerShape"] [sub_resource type="CapsuleShape3D" id="PlayerShape"]
radius = 0.45 radius = 0.45
@ -41,6 +42,10 @@ position = Vector3(0, 1.0, -0.55)
mesh = SubResource("FacingMarkerMesh") mesh = SubResource("FacingMarkerMesh")
material_override = SubResource("FacingMarkerMaterial") material_override = SubResource("FacingMarkerMaterial")
[node name="Inventory" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("2_inventory")
[node name="CameraYaw" type="Node3D" parent="."] [node name="CameraYaw" type="Node3D" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
position = Vector3(0, 1.35, 0) position = Vector3(0, 1.35, 0)
@ -56,4 +61,5 @@ margin = 0.15
collision_mask = 1 collision_mask = 1
[node name="Camera3D" type="Camera3D" parent="CameraYaw/CameraPitch/SpringArm3D"] [node name="Camera3D" type="Camera3D" parent="CameraYaw/CameraPitch/SpringArm3D"]
unique_name_in_owner = true
current = true current = true

35
ui/game_ui.gd Normal file
View file

@ -0,0 +1,35 @@
class_name GameUI
extends CanvasLayer
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
@onready var _status_label: Label = %StatusLabel
@onready var _bluegill_count_label: Label = %BluegillCountLabel
@onready var _reel_progress: ProgressBar = %ReelProgress
func setup(inventory: FishInventoryType, fishing_spot: FishingSpotType) -> void:
inventory.contents_changed.connect(_on_inventory_contents_changed)
fishing_spot.status_changed.connect(_on_fishing_status_changed)
fishing_spot.reel_progress_changed.connect(_on_reel_progress_changed)
_update_bluegill_count(inventory.get_count(&"bluegill"))
func _on_inventory_contents_changed(fish_id: StringName, count: int) -> void:
if fish_id == &"bluegill":
_update_bluegill_count(count)
func _on_fishing_status_changed(status: String) -> void:
_status_label.text = status
_status_label.visible = not status.is_empty()
func _on_reel_progress_changed(progress: float, visible: bool) -> void:
_reel_progress.value = progress * 100.0
_reel_progress.visible = visible
func _update_bluegill_count(count: int) -> void:
_bluegill_count_label.text = "Bluegill: %d" % count

1
ui/game_ui.gd.uid Normal file
View file

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

57
ui/game_ui.tscn Normal file
View file

@ -0,0 +1,57 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
[node name="GameUI" type="CanvasLayer"]
script = ExtResource("1_ui")
[node name="InventoryPanel" type="PanelContainer" parent="."]
offset_left = 16.0
offset_top = 16.0
offset_right = 176.0
offset_bottom = 62.0
[node name="MarginContainer" type="MarginContainer" parent="InventoryPanel"]
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="BluegillCountLabel" type="Label" parent="InventoryPanel/MarginContainer"]
unique_name_in_owner = true
text = "Bluegill: 0"
[node name="FishingPanel" type="PanelContainer" parent="."]
anchors_preset = 7
anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -160.0
offset_top = -100.0
offset_right = 160.0
offset_bottom = -20.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
[node name="MarginContainer" type="MarginContainer" parent="FishingPanel"]
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="VBoxContainer" type="VBoxContainer" parent="FishingPanel/MarginContainer"]
[node name="StatusLabel" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
text = "Left click to cast"
horizontal_alignment = 1
[node name="ReelProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 18)
value = 0.0
show_percentage = false