Add barrier-based catch and chase mechanic
This commit is contained in:
parent
ad848f9431
commit
ae2f724fe2
12 changed files with 631 additions and 68 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
[gd_resource type="Resource" load_steps=2 format=3]
|
[gd_resource type="Resource" load_steps=3 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
|
[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"]
|
||||||
|
[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("1_fish_data")
|
script = ExtResource("1_fish_data")
|
||||||
id = &"bluegill"
|
id = &"bluegill"
|
||||||
display_name = "Bluegill"
|
display_name = "Bluegill"
|
||||||
|
catch_profile = ExtResource("2_profile")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
class_name FishData
|
class_name FishData
|
||||||
extends Resource
|
extends Resource
|
||||||
|
|
||||||
|
const CatchDifficultyProfileType = preload(
|
||||||
|
"res://fishing/catch_difficulty_profile.gd"
|
||||||
|
)
|
||||||
|
|
||||||
@export var id: StringName
|
@export var id: StringName
|
||||||
@export var display_name: String
|
@export var display_name: String
|
||||||
@export var icon: Texture2D
|
@export var icon: Texture2D
|
||||||
|
@export var catch_profile: CatchDifficultyProfileType
|
||||||
|
|
|
||||||
314
fishing/catch_controller.gd
Normal file
314
fishing/catch_controller.gd
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
class_name CatchController
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const CatchDifficultyProfileType = preload(
|
||||||
|
"res://fishing/catch_difficulty_profile.gd"
|
||||||
|
)
|
||||||
|
|
||||||
|
signal encounter_updated(
|
||||||
|
progress: float,
|
||||||
|
chase_progress: float,
|
||||||
|
barrier_positions: PackedFloat32Array,
|
||||||
|
barrier_health: PackedInt32Array,
|
||||||
|
barrier_max_health: PackedInt32Array,
|
||||||
|
active_barrier_index: int,
|
||||||
|
visible: bool,
|
||||||
|
)
|
||||||
|
signal caught
|
||||||
|
signal escaped
|
||||||
|
|
||||||
|
enum CatchState {
|
||||||
|
IDLE,
|
||||||
|
REELING,
|
||||||
|
BLOCKED_BY_BARRIER,
|
||||||
|
CAUGHT,
|
||||||
|
ESCAPED,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Barrier:
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var position: float
|
||||||
|
var health: int
|
||||||
|
var maximum_health: int
|
||||||
|
var defeated: bool = false
|
||||||
|
|
||||||
|
func _init(
|
||||||
|
barrier_position: float,
|
||||||
|
barrier_health: int,
|
||||||
|
) -> void:
|
||||||
|
position = barrier_position
|
||||||
|
health = barrier_health
|
||||||
|
maximum_health = barrier_health
|
||||||
|
|
||||||
|
|
||||||
|
@export_category("Accessibility")
|
||||||
|
@export var auto_click_enabled: bool = false
|
||||||
|
@export_range(0.05, 2.0, 0.01) var auto_click_interval: float = 0.20
|
||||||
|
|
||||||
|
@export_category("Testing")
|
||||||
|
@export var use_deterministic_test_seed: bool = false
|
||||||
|
@export var deterministic_test_seed: int = 12345
|
||||||
|
|
||||||
|
var state: CatchState = CatchState.IDLE
|
||||||
|
var progress: float = 0.0
|
||||||
|
var chase_progress: float = 0.0
|
||||||
|
var encounter_seed: int = 0
|
||||||
|
|
||||||
|
var _barriers: Array[Barrier] = []
|
||||||
|
var _active_barrier_index: int = -1
|
||||||
|
var _reel_input_held: bool = false
|
||||||
|
var _reel_speed: float = 0.0
|
||||||
|
var _click_power: int = 1
|
||||||
|
var _chase_start_delay: float = 0.0
|
||||||
|
var _chase_delay_remaining: float = 0.0
|
||||||
|
var _chase_speed: float = 0.0
|
||||||
|
var _failure_epsilon: float = 0.0001
|
||||||
|
var _auto_click_accumulator: float = 0.0
|
||||||
|
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
match state:
|
||||||
|
CatchState.REELING:
|
||||||
|
_update_free_reeling(delta)
|
||||||
|
CatchState.BLOCKED_BY_BARRIER:
|
||||||
|
_update_auto_click(delta)
|
||||||
|
_:
|
||||||
|
return
|
||||||
|
|
||||||
|
if state in [CatchState.CAUGHT, CatchState.ESCAPED, CatchState.IDLE]:
|
||||||
|
return
|
||||||
|
if progress >= 1.0:
|
||||||
|
_resolve_catch()
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_chase(delta)
|
||||||
|
if _is_chase_failure():
|
||||||
|
_resolve_escape()
|
||||||
|
return
|
||||||
|
_emit_encounter_update()
|
||||||
|
|
||||||
|
|
||||||
|
func start_encounter(
|
||||||
|
profile: CatchDifficultyProfileType,
|
||||||
|
reel_speed: float,
|
||||||
|
click_power: int,
|
||||||
|
) -> void:
|
||||||
|
reset()
|
||||||
|
if profile == null:
|
||||||
|
_resolve_escape()
|
||||||
|
return
|
||||||
|
|
||||||
|
_reel_speed = maxf(reel_speed, 0.0)
|
||||||
|
_click_power = maxi(click_power, 1)
|
||||||
|
_chase_start_delay = maxf(profile.chase_start_delay, 0.0)
|
||||||
|
_chase_delay_remaining = _chase_start_delay
|
||||||
|
chase_progress = -maxf(profile.chase_start_offset, 0.01)
|
||||||
|
_chase_speed = maxf(profile.chase_speed, 0.0)
|
||||||
|
_seed_encounter_rng()
|
||||||
|
_generate_barriers(profile)
|
||||||
|
progress = 0.0
|
||||||
|
state = CatchState.REELING
|
||||||
|
_emit_encounter_update()
|
||||||
|
|
||||||
|
|
||||||
|
func set_reel_input(held: bool) -> void:
|
||||||
|
_reel_input_held = held
|
||||||
|
if not held:
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func handle_primary_pressed() -> void:
|
||||||
|
if state not in [CatchState.REELING, CatchState.BLOCKED_BY_BARRIER]:
|
||||||
|
return
|
||||||
|
_reel_input_held = true
|
||||||
|
if state == CatchState.BLOCKED_BY_BARRIER and not auto_click_enabled:
|
||||||
|
_damage_active_barrier()
|
||||||
|
|
||||||
|
|
||||||
|
func reset() -> void:
|
||||||
|
state = CatchState.IDLE
|
||||||
|
progress = 0.0
|
||||||
|
chase_progress = 0.0
|
||||||
|
encounter_seed = 0
|
||||||
|
_barriers.clear()
|
||||||
|
_active_barrier_index = -1
|
||||||
|
_reel_input_held = false
|
||||||
|
_reel_speed = 0.0
|
||||||
|
_click_power = 1
|
||||||
|
_chase_start_delay = 0.0
|
||||||
|
_chase_delay_remaining = 0.0
|
||||||
|
_chase_speed = 0.0
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
emit_signal(
|
||||||
|
"encounter_updated",
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
PackedFloat32Array(),
|
||||||
|
PackedInt32Array(),
|
||||||
|
PackedInt32Array(),
|
||||||
|
-1,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_barrier_count() -> int:
|
||||||
|
return _barriers.size()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_free_reeling(delta: float) -> void:
|
||||||
|
if not _reel_input_held:
|
||||||
|
return
|
||||||
|
|
||||||
|
var intended_progress: float = minf(
|
||||||
|
progress + _reel_speed * delta,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
var next_barrier: Barrier = _get_next_undefeated_barrier()
|
||||||
|
if next_barrier != null and intended_progress >= next_barrier.position:
|
||||||
|
progress = next_barrier.position
|
||||||
|
_active_barrier_index = _barriers.find(next_barrier)
|
||||||
|
state = CatchState.BLOCKED_BY_BARRIER
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
else:
|
||||||
|
progress = intended_progress
|
||||||
|
|
||||||
|
|
||||||
|
func _update_chase(delta: float) -> void:
|
||||||
|
var active_delta: float = delta
|
||||||
|
if _chase_delay_remaining > 0.0:
|
||||||
|
var protected_time: float = minf(_chase_delay_remaining, active_delta)
|
||||||
|
_chase_delay_remaining -= protected_time
|
||||||
|
active_delta -= protected_time
|
||||||
|
if active_delta <= 0.0:
|
||||||
|
return
|
||||||
|
chase_progress = minf(
|
||||||
|
chase_progress + _chase_speed * active_delta,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _is_chase_failure() -> bool:
|
||||||
|
if _chase_delay_remaining > 0.0:
|
||||||
|
return false
|
||||||
|
return chase_progress + _failure_epsilon >= progress
|
||||||
|
|
||||||
|
|
||||||
|
func _update_auto_click(delta: float) -> void:
|
||||||
|
if not auto_click_enabled or not _reel_input_held:
|
||||||
|
return
|
||||||
|
_auto_click_accumulator += delta
|
||||||
|
var interval: float = maxf(auto_click_interval, 0.05)
|
||||||
|
while (
|
||||||
|
_auto_click_accumulator >= interval
|
||||||
|
and state == CatchState.BLOCKED_BY_BARRIER
|
||||||
|
and _reel_input_held
|
||||||
|
):
|
||||||
|
_auto_click_accumulator -= interval
|
||||||
|
_damage_active_barrier()
|
||||||
|
|
||||||
|
|
||||||
|
func _damage_active_barrier() -> void:
|
||||||
|
if (
|
||||||
|
state != CatchState.BLOCKED_BY_BARRIER
|
||||||
|
or _active_barrier_index < 0
|
||||||
|
or _active_barrier_index >= _barriers.size()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
var barrier: Barrier = _barriers[_active_barrier_index]
|
||||||
|
if barrier.defeated:
|
||||||
|
return
|
||||||
|
barrier.health = maxi(barrier.health - _click_power, 0)
|
||||||
|
if barrier.health == 0:
|
||||||
|
barrier.defeated = true
|
||||||
|
_active_barrier_index = -1
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
state = CatchState.REELING
|
||||||
|
_emit_encounter_update()
|
||||||
|
|
||||||
|
|
||||||
|
func _get_next_undefeated_barrier() -> Barrier:
|
||||||
|
for barrier: Barrier in _barriers:
|
||||||
|
if not barrier.defeated and barrier.position >= progress:
|
||||||
|
return barrier
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _seed_encounter_rng() -> void:
|
||||||
|
if use_deterministic_test_seed:
|
||||||
|
encounter_seed = deterministic_test_seed
|
||||||
|
_rng.seed = deterministic_test_seed
|
||||||
|
else:
|
||||||
|
_rng.randomize()
|
||||||
|
encounter_seed = _rng.seed
|
||||||
|
|
||||||
|
|
||||||
|
func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
|
||||||
|
var count_range: Vector2i = profile.get_barrier_count_range()
|
||||||
|
var health_range: Vector2i = profile.get_barrier_health_range()
|
||||||
|
var interval: Vector2 = profile.get_generation_interval()
|
||||||
|
var spacing: float = maxf(profile.minimum_barrier_spacing, 0.01)
|
||||||
|
var available_distance: float = maxf(interval.y - interval.x, 0.0)
|
||||||
|
var maximum_fitting_count: int = 0
|
||||||
|
if available_distance > 0.0:
|
||||||
|
maximum_fitting_count = int(floor(available_distance / spacing)) + 1
|
||||||
|
var requested_count: int = _rng.randi_range(count_range.x, count_range.y)
|
||||||
|
var barrier_count: int = mini(requested_count, maximum_fitting_count)
|
||||||
|
if barrier_count <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
var random_offsets := PackedFloat32Array()
|
||||||
|
for _barrier_index: int in range(barrier_count):
|
||||||
|
random_offsets.append(_rng.randf())
|
||||||
|
random_offsets.sort()
|
||||||
|
var required_spacing: float = spacing * float(barrier_count - 1)
|
||||||
|
var random_slack: float = maxf(available_distance - required_spacing, 0.0)
|
||||||
|
for barrier_index: int in range(barrier_count):
|
||||||
|
var position: float = (
|
||||||
|
interval.x
|
||||||
|
+ spacing * float(barrier_index)
|
||||||
|
+ random_offsets[barrier_index] * random_slack
|
||||||
|
)
|
||||||
|
var health: int = _rng.randi_range(health_range.x, health_range.y)
|
||||||
|
_barriers.append(Barrier.new(position, health))
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_catch() -> void:
|
||||||
|
if state in [CatchState.CAUGHT, CatchState.ESCAPED, CatchState.IDLE]:
|
||||||
|
return
|
||||||
|
state = CatchState.CAUGHT
|
||||||
|
_reel_input_held = false
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
_emit_encounter_update()
|
||||||
|
caught.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_escape() -> void:
|
||||||
|
if state in [CatchState.CAUGHT, CatchState.ESCAPED]:
|
||||||
|
return
|
||||||
|
state = CatchState.ESCAPED
|
||||||
|
_reel_input_held = false
|
||||||
|
_auto_click_accumulator = 0.0
|
||||||
|
_emit_encounter_update()
|
||||||
|
escaped.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_encounter_update() -> void:
|
||||||
|
var positions := PackedFloat32Array()
|
||||||
|
var health := PackedInt32Array()
|
||||||
|
var maximum_health := PackedInt32Array()
|
||||||
|
for barrier: Barrier in _barriers:
|
||||||
|
positions.append(barrier.position)
|
||||||
|
health.append(barrier.health)
|
||||||
|
maximum_health.append(barrier.maximum_health)
|
||||||
|
encounter_updated.emit(
|
||||||
|
progress,
|
||||||
|
chase_progress,
|
||||||
|
positions,
|
||||||
|
health,
|
||||||
|
maximum_health,
|
||||||
|
_active_barrier_index,
|
||||||
|
state != CatchState.IDLE
|
||||||
|
)
|
||||||
1
fishing/catch_controller.gd.uid
Normal file
1
fishing/catch_controller.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bp3fvh7q1jw07
|
||||||
29
fishing/catch_difficulty_profile.gd
Normal file
29
fishing/catch_difficulty_profile.gd
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
class_name CatchDifficultyProfile
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export_range(0, 12, 1) var barrier_count_min: int = 2
|
||||||
|
@export_range(0, 12, 1) var barrier_count_max: int = 4
|
||||||
|
@export_range(1, 100, 1) var barrier_health_min: int = 3
|
||||||
|
@export_range(1, 100, 1) var barrier_health_max: int = 7
|
||||||
|
@export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15
|
||||||
|
@export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10
|
||||||
|
@export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15
|
||||||
|
@export_range(0.0, 5.0, 0.05) var chase_start_delay: float = 1.0
|
||||||
|
@export_range(0.01, 0.5, 0.01) var chase_start_offset: float = 0.15
|
||||||
|
@export_range(0.0, 2.0, 0.01) var chase_speed: float = 0.14
|
||||||
|
|
||||||
|
|
||||||
|
func get_barrier_count_range() -> Vector2i:
|
||||||
|
var minimum: int = maxi(0, barrier_count_min)
|
||||||
|
return Vector2i(minimum, maxi(minimum, barrier_count_max))
|
||||||
|
|
||||||
|
|
||||||
|
func get_barrier_health_range() -> Vector2i:
|
||||||
|
var minimum: int = maxi(1, barrier_health_min)
|
||||||
|
return Vector2i(minimum, maxi(minimum, barrier_health_max))
|
||||||
|
|
||||||
|
|
||||||
|
func get_generation_interval() -> Vector2:
|
||||||
|
var start: float = clampf(first_barrier_margin, 0.0, 0.95)
|
||||||
|
var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0)
|
||||||
|
return Vector2(start, finish)
|
||||||
1
fishing/catch_difficulty_profile.gd.uid
Normal file
1
fishing/catch_difficulty_profile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://byhefytd4228q
|
||||||
16
fishing/common_catch_profile.tres
Normal file
16
fishing/common_catch_profile.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[gd_resource type="Resource" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://fishing/catch_difficulty_profile.gd" id="1_profile"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1_profile")
|
||||||
|
barrier_count_min = 2
|
||||||
|
barrier_count_max = 4
|
||||||
|
barrier_health_min = 3
|
||||||
|
barrier_health_max = 7
|
||||||
|
first_barrier_margin = 0.15
|
||||||
|
final_barrier_margin = 0.1
|
||||||
|
minimum_barrier_spacing = 0.15
|
||||||
|
chase_start_delay = 1.0
|
||||||
|
chase_start_offset = 0.15
|
||||||
|
chase_speed = 0.14
|
||||||
|
|
@ -2,11 +2,20 @@ class_name FishingSpot
|
||||||
extends Node3D
|
extends Node3D
|
||||||
|
|
||||||
const FishDataType = preload("res://fish/fish_data.gd")
|
const FishDataType = preload("res://fish/fish_data.gd")
|
||||||
|
const CatchControllerType = preload("res://fishing/catch_controller.gd")
|
||||||
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
|
const FishingPresentationType = preload("res://fishing/fishing_presentation.gd")
|
||||||
const PlayerType = preload("res://player/player.gd")
|
const PlayerType = preload("res://player/player.gd")
|
||||||
|
|
||||||
signal status_changed(status: String)
|
signal status_changed(status: String)
|
||||||
signal reel_progress_changed(progress: float, visible: bool)
|
signal catch_display_changed(
|
||||||
|
progress: float,
|
||||||
|
chase_progress: float,
|
||||||
|
barrier_positions: PackedFloat32Array,
|
||||||
|
barrier_health: PackedInt32Array,
|
||||||
|
barrier_max_health: PackedInt32Array,
|
||||||
|
active_barrier_index: int,
|
||||||
|
visible: bool,
|
||||||
|
)
|
||||||
|
|
||||||
enum FishingState {
|
enum FishingState {
|
||||||
READY,
|
READY,
|
||||||
|
|
@ -14,7 +23,7 @@ enum FishingState {
|
||||||
CASTING,
|
CASTING,
|
||||||
WAITING_FOR_BITE,
|
WAITING_FOR_BITE,
|
||||||
BITE_ACTIVE,
|
BITE_ACTIVE,
|
||||||
REELING,
|
FIGHTING,
|
||||||
COOLDOWN,
|
COOLDOWN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,13 +48,11 @@ enum FishingState {
|
||||||
@export_range(0.1, 30.0, 0.1) var wait_time: float = 2.0
|
@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 bite_window_duration: float = 1.5
|
||||||
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
|
@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_category("Catch")
|
||||||
@export var catchable_fish: FishDataType
|
@export var catchable_fish: FishDataType
|
||||||
|
|
||||||
|
@onready var _catch_controller: CatchControllerType = %CatchController
|
||||||
@onready var _presentation: FishingPresentationType = %FishingPresentation
|
@onready var _presentation: FishingPresentationType = %FishingPresentation
|
||||||
|
|
||||||
var state: FishingState = FishingState.READY
|
var state: FishingState = FishingState.READY
|
||||||
|
|
@ -62,14 +69,15 @@ var _withdrawal_progress: float = 0.0
|
||||||
var _withdrawal_input_held: bool = false
|
var _withdrawal_input_held: bool = false
|
||||||
var _new_cast_press_armed: bool = true
|
var _new_cast_press_armed: bool = true
|
||||||
var _bobber_water_position: Vector3
|
var _bobber_water_position: Vector3
|
||||||
var _reel_start_position: Vector3
|
var _fight_start_position: Vector3
|
||||||
var _reel_progress: float = 0.0
|
|
||||||
var _reel_input_held: bool = false
|
|
||||||
var _cooldown_status: String = ""
|
var _cooldown_status: String = ""
|
||||||
var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
|
var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_catch_controller.encounter_updated.connect(_on_catch_encounter_updated)
|
||||||
|
_catch_controller.caught.connect(_on_catch_completed)
|
||||||
|
_catch_controller.escaped.connect(_on_catch_escaped)
|
||||||
_presentation.cast_completed.connect(_on_cast_completed)
|
_presentation.cast_completed.connect(_on_cast_completed)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,10 +100,9 @@ func _process(delta: float) -> void:
|
||||||
_state_time_remaining -= delta
|
_state_time_remaining -= delta
|
||||||
if _state_time_remaining <= 0.0:
|
if _state_time_remaining <= 0.0:
|
||||||
_miss_bite()
|
_miss_bite()
|
||||||
FishingState.REELING:
|
FishingState.FIGHTING:
|
||||||
if _reel_input_held and not Input.is_action_pressed("fish_primary"):
|
if not Input.is_action_pressed("fish_primary"):
|
||||||
_reel_input_held = false
|
_catch_controller.set_reel_input(false)
|
||||||
_update_reel_progress(delta)
|
|
||||||
FishingState.COOLDOWN:
|
FishingState.COOLDOWN:
|
||||||
_state_time_remaining -= delta
|
_state_time_remaining -= delta
|
||||||
if _state_time_remaining <= 0.0:
|
if _state_time_remaining <= 0.0:
|
||||||
|
|
@ -111,7 +118,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
FishingState.CASTING,
|
FishingState.CASTING,
|
||||||
FishingState.WAITING_FOR_BITE,
|
FishingState.WAITING_FOR_BITE,
|
||||||
FishingState.BITE_ACTIVE,
|
FishingState.BITE_ACTIVE,
|
||||||
FishingState.REELING,
|
FishingState.FIGHTING,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
_cancel_attempt()
|
_cancel_attempt()
|
||||||
|
|
@ -136,8 +143,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
)
|
)
|
||||||
FishingState.BITE_ACTIVE:
|
FishingState.BITE_ACTIVE:
|
||||||
_confirm_hook()
|
_confirm_hook()
|
||||||
FishingState.REELING:
|
FishingState.FIGHTING:
|
||||||
_reel_input_held = true
|
_catch_controller.handle_primary_pressed()
|
||||||
_presentation.set_line_mode(
|
_presentation.set_line_mode(
|
||||||
FishingPresentationType.LineMode.TAUT
|
FishingPresentationType.LineMode.TAUT
|
||||||
)
|
)
|
||||||
|
|
@ -151,8 +158,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
_withdrawal_input_held = false
|
_withdrawal_input_held = false
|
||||||
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
|
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
elif state == FishingState.REELING:
|
elif state == FishingState.FIGHTING:
|
||||||
_reel_input_held = false
|
_catch_controller.set_reel_input(false)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -340,58 +347,80 @@ func _activate_bite() -> void:
|
||||||
|
|
||||||
|
|
||||||
func _confirm_hook() -> void:
|
func _confirm_hook() -> void:
|
||||||
if state != FishingState.BITE_ACTIVE or _active_player == null:
|
if (
|
||||||
|
state != FishingState.BITE_ACTIVE
|
||||||
|
or _active_player == null
|
||||||
|
or catchable_fish == null
|
||||||
|
or catchable_fish.catch_profile == null
|
||||||
|
):
|
||||||
|
_cancel_attempt()
|
||||||
return
|
return
|
||||||
|
|
||||||
state = FishingState.REELING
|
state = FishingState.FIGHTING
|
||||||
_state_time_remaining = 0.0
|
_state_time_remaining = 0.0
|
||||||
_reel_progress = clampf(hooked_starting_progress, 0.0, 0.95)
|
_fight_start_position = _bobber_water_position
|
||||||
_reel_input_held = true
|
|
||||||
_reel_start_position = _bobber_water_position
|
|
||||||
status_changed.emit("Hold left click to reel")
|
status_changed.emit("Hold left click to reel")
|
||||||
reel_progress_changed.emit(_reel_progress, true)
|
|
||||||
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
|
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
|
||||||
_presentation.begin_reeling()
|
_presentation.begin_reeling()
|
||||||
_update_reel_presentation()
|
_catch_controller.start_encounter(
|
||||||
|
catchable_fish.catch_profile,
|
||||||
|
_active_player.reel_speed,
|
||||||
|
_active_player.click_power
|
||||||
|
)
|
||||||
|
_catch_controller.set_reel_input(true)
|
||||||
|
|
||||||
|
|
||||||
func _update_reel_progress(delta: float) -> void:
|
func _on_catch_encounter_updated(
|
||||||
if state != FishingState.REELING:
|
progress: float,
|
||||||
|
chase_progress: float,
|
||||||
|
barrier_positions: PackedFloat32Array,
|
||||||
|
barrier_health: PackedInt32Array,
|
||||||
|
barrier_max_health: PackedInt32Array,
|
||||||
|
active_barrier_index: int,
|
||||||
|
visible: bool,
|
||||||
|
) -> void:
|
||||||
|
catch_display_changed.emit(
|
||||||
|
progress,
|
||||||
|
chase_progress,
|
||||||
|
barrier_positions,
|
||||||
|
barrier_health,
|
||||||
|
barrier_max_health,
|
||||||
|
active_barrier_index,
|
||||||
|
visible
|
||||||
|
)
|
||||||
|
if state != FishingState.FIGHTING or not visible:
|
||||||
return
|
return
|
||||||
|
|
||||||
if _reel_input_held:
|
var desired_position: Vector3 = _fight_start_position.lerp(
|
||||||
_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)
|
|
||||||
_update_reel_presentation()
|
|
||||||
if is_equal_approx(_reel_progress, 1.0):
|
|
||||||
_resolve_catch()
|
|
||||||
elif is_zero_approx(_reel_progress):
|
|
||||||
_resolve_escape()
|
|
||||||
|
|
||||||
|
|
||||||
func _update_reel_presentation() -> void:
|
|
||||||
var desired_position: Vector3 = _reel_start_position.lerp(
|
|
||||||
_withdrawal_endpoint,
|
_withdrawal_endpoint,
|
||||||
_reel_progress
|
progress
|
||||||
)
|
)
|
||||||
_bobber_water_position = _get_clamped_reel_position(desired_position)
|
_bobber_water_position = find_last_fishable_position(
|
||||||
_presentation.show_reel_position(
|
_fight_start_position,
|
||||||
_bobber_water_position,
|
|
||||||
_reel_input_held
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _get_clamped_reel_position(desired_position: Vector3) -> Vector3:
|
|
||||||
return find_last_fishable_position(
|
|
||||||
_reel_start_position,
|
|
||||||
desired_position
|
desired_position
|
||||||
)
|
)
|
||||||
|
_presentation.show_reel_position(
|
||||||
|
_bobber_water_position,
|
||||||
|
Input.is_action_pressed("fish_primary")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
active_barrier_index >= 0
|
||||||
|
and active_barrier_index < barrier_health.size()
|
||||||
|
and active_barrier_index < barrier_max_health.size()
|
||||||
|
):
|
||||||
|
status_changed.emit(
|
||||||
|
"Barrier: %d / %d"
|
||||||
|
% [
|
||||||
|
barrier_health[active_barrier_index],
|
||||||
|
barrier_max_health[active_barrier_index],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status_changed.emit("Hold left click to reel")
|
||||||
|
|
||||||
|
|
||||||
func _resolve_catch() -> void:
|
func _on_catch_completed() -> void:
|
||||||
if state != FishingState.REELING or _active_player == null or catchable_fish == null:
|
if state != FishingState.FIGHTING or _active_player == null or catchable_fish == null:
|
||||||
_cancel_attempt()
|
_cancel_attempt()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -399,8 +428,8 @@ func _resolve_catch() -> void:
|
||||||
_cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch")
|
_cleanup_attempt("Caught %s!" % catchable_fish.display_name, &"catch")
|
||||||
|
|
||||||
|
|
||||||
func _resolve_escape() -> void:
|
func _on_catch_escaped() -> void:
|
||||||
if state != FishingState.REELING:
|
if state != FishingState.FIGHTING:
|
||||||
return
|
return
|
||||||
|
|
||||||
var fish_name: String = "The fish"
|
var fish_name: String = "The fish"
|
||||||
|
|
@ -432,10 +461,8 @@ func _cleanup_attempt(
|
||||||
_withdrawal_progress = 0.0
|
_withdrawal_progress = 0.0
|
||||||
_withdrawal_input_held = false
|
_withdrawal_input_held = false
|
||||||
_bobber_water_position = Vector3.ZERO
|
_bobber_water_position = Vector3.ZERO
|
||||||
_reel_start_position = Vector3.ZERO
|
_fight_start_position = Vector3.ZERO
|
||||||
_reel_input_held = false
|
_catch_controller.reset()
|
||||||
_reel_progress = 0.0
|
|
||||||
reel_progress_changed.emit(0.0, false)
|
|
||||||
if visual_outcome.is_empty():
|
if visual_outcome.is_empty():
|
||||||
_presentation.cleanup()
|
_presentation.cleanup()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
[gd_scene load_steps=11 format=3]
|
[gd_scene load_steps=12 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://fishing/fishing_spot.gd" id="1_spot"]
|
[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"]
|
[ext_resource type="Resource" path="res://fish/bluegill.tres" id="2_bluegill"]
|
||||||
[ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"]
|
[ext_resource type="Script" path="res://fishing/fishing_presentation.gd" id="3_presentation"]
|
||||||
|
[ext_resource type="Script" path="res://fishing/catch_controller.gd" id="4_catch"]
|
||||||
|
|
||||||
[sub_resource type="SphereMesh" id="BobberMesh"]
|
[sub_resource type="SphereMesh" id="BobberMesh"]
|
||||||
radius = 0.12
|
radius = 0.12
|
||||||
|
|
@ -42,6 +43,10 @@ size = Vector3(0.72, 0.06, 0.12)
|
||||||
script = ExtResource("1_spot")
|
script = ExtResource("1_spot")
|
||||||
catchable_fish = ExtResource("2_bluegill")
|
catchable_fish = ExtResource("2_bluegill")
|
||||||
|
|
||||||
|
[node name="CatchController" type="Node" parent="."]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
script = ExtResource("4_catch")
|
||||||
|
|
||||||
[node name="FishingPresentation" type="Node3D" parent="."]
|
[node name="FishingPresentation" type="Node3D" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
script = ExtResource("3_presentation")
|
script = ExtResource("3_presentation")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ const FishInventoryType = preload("res://inventory/fish_inventory.gd")
|
||||||
@export_category("Control")
|
@export_category("Control")
|
||||||
@export var local_control_enabled: bool = true
|
@export var local_control_enabled: bool = true
|
||||||
|
|
||||||
|
@export_category("Fishing Stats")
|
||||||
|
@export_range(0.01, 2.0, 0.01) var reel_speed: float = 0.32
|
||||||
|
@export_range(1, 100, 1) var click_power: int = 1
|
||||||
|
|
||||||
@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
|
||||||
|
|
|
||||||
111
ui/game_ui.gd
111
ui/game_ui.gd
|
|
@ -6,13 +6,18 @@ const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||||
|
|
||||||
@onready var _status_label: Label = %StatusLabel
|
@onready var _status_label: Label = %StatusLabel
|
||||||
@onready var _bluegill_count_label: Label = %BluegillCountLabel
|
@onready var _bluegill_count_label: Label = %BluegillCountLabel
|
||||||
@onready var _reel_progress: ProgressBar = %ReelProgress
|
@onready var _catch_track: Control = %CatchTrack
|
||||||
|
@onready var _green_catch_progress: ProgressBar = %GreenCatchProgress
|
||||||
|
@onready var _red_chase_progress: ProgressBar = %RedChaseProgress
|
||||||
|
@onready var _barrier_markers: Control = %BarrierMarkers
|
||||||
|
@onready var _barrier_summary: Label = %BarrierSummary
|
||||||
|
@onready var _barrier_health: Label = %BarrierHealth
|
||||||
|
|
||||||
|
|
||||||
func setup(inventory: FishInventoryType, fishing_spot: FishingSpotType) -> void:
|
func setup(inventory: FishInventoryType, fishing_spot: FishingSpotType) -> void:
|
||||||
inventory.contents_changed.connect(_on_inventory_contents_changed)
|
inventory.contents_changed.connect(_on_inventory_contents_changed)
|
||||||
fishing_spot.status_changed.connect(_on_fishing_status_changed)
|
fishing_spot.status_changed.connect(_on_fishing_status_changed)
|
||||||
fishing_spot.reel_progress_changed.connect(_on_reel_progress_changed)
|
fishing_spot.catch_display_changed.connect(_on_catch_display_changed)
|
||||||
_update_bluegill_count(inventory.get_count(&"bluegill"))
|
_update_bluegill_count(inventory.get_count(&"bluegill"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,9 +31,105 @@ func _on_fishing_status_changed(status: String) -> void:
|
||||||
_status_label.visible = not status.is_empty()
|
_status_label.visible = not status.is_empty()
|
||||||
|
|
||||||
|
|
||||||
func _on_reel_progress_changed(progress: float, visible: bool) -> void:
|
func _on_catch_display_changed(
|
||||||
_reel_progress.value = progress * 100.0
|
progress: float,
|
||||||
_reel_progress.visible = visible
|
chase_progress: float,
|
||||||
|
barrier_positions: PackedFloat32Array,
|
||||||
|
barrier_health: PackedInt32Array,
|
||||||
|
barrier_max_health: PackedInt32Array,
|
||||||
|
active_barrier_index: int,
|
||||||
|
visible: bool,
|
||||||
|
) -> void:
|
||||||
|
_green_catch_progress.value = progress * 100.0
|
||||||
|
_red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0
|
||||||
|
_catch_track.visible = visible
|
||||||
|
_barrier_summary.visible = visible
|
||||||
|
if not visible:
|
||||||
|
_barrier_health.visible = false
|
||||||
|
_clear_barrier_markers()
|
||||||
|
_barrier_summary.text = ""
|
||||||
|
_barrier_health.text = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_barrier_markers(
|
||||||
|
barrier_positions,
|
||||||
|
barrier_health,
|
||||||
|
active_barrier_index
|
||||||
|
)
|
||||||
|
var barrier_labels: PackedStringArray = []
|
||||||
|
for barrier_index: int in range(barrier_positions.size()):
|
||||||
|
var defeated: bool = (
|
||||||
|
barrier_index < barrier_health.size()
|
||||||
|
and barrier_health[barrier_index] <= 0
|
||||||
|
)
|
||||||
|
var marker: String = "✓" if defeated else "●"
|
||||||
|
if barrier_index == active_barrier_index:
|
||||||
|
marker = "[%s]" % marker
|
||||||
|
barrier_labels.append(
|
||||||
|
"%d%% %s"
|
||||||
|
% [roundi(barrier_positions[barrier_index] * 100.0), marker]
|
||||||
|
)
|
||||||
|
_barrier_summary.text = "Barriers: %s" % " ".join(barrier_labels)
|
||||||
|
|
||||||
|
if (
|
||||||
|
active_barrier_index >= 0
|
||||||
|
and active_barrier_index < barrier_health.size()
|
||||||
|
and active_barrier_index < barrier_max_health.size()
|
||||||
|
):
|
||||||
|
_barrier_health.text = (
|
||||||
|
"Barrier: %d / %d"
|
||||||
|
% [
|
||||||
|
barrier_health[active_barrier_index],
|
||||||
|
barrier_max_health[active_barrier_index],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_barrier_health.text = ""
|
||||||
|
|
||||||
|
var chase_gap: float = progress - chase_progress
|
||||||
|
if chase_gap <= 0.05:
|
||||||
|
_barrier_health.text = (
|
||||||
|
"%s%s"
|
||||||
|
% [
|
||||||
|
_barrier_health.text,
|
||||||
|
"\nRed is closing in!",
|
||||||
|
]
|
||||||
|
).strip_edges()
|
||||||
|
_barrier_health.visible = (
|
||||||
|
visible
|
||||||
|
and (active_barrier_index >= 0 or chase_gap <= 0.05)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_barrier_markers(
|
||||||
|
positions: PackedFloat32Array,
|
||||||
|
health: PackedInt32Array,
|
||||||
|
active_index: int,
|
||||||
|
) -> void:
|
||||||
|
_clear_barrier_markers()
|
||||||
|
for barrier_index: int in range(positions.size()):
|
||||||
|
var marker := ColorRect.new()
|
||||||
|
var defeated: bool = (
|
||||||
|
barrier_index < health.size()
|
||||||
|
and health[barrier_index] <= 0
|
||||||
|
)
|
||||||
|
marker.color = Color(0.35, 0.38, 0.42, 0.8) if defeated else Color(1.0, 0.82, 0.2, 1.0)
|
||||||
|
if barrier_index == active_index:
|
||||||
|
marker.color = Color(1.0, 1.0, 1.0, 1.0)
|
||||||
|
marker.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
marker.anchor_left = positions[barrier_index]
|
||||||
|
marker.anchor_right = positions[barrier_index]
|
||||||
|
marker.offset_left = -2.0
|
||||||
|
marker.offset_right = 2.0
|
||||||
|
marker.offset_top = 0.0
|
||||||
|
marker.offset_bottom = 18.0
|
||||||
|
marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_barrier_markers.add_child(marker)
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_barrier_markers() -> void:
|
||||||
|
for marker: Node in _barrier_markers.get_children():
|
||||||
|
marker.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _update_bluegill_count(count: int) -> void:
|
func _update_bluegill_count(count: int) -> void:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=6 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
|
[ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"]
|
||||||
|
bg_color = Color(0.08, 0.08, 0.1, 0.9)
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBox_chase_fill"]
|
||||||
|
bg_color = Color(0.85, 0.12, 0.12, 0.95)
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBox_catch_fill"]
|
||||||
|
bg_color = Color(0.16, 0.82, 0.28, 0.9)
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBox_transparent"]
|
||||||
|
bg_color = Color(0, 0, 0, 0)
|
||||||
|
|
||||||
[node name="GameUI" type="CanvasLayer"]
|
[node name="GameUI" type="CanvasLayer"]
|
||||||
script = ExtResource("1_ui")
|
script = ExtResource("1_ui")
|
||||||
|
|
||||||
|
|
@ -49,9 +61,55 @@ visible = false
|
||||||
text = ""
|
text = ""
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="ReelProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer"]
|
[node name="CatchTrack" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
visible = false
|
visible = false
|
||||||
custom_minimum_size = Vector2(0, 18)
|
custom_minimum_size = Vector2(0, 18)
|
||||||
|
|
||||||
|
[node name="GreenCatchProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
theme_override_styles/background = SubResource("StyleBox_chase_background")
|
||||||
|
theme_override_styles/fill = SubResource("StyleBox_catch_fill")
|
||||||
value = 0.0
|
value = 0.0
|
||||||
show_percentage = false
|
show_percentage = false
|
||||||
|
|
||||||
|
[node name="RedChaseProgress" type="ProgressBar" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
theme_override_styles/background = SubResource("StyleBox_transparent")
|
||||||
|
theme_override_styles/fill = SubResource("StyleBox_chase_fill")
|
||||||
|
value = 0.0
|
||||||
|
show_percentage = false
|
||||||
|
|
||||||
|
[node name="BarrierMarkers" type="Control" parent="FishingPanel/MarginContainer/VBoxContainer/CatchTrack"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="BarrierSummary" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="BarrierHealth" type="Label" parent="FishingPanel/MarginContainer/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
visible = false
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue