Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6c8427d
[FEATURE] Implement Parallax Scrolling Background linked to Player Sp…
ikostan Apr 17, 2026
1246521
Update main_scene.gd
ikostan Apr 17, 2026
b137b63
Update scripts/main_scene.gd
ikostan Apr 17, 2026
e436db6
Update main_scene.gd
ikostan Apr 17, 2026
e3a9b70
Update main_scene.gd
ikostan Apr 17, 2026
7040237
Here is a comprehensive GUT unit test suite for the new parallax_mana…
ikostan Apr 17, 2026
d6b27ed
Update parallax_manager.gd
ikostan Apr 18, 2026
64b1bbc
completely eliminate the _process polling by using Dependency Inject…
ikostan Apr 18, 2026
1dbb09d
Missing coverage for the fuel-depletion reset, and tests implicitly d…
ikostan Apr 18, 2026
6af4227
Update test/gut/test_parallax_manager.gd
ikostan Apr 18, 2026
d5cc59a
Update main_scene.gd
ikostan Apr 18, 2026
39bd963
Update parallax_manager.gd
ikostan Apr 18, 2026
ee92636
Update main_scene.gd
ikostan Apr 18, 2026
43ed4c2
Update main_scene.gd
ikostan Apr 18, 2026
f6eeb1f
Update main_scene.gd
ikostan Apr 18, 2026
19467a2
scroll_offset.y grows unboundedly — consider wrapping to preserve flo…
ikostan Apr 19, 2026
bc0702e
Update main_scene.gd
ikostan Apr 19, 2026
b101d71
Update parallax_manager.gd
ikostan Apr 19, 2026
9abb76a
126-148: Nit: test name/assertion message is slightly misleading.
ikostan Apr 19, 2026
2c99f7c
63-67: LGTM — refuel recovery path is correctly wired.
ikostan Apr 19, 2026
27a0903
Update main_scene.gd
ikostan Apr 19, 2026
0117f9f
Update parallax_manager.gd
ikostan Apr 19, 2026
87efef1
Update main_scene.gd
ikostan Apr 19, 2026
189ec21
Update main_scene.gd
ikostan Apr 19, 2026
14084a0
This fully resolves the reviewer's concern by creating a proper, publ…
ikostan Apr 19, 2026
b7d5b25
let the ParallaxManager inspect its own children and calculate the ma…
ikostan Apr 19, 2026
ba0fb22
player's speed is now rigorously typed
ikostan Apr 19, 2026
cafaa32
Unit test updates
ikostan Apr 19, 2026
a8a50fe
Update test/gut/test_parallax_manager.gd
ikostan Apr 19, 2026
539986c
Update GUT unit tests
ikostan Apr 19, 2026
e1c8628
Merge branch 'implement-parallax-scrolling-background-linked-to-playe…
ikostan Apr 19, 2026
de072dc
auto_calculate_wrap_period() silently disables wrap when no layers ar…
ikostan Apr 19, 2026
d08fb80
Update parallax_manager.gd
ikostan Apr 19, 2026
cac27df
Optional: max-of-periods is only safe when layer periods are commens…
ikostan Apr 19, 2026
5fdc662
Update scripts/main_scene.gd
ikostan Apr 19, 2026
4c3c8ec
Update main_scene.gd
ikostan Apr 19, 2026
e6cfa37
Update parallax_manager.gd
ikostan Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion scenes/main_scene.tscn
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[gd_scene load_steps=82 format=3 uid="uid://nnnc0qhx07i8"]
[gd_scene load_steps=83 format=3 uid="uid://nnnc0qhx07i8"]

[ext_resource type="Script" uid="uid://ctm7qg12s2swt" path="res://scripts/main_scene.gd" id="1_7ykc4"]
[ext_resource type="PackedScene" uid="uid://cb4n4cqkuddqg" path="res://scenes/pause_menu.tscn" id="1_w2twt"]
Expand Down Expand Up @@ -77,6 +77,7 @@
[ext_resource type="Texture2D" uid="uid://bvxu5x1awjrjv" path="res://files/random_decor/dirt_001.png" id="69_7tyuc"]
[ext_resource type="Texture2D" uid="uid://f4hxu68qa4fi" path="res://files/random_decor/dirt_002.png" id="70_isor2"]
[ext_resource type="Script" uid="uid://blu5qujicfa7e" path="res://scripts/hud.gd" id="72_sgkfd"]
[ext_resource type="Script" uid="uid://b5x2ehdthhrla" path="res://scripts/parallax_manager.gd" id="76_qj6t7"]

[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pu3yx"]
bg_color = Color(0.2627451, 0.2627451, 0.2627451, 0.5882353)
Expand Down Expand Up @@ -204,6 +205,7 @@ step = 1.0
[node name="PauseMenu" parent="." instance=ExtResource("1_w2twt")]

[node name="Background" type="ParallaxBackground" parent="."]
script = ExtResource("76_qj6t7")

[node name="Sand" type="ParallaxLayer" parent="Background"]

Expand Down
73 changes: 55 additions & 18 deletions scripts/main_scene.gd
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Copyright (C) 2025 Egor Kostan
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## main_scene.gd
## Main scene script for SkyLockAssault.
Expand Down Expand Up @@ -52,6 +52,59 @@ func _ready() -> void:
# Setup decor layer with random instances
setup_decor_layer(viewport_size)

# =========================================================
# DEPENDENCY INJECTION: Parallax Background
# =========================================================
# Safely extract settings once to use for both injection and priming.
# The is_instance_valid(Globals) guard prevents hard crashes during
# isolated GUT tests where Autoloads may not be fully initialized.
var settings_res: GameSettingsResource = (
Globals.settings if is_instance_valid(Globals) else null
)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

if background.has_method("setup"):
background.setup(settings_res)
else:
push_warning(
"Parallax background is missing the `setup` method. Settings injection failed."
)

# Wire up the signal architecture for the parallax background
if player.has_signal("speed_changed") and background.has_method("update_speed"):
# 1. Guard against duplicate connections
if not player.speed_changed.is_connected(background.update_speed):
player.speed_changed.connect(background.update_speed)

# 2. Prime the background securely via a public method
if background.has_method("prime_speed"):
background.prime_speed(player.current_speed)
else:
push_warning("Parallax background is missing the `prime_speed` method.")

elif not player.has_signal("speed_changed"):
push_warning(
(
"Parallax background not wired: player is missing the `speed_changed` signal. "
+ "Verify that the Player node defines and emits `speed_changed`."
)
)
elif not background.has_method("update_speed"):
push_warning(
(
"Parallax background not wired: background is missing"
+ " `update_speed` method. "
+ "Ensure the background script implements "
+ " `update_speed(speed: float, max_speed: float)`."
)
)
# =========================================================
# FLOAT DEGRADATION SAFEGUARD
# =========================================================
# Delegate wrap period calculation entirely to the ParallaxManager.
# This preserves encapsulation so main_scene doesn't need to know layer specifics.
if background.has_method("auto_calculate_wrap_period"):
background.auto_calculate_wrap_period()


# 2. Detect when player presses a key/button that has NO binding at all
# Only significant inputs (axes above deadzone) are checked to prevent
Expand Down Expand Up @@ -217,23 +270,7 @@ func setup_decor_layer(viewport: Vector2) -> void:
decor_layer.motion_mirroring = Vector2(0, layer_height)


func _process(delta: float) -> void:
# NEW: Safely grab the settings resource and guard against null crashes
# during scene transitions, engine shutdown, or isolated GUT tests.
var settings_res: GameSettingsResource = (
Globals.settings if is_instance_valid(Globals) else null
)
if not is_instance_valid(settings_res):
return

# Use the safe local reference for difficulty
var scroll_speed: float = player.speed["speed"] * delta * settings_res.difficulty * 0.8
background.scroll_offset.y += scroll_speed

# Use the safe local reference for current_fuel
if settings_res.current_fuel <= 0:
background.scroll_offset = Vector2(0, 0)

func _process(_delta: float) -> void:
# 1. Critical unbound controls warning (shown ONCE per session)
# Flag stays true until player fixes bindings (e.g., in key_mapping.gd after remap).
# Do NOT reset here — that would make it repeat every 4s (bug fixed).
Expand Down
144 changes: 144 additions & 0 deletions scripts/parallax_manager.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
## Copyright (C) 2026 Egor Kostan
## SPDX-License-Identifier: GPL-3.0-or-later
## parallax_manager.gd
## Manages the scrolling speed of the parallax background based on player velocity.
## Decoupled via Dependency Injection and the Observer Pattern.

class_name ParallaxManager
extends ParallaxBackground

## Base multiplier applied to the final scroll math to scale the speed visually.
const SCROLL_MULTIPLIER: float = 0.8

## Optional wrap limit to prevent float32 precision degradation over long sessions.
## Should be a common multiple of the layers' (motion_mirroring.y / motion_scale.y).
@export var wrap_period: float = 0.0

var _current_speed: float = 0.0
var _difficulty: float = 1.0
var _out_of_fuel: bool = false


## Injects the game settings resource and wires up observer signals.
## Prevents tight coupling to global singletons in the process loop.
## @param settings: GameSettingsResource - The configuration resource.
## @return: void
func setup(settings: GameSettingsResource) -> void:
if not is_instance_valid(settings):
return

_difficulty = settings.difficulty
_out_of_fuel = (settings.current_fuel <= 0.0)

if not settings.setting_changed.is_connected(_on_setting_changed):
settings.setting_changed.connect(_on_setting_changed)
if not settings.fuel_depleted.is_connected(_on_fuel_depleted):
settings.fuel_depleted.connect(_on_fuel_depleted)


## Public method to prime the background's initial speed.
## Keeps private signal handlers properly encapsulated.
## @param initial_speed: float - The starting forward speed.
## @return: void
func prime_speed(initial_speed: float) -> void:
_current_speed = initial_speed


## Helper to find the Greatest Common Divisor for LCM calculations.
func _gcd(a: int, b: int) -> int:
while b != 0:
var temp: int = b
b = a % b
a = temp
return a


## Helper to find the Least Common Multiple to sync disparate layer periods.
func _lcm(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return absi((a * b) / _gcd(a, b))


## Public method to dynamically calculate the optimal wrap limit
## based on the properties of its ParallaxLayer children.
## Uses the Least Common Multiple (LCM) to ensure non-commensurate layers don't jump.
## Must be called after all layers have had their mirroring and scale set.
## @return: void
func auto_calculate_wrap_period() -> void:
var computed_lcm: int = 1
var periods: Array[int] = []

# 1. Collect all valid layer periods as integers (pixels)
for child in get_children():
if child is ParallaxLayer:
var layer_scale: float = child.motion_scale.y
var layer_mirror: float = child.motion_mirroring.y

if layer_scale > 0.0 and layer_mirror > 0.0:
var period: float = layer_mirror / layer_scale
periods.append(roundi(period))

# 2. Calculate the universal LCM of all collected periods
if periods.size() > 0:
computed_lcm = periods[0]
for i in range(1, periods.size()):
computed_lcm = _lcm(computed_lcm, periods[i])

# 3. Only overwrite the exported wrap_period if we successfully calculated a valid LCM.
# This protects values set manually via the Godot Inspector.
if computed_lcm > 1:
wrap_period = float(computed_lcm)

# 4. Warn the developer if the background is scrolling forever with no safeguard
if wrap_period <= 0.0:
push_warning(
(
"ParallaxManager: No valid wrap limit calculated or set. "
+ "Float precision degradation may occur during long sessions."
)
)


## Public method to update the scrolling speed.
## Designed to be safely connected to external speed_changed signals.
## @param new_speed: float - The new forward speed.
## @param _max_speed: float - The maximum speed threshold (unused, defaults to 0.0).
## @return: void
func update_speed(new_speed: float, _max_speed: float = 0.0) -> void:
_current_speed = new_speed


## Observer callback for specific setting updates (difficulty and refueling).
## @param setting_name: String - The name of the changed setting.
## @param new_value: Variant - The updated value.
## @return: void
func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
if setting_name == "difficulty":
_difficulty = float(new_value)
elif setting_name == "current_fuel" and float(new_value) > 0.0:
_out_of_fuel = false


## Observer callback to instantly snap the background when fuel runs out.
## @return: void
func _on_fuel_depleted() -> void:
_out_of_fuel = true
scroll_offset = Vector2.ZERO


## Called every physics/rendering frame.
## Updates scroll offset based entirely on cached local variables
## and wraps to preserve float precision.
## @param delta: float - The elapsed time since the previous frame.
## @return: void
func _process(delta: float) -> void:
if _out_of_fuel:
scroll_offset = Vector2.ZERO
else:
var scroll_amount: float = _current_speed * delta * _difficulty * SCROLL_MULTIPLIER
scroll_offset.y += scroll_amount

# Prevent float precision degradation by wrapping modulo the period
if wrap_period > 0.0:
scroll_offset.y = wrapf(scroll_offset.y, 0.0, wrap_period)
1 change: 1 addition & 0 deletions scripts/parallax_manager.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://b5x2ehdthhrla
25 changes: 14 additions & 11 deletions scripts/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ var rotor_left_sfx: AudioStreamPlayer2D
var rotor_right_sfx: AudioStreamPlayer2D

# Local state container for physics
var speed: Dictionary = {"speed": 250.0}
# var speed: Dictionary = {"speed": 250.0}

## The player's current forward speed.
var current_speed: float = 250.0

# Cache the global settings to avoid singleton lookups in hot paths
var _settings: GameSettingsResource = null
Expand Down Expand Up @@ -136,28 +139,28 @@ func _set_speed(target_speed: float) -> void:
if not is_instance_valid(_settings):
return

var old_speed: float = speed["speed"]
var old_speed: float = current_speed

# Clamp current_speed based on fuel state
if _settings.current_fuel == 0:
speed["speed"] = clamp(target_speed, 0.0, _settings.max_speed)
current_speed = clamp(target_speed, 0.0, _settings.max_speed)
else:
speed["speed"] = clamp(target_speed, _settings.min_speed, _settings.max_speed)
current_speed = clamp(target_speed, _settings.min_speed, _settings.max_speed)

# Emit signals if speed actually changed
if old_speed != speed["speed"]:
speed_changed.emit(speed["speed"], _settings.max_speed)
if old_speed != current_speed:
speed_changed.emit(current_speed, _settings.max_speed)

# Check for maximum speed limit
if speed["speed"] >= _settings.max_speed:
if current_speed >= _settings.max_speed:
speed_maxed.emit()

# Check for low speed warning
var low_yellow_thresh: float = (
_settings.min_speed
+ (_settings.max_speed - _settings.min_speed) * _settings.low_yellow_fraction
)
if speed["speed"] <= low_yellow_thresh:
if current_speed <= low_yellow_thresh:
speed_low.emit(low_yellow_thresh)


Expand Down Expand Up @@ -222,7 +225,7 @@ func _on_fuel_timer_timeout() -> void:
if not is_instance_valid(_settings):
return

var normalized_speed: float = clamp(speed["speed"] / _settings.max_speed, 0.0, 1.0)
var normalized_speed: float = clamp(current_speed / _settings.max_speed, 0.0, 1.0)
var consumption: float = (
_settings.base_consumption_rate * normalized_speed * _settings.difficulty
)
Expand All @@ -238,7 +241,7 @@ func _physics_process(_delta: float) -> void:
if not is_instance_valid(_settings):
return

var target_speed: float = speed["speed"]
var target_speed: float = current_speed

# Speed changes allowed only if fuel > 0
if Input.is_action_pressed("speed_up") and _settings.current_fuel > 0:
Expand All @@ -253,7 +256,7 @@ func _physics_process(_delta: float) -> void:
# Left/Right movement
var lateral_input: float = Input.get_axis("move_left", "move_right")

if lateral_input and _settings.current_fuel > 0 and speed["speed"] > 0:
if lateral_input and _settings.current_fuel > 0 and current_speed > 0:
player.velocity.x = lateral_input * _settings.lateral_speed
else:
player.velocity.x = 0.0
Expand Down
6 changes: 3 additions & 3 deletions test/gdunit4/test_difficulty.gd
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func test_fuel_depletion_with_difficulty() -> void:
Globals.settings.difficulty = 1.0

# NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED
var normalized_speed: float = player_inst.speed["speed"] / Globals.settings.max_speed
var normalized_speed: float = player_inst.current_speed / Globals.settings.max_speed

# OLD: var dep_1: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty
# NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain
Expand All @@ -67,7 +67,7 @@ func test_fuel_depletion_with_difficulty() -> void:
Globals.settings.difficulty = 2.0

# NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED
normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed
normalized_speed = player_inst.current_speed / Globals.settings.max_speed

# OLD: var dep_2: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty
# NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain
Expand All @@ -85,7 +85,7 @@ func test_fuel_depletion_with_difficulty() -> void:
Globals.settings.difficulty = 0.5

# NEW: Use Globals.settings.max_speed instead of the removed player_inst.MAX_SPEED
normalized_speed = player_inst.speed["speed"] / Globals.settings.max_speed
normalized_speed = player_inst.current_speed / Globals.settings.max_speed

# OLD: var dep_05: float = player_inst.base_fuel_drain * normalized_speed * Globals.settings.difficulty
# NEW: Use the global base_consumption_rate instead of the removed local base_fuel_drain
Expand Down
2 changes: 1 addition & 1 deletion test/gdunit4/test_difficulty_integration.gd
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func test_difficulty_scales_fuel_and_weapon() -> void:
Globals.settings.current_fuel = start_fuel

# NEW: Calculate normalized speed using the global max_speed, as MAX_SPEED was removed from player.gd
var normalized_speed: float = player.speed["speed"] / Globals.settings.max_speed
var normalized_speed: float = player.current_speed / Globals.settings.max_speed

# OLD: var expected_depletion: float = player.base_fuel_drain * normalized_speed * Globals.settings.difficulty
# NEW: Reference base_consumption_rate from the global resource since it was removed from the player script
Expand Down
2 changes: 1 addition & 1 deletion test/gdunit4/test_helpers.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extends RefCounted
## Calculates the expected fuel depletion based on the global GameSettingsResource.
static func calculate_expected_depletion(player_root: Node, difficulty: float) -> float:
# NEW: Use Globals.settings.max_speed instead of player_root.MAX_SPEED
var normalized_speed: float = player_root.speed["speed"] / Globals.settings.max_speed
var normalized_speed: float = player_root.current_speed / Globals.settings.max_speed

# NEW: Use Globals.settings.base_consumption_rate instead of player_root.base_fuel_drain
return Globals.settings.base_consumption_rate * normalized_speed * difficulty
Loading
Loading