Skip to content

Implement Parallax Scrolling Background linked to Player Speed#574

Merged
ikostan merged 37 commits intomainfrom
implement-parallax-scrolling-background-linked-to-player-speed
Apr 19, 2026
Merged

Implement Parallax Scrolling Background linked to Player Speed#574
ikostan merged 37 commits intomainfrom
implement-parallax-scrolling-background-linked-to-player-speed

Conversation

@ikostan
Copy link
Copy Markdown
Owner

@ikostan ikostan commented Apr 17, 2026

This PR implements a comprehensive architectural overhaul of the player movement and parallax systems to improve maintainability, stability, and type safety. Key changes focus on decoupling systems through the Observer pattern and resolving long-term float-precision risks.


Summary of Changes

Architectural Improvements

  • Observer Pattern & Decoupling: Transitioned the ParallaxManager and UI systems away from polling global singletons. They now react dynamically to signals (e.g., speed_changed, fuel_depleted) emitted by the player.
  • Dependency Injection: Refined main_scene.gd to inject the GameSettingsResource into subordinates at initialization, reducing coupling to the Globals autoload and improving testability.
  • Encapsulation: Moved complex internal logic, such as parallax wrap period calculations, into the ParallaxManager, hiding implementation details from the MainScene.

Technical Safeguards

  • Float Precision Safeguard: Implemented a wrapf mechanism in the parallax loop to prevent "stuttering" during long-duration play sessions by resetting the scroll offset at a calculated period.
  • LCM Sync Math: Developed a Least Common Multiple (LCM) algorithm to synchronize disparate parallax layer periods, ensuring that layers with different motion_scale values wrap seamlessly without visual jumping.
  • Type Safety: Refactored the player's speed data structure from a loosely typed dictionary (speed["speed"]) to a strictly typed float (current_speed), eliminating silent runtime errors.

UI & Input

  • Unbound Controls Warning: Implemented a dynamic notification system that alerts players if critical actions are not bound to their current device (Keyboard vs. Gamepad).
  • Web Compatibility: Added JavaScript callbacks and user-gesture detection for background music to ensure a smooth experience on web/itch.io exports.

Testing & Quality Assurance

  • Test Suite Synchronization: Updated the entire GUT and GdUnit4 test suites to match the new typed properties and decoupled signal architecture.
  • Static Analysis Compliance: Resolved multiple "code smells" and "anti-patterns" identified by @deepsource-bot, @coderabbitai, and @sourcery-ai.

Contributors

  • @ikostan: Lead architectural design, core implementation, and test suite refactoring.
  • @coderabbitai: Detailed code review and architectural feedback.
  • @deepsource-bot: Automated static analysis and code quality monitoring.
  • @sourcery-ai: Automated logic refactoring and code simplification suggestions.

name: Default Pull Request Template
about: Suggesting changes to SkyLockAssault
title: ''
labels: ''
assignees: ''

Description

What does this PR do? (e.g., "Fixes player jump physics in level 2" or "Adds
new enemy AI script")

Related Issue

Closes #ISSUE_NUMBER (if applicable)

Changes

  • List key changes here (e.g., "Updated Jump.gd to use Godot 4.4's new Tween
    system")
  • Any breaking changes? (e.g., "Deprecated old signal; migrate to new one")

Testing

  • Ran the game in Godot v4.5 editor—describe what you tested (e.g., "Jump
    works on Win10 with 60 FPS")
  • Any new unit tests added? (Link to test scene if yes)
  • Screenshots/GIFs if UI-related: (Attach below)

Checklist

  • Code follows Godot style guide (e.g., snake_case for variables)
  • No console errors in editor/output
  • Ready for review!

Additional Notes

Anything else? (e.g., "Tested on Win10 64-bit; needs Linux validation")


@ikostan Contributions Summary

  • Architectural Refactoring: Spearheaded the transition from a tightly coupled, singleton-dependent system to a decoupled, signal-based Observer Pattern.
  • Precision Engineering: Implemented a sophisticated Least Common Multiple (LCM) calculation for the Parallax Manager to synchronize non-commensurate layer periods and prevent visual "jumps".
  • Type-Safety Migration: Eliminated stringly-typed dictionary access for player speed, replacing it with a strongly-typed float property (current_speed) to ensure compile-time safety across the game engine.
  • Stability & Safeguards: Developed a Float Degradation Safeguard using the wrapf method, protecting the game's visual integrity during extended play sessions.
  • UI/UX Logic: Integrated a dynamic Unbound Controls warning system that detects and alerts players to missing critical keybindings based on their active device (Keyboard vs. Gamepad).
  • Test Suite Maintenance: Successfully updated and maintained both GUT and GdUnit4 test suites, ensuring that all architectural refactors remained verified by automated unit and integration tests.
  • Dependency Injection: Refined the main_scene.gd initialization logic to use safe Dependency Injection, protecting the scene from crashes during isolated testing or scene transitions.

This Pull Request (PR) #574 has benefited from significant contributions and automated reviews from several AI-driven tools and bots. Below is a summary of their contributions and their correctly formatted GitHub handles for inclusion in the contributors list.

Bots & AI Contribution Summary

  • @coderabbitai: Acted as the primary AI reviewer for this PR. It provided deep architectural feedback, identified magic numbers, suggested the transition to the Observer pattern for the parallax system, and caught critical edge cases like float-precision degradation and signal redundancy. It also verified the fix for "unbound controls" and assisted in refactoring the speed data structure from a dictionary to a strongly-typed float.
  • @deepsource-bot: Performed automated static analysis and code quality reviews. It flagged potential "anti-patterns" and ensured the GDScript followed consistent style guidelines and best practices.
  • @sourcery-ai: Contributed automated refactoring suggestions to improve code readability and efficiency. It focused on simplifying complex logic and ensuring the new decoupled architecture remained clean and maintainable.

Contributor List (GitHub Format)

To ensure these are properly recognized in your GitHub contributors list or README.md:


Summary by Sourcery

Introduce a dedicated ParallaxManager to drive background scrolling from the player’s speed signal, decoupling parallax logic from the main scene and player while preserving fuel-based behavior and precision safeguards.

New Features:

  • Add a ParallaxManager node that controls parallax background scroll speed based on the player’s current forward velocity and game difficulty.
  • Automatically calculate an optimal wrap period for parallax layers to avoid float precision issues during long sessions.

Enhancements:

  • Refactor player speed handling to use a strongly-typed current_speed property instead of a dictionary, simplifying signal emissions and fuel consumption logic.
  • Wire the main scene to inject game settings into the parallax manager and connect player speed signals using a safer, dependency-injected architecture.

Tests:

  • Add comprehensive GUT tests for ParallaxManager covering signal integration, scroll math, flameout behavior, recovery, null global safety, and default-initialization behavior.
  • Update existing player, fuel, difficulty, and UI tests to use the new current_speed property and validate the updated fuel depletion calculations.

Summary by CodeRabbit

  • New Features
    • Added a dedicated parallax manager that accepts injected settings, can be primed with an initial speed, and can auto-calculate optional vertical wrap to avoid float drift.
  • Refactor
    • Parallax behavior moved out of the main scene into the manager; player speed state simplified to a scalar property.
  • Bug Fixes
    • Background scrolling reliably stops and snaps to zero on fuel depletion.
  • Tests
    • New tests cover manager behavior, scroll math, flameout/recovery, and safety when settings are missing; player tests updated to use the new speed property.

…eed #543

Implement a dynamic ParallaxBackground system that automatically adjusts its scrolling speed based on the player's current forward velocity.
@ikostan ikostan self-assigned this Apr 17, 2026
@ikostan ikostan added enhancement New feature or request GUI labels Apr 17, 2026
@ikostan ikostan linked an issue Apr 17, 2026 that may be closed by this pull request
5 tasks
@ikostan ikostan moved this to In Progress in Sky Lock Assault Project Apr 17, 2026
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 17, 2026

Reviewer's Guide

Introduces a dedicated ParallaxManager to own parallax scroll math and wiring, refactors main_scene to inject settings and connect player speed signals, and simplifies player speed state from a Dictionary to a scalar property with updated tests across GUT and GDUnit4.

Sequence diagram for parallax background wiring and runtime updates

sequenceDiagram
    actor Player
    participant MainScene
    participant ParallaxManager
    participant GameSettingsResource as Settings
    participant Globals
    participant Engine as GodotEngine

    MainScene->>Globals: is_instance_valid(Globals)
    alt Globals valid
        MainScene->>Globals: settings
        Globals-->>MainScene: Settings
    else Globals invalid
        MainScene-->>MainScene: settings_res = null
    end

    MainScene->>ParallaxManager: setup(settings_res)
    alt settings_res valid
        ParallaxManager->>Settings: connect(setting_changed, _on_setting_changed)
        ParallaxManager->>Settings: connect(fuel_depleted, _on_fuel_depleted)
        Settings-->>ParallaxManager: emit setting_changed(difficulty, value)
        ParallaxManager-->>ParallaxManager: _difficulty updated
        Settings-->>ParallaxManager: emit setting_changed(current_fuel, value)
        ParallaxManager-->>ParallaxManager: _out_of_fuel updated
    else settings_res invalid
        ParallaxManager-->>ParallaxManager: return without wiring
    end

    MainScene->>Player: connect(speed_changed, ParallaxManager.update_speed)
    MainScene->>ParallaxManager: prime_speed(player.current_speed)
    MainScene->>ParallaxManager: auto_calculate_wrap_period()

    loop Every_frame
        Engine->>Player: _physics_process(delta)
        Player-->>Player: _set_speed(target_speed)
        Player-->>Player: current_speed updated
        Player-->>Player: emit speed_changed(current_speed, max_speed)
        Player->>ParallaxManager: speed_changed(current_speed, max_speed)
        ParallaxManager-->>ParallaxManager: update_speed(new_speed)

        Engine->>ParallaxManager: _process(delta)
        alt _out_of_fuel true
            ParallaxManager-->>ParallaxManager: scroll_offset = Vector2.ZERO
        else _out_of_fuel false
            ParallaxManager-->>ParallaxManager: scroll_offset.y += current_speed * delta * _difficulty * SCROLL_MULTIPLIER
            alt wrap_period > 0
                ParallaxManager-->>ParallaxManager: scroll_offset.y = wrapf(scroll_offset.y, 0, wrap_period)
            end
        end
    end

    Settings-->>ParallaxManager: emit fuel_depleted()
    ParallaxManager-->>ParallaxManager: _on_fuel_depleted() -> _out_of_fuel = true, scroll_offset = Vector2.ZERO
Loading

Class diagram for Player, ParallaxManager, and GameSettingsResource

classDiagram
    class Player {
        +float current_speed
        -GameSettingsResource _settings
        +signal speed_changed(new_speed: float, max_speed: float)
        +signal speed_maxed()
        +signal speed_low(low_threshold: float)
        +func _set_speed(target_speed: float) void
        +func _physics_process(delta: float) void
        +func _on_fuel_timer_timeout() void
    }

    class ParallaxManager {
        <<ParallaxBackground>>
        +const float SCROLL_MULTIPLIER
        +float wrap_period
        -float _current_speed
        -float _difficulty
        -bool _out_of_fuel
        +func setup(settings: GameSettingsResource) void
        +func prime_speed(initial_speed: float) void
        -func _gcd(a: int, b: int) int
        -func _lcm(a: int, b: int) int
        +func auto_calculate_wrap_period() void
        +func update_speed(new_speed: float, max_speed: float) void
        -func _on_setting_changed(setting_name: String, new_value: Variant) void
        -func _on_fuel_depleted() void
        +func _process(delta: float) void
    }

    class GameSettingsResource {
        +float difficulty
        +float current_fuel
        +float min_speed
        +float max_speed
        +float low_yellow_fraction
        +float base_consumption_rate
        +float lateral_speed
        +signal setting_changed(setting_name: String, new_value: Variant)
        +signal fuel_depleted()
    }

    Player --> GameSettingsResource : uses
    ParallaxManager --> GameSettingsResource : injected_via_setup
    Player ..> ParallaxManager : emits speed_changed
    GameSettingsResource ..> ParallaxManager : emits setting_changed
    GameSettingsResource ..> ParallaxManager : emits fuel_depleted
Loading

File-Level Changes

Change Details Files
Introduce ParallaxManager node that manages parallax scroll speed, wrapping, and observer integration with GameSettingsResource.
  • Create ParallaxManager script extending ParallaxBackground with injected GameSettingsResource via setup() and internal cached difficulty/out_of_fuel state.
  • Implement update_speed() and prime_speed() APIs to receive player speed via signals and initialize scroll speed.
  • Add scroll math in _process() using current speed, delta, difficulty, and a SCROLL_MULTIPLIER constant, with flameout snapping scroll_offset to Vector2.ZERO.
  • Implement optional float-safe wrapping using an exported wrap_period, an auto_calculate_wrap_period() helper, and internal GCD/LCM utilities to derive a common wrap length from child ParallaxLayer properties.
  • Wire observer callbacks _on_setting_changed() and _on_fuel_depleted() to GameSettingsResource signals to react to difficulty and fuel changes without hitting Globals in hot paths.
scripts/parallax_manager.gd
scripts/parallax_manager.gd.uid
Refactor main_scene to delegate parallax behavior to ParallaxManager via dependency injection and signals, removing inline scroll logic.
  • In _ready(), retrieve Globals.settings defensively with is_instance_valid() and pass it into background.setup().
  • Connect player.speed_changed to background.update_speed() with duplicate-connection guarding, and prime parallax speed via background.prime_speed(player.current_speed) when available.
  • Call background.auto_calculate_wrap_period() to let the manager compute its own safe wrap period.
  • Replace the old _process() implementation that directly mutated background.scroll_offset based on player.speed and fuel with a stub that only handles input warnings, leaving scrolling to ParallaxManager.
  • Add push_warning fallbacks when expected setup/update methods or player signals are missing so misconfigured scenes fail loudly in the editor.
scripts/main_scene.gd
scenes/main_scene.tscn
Simplify player forward speed from a Dictionary to a float property and ensure speed-dependent systems and tests use the new API.
  • Replace the speed Dictionary with current_speed: float on the player, updating _set_speed(), _physics_process(), lateral movement checks, and fuel consumption to reference current_speed.
  • Keep speed_changed, speed_maxed, and speed_low signals but emit them based on current_speed changes; preserve clamping logic and thresholds using GameSettingsResource.
  • Update all GUT and GDUnit4 tests to set and assert against current_speed instead of speed["speed"], including depletion helpers, difficulty scaling, HUD integration, and fuel edge cases.
scripts/player.gd
test/gdunit4/test_player.gd
test/gdunit4/test_difficulty.gd
test/gdunit4/test_difficulty_integration.gd
test/gdunit4/test_helpers.gd
test/gut/test_player_movement_signals.gd
test/gut/test_player_fuel_logic.gd
test/gut/test_fuel_additional_edge_cases.gd
Add comprehensive GUT tests for ParallaxManager behavior, including scroll math, flameout handling, safety, and default-path behavior.
  • Instantiate ParallaxManager with injected fresh GameSettingsResource in before_each(), caching/restoring Globals.settings to avoid test leakage.
  • Verify update_speed() updates internal _current_speed and that _process() scrolls using expected math under different difficulty and speed values.
  • Test flameout behavior by driving current_fuel <= 0, ensuring scroll_offset resets and stays locked, and that refueling via _on_setting_changed() resumes scrolling.
  • Ensure _process() behaves correctly when Globals.settings becomes null after setup (using cached difficulty) and when setup() was never called (using default difficulty).
test/gut/test_parallax_manager.gd
test/gut/test_parallax_manager.gd.uid
Minor cleanups and comments to align with the new architecture.
  • Bump main_scene and new files to 2026 copyright headers.
  • Clarify and expand test comments around speed_changed signal semantics, observer pattern usage, and fuel depletion helpers.
  • Remove obsolete comments referencing the old speed Dictionary and MAX_SPEED/base_fuel_drain fields now replaced by GameSettingsResource.
scripts/main_scene.gd
test/gdunit4/test_player.gd
test/gdunit4/test_difficulty.gd
test/gdunit4/test_difficulty_integration.gd
test/gdunit4/test_helpers.gd
test/gut/test_player_movement_signals.gd
test/gut/test_player_fuel_logic.gd
test/gut/test_fuel_additional_edge_cases.gd
test/gut/test_fuel_additional_edge_cases.gd
test/gut/test_parallax_manager.gd

Assessment against linked issues

Issue Objective Addressed Explanation
#282 Create a dedicated ParallaxManager script that extends ParallaxBackground, caches player speed via an observer method, and updates scroll_offset.y each frame based on the cached speed (respecting difficulty and fuel state).
#282 In main_scene.gd, dynamically connect the Player's speed_changed signal to the ParallaxManager, prime its initial speed, and inject GameSettingsResource so the background no longer directly polls physics/fuel in main_scene.gd.
#543 Implement a ParallaxBackground system with a dedicated ParallaxManager script that controls background scrolling based on player forward speed.
#543 Integrate the ParallaxManager into the main level so that the Player's speed_changed(new_speed, max_speed) signal is connected to it, with initial speed primed and previous inline scrolling logic removed.
#543 Update the parallax background scroll_offset dynamically each frame in a process loop based on the broadcasted player speed (and relevant game settings like difficulty and fuel), keeping behavior robust over time.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

Parallax scrolling logic was moved into a new ParallaxManager (a ParallaxBackground subclass). main_scene.gd now wires Globals.settings and the player's speed_changed signal to the manager and no longer performs per-frame parallax math. Tests were added for the manager.

Changes

Cohort / File(s) Summary
Scene Configuration
scenes/main_scene.tscn
Added ext_resource for res://scripts/parallax_manager.gd and attached it to the Background node; incremented load_steps.
Main Scene Script
scripts/main_scene.gd
_ready() injects Globals.settings into background via background.setup(...), connects player.speed_changed to background handlers (guarding duplicates), primes background speed, and delegates wrap-period handling; _process no longer performs parallax scrolling.
Parallax Manager
scripts/parallax_manager.gd, scripts/parallax_manager.gd.uid
New class_name ParallaxManager extending ParallaxBackground; exports wrap_period; caches _current_speed, _difficulty, _out_of_fuel; provides setup(), prime_speed(), auto_calculate_wrap_period(), update_speed(); updates scroll_offset.y in _process() with wrapping and fuel-out behavior; connects to settings signals. Added UID file.
Player
scripts/player.gd
Replaced Dictionary-based speed with scalar current_speed; updated _set_speed, emissions, and fuel-consumption math to use current_speed; updated physics logic to gate lateral movement on current_speed.
Tests
test/gut/test_parallax_manager.gd, test/gut/test_parallax_manager.gd.uid, test/gut/test_player_fuel_logic.gd
Added GUT suite for ParallaxManager covering setup, update/prime speed, _process scroll math, wrap/zeroing on fuel depletion and recovery, and null-Globals fallback. Updated player fuel test to use current_speed. Added test UID file.

Sequence Diagram(s)

sequenceDiagram
  participant Main as MainScene
  participant Player as Player
  participant Parallax as ParallaxManager
  participant Globals as Globals.settings

  Main->>Parallax: attach script to Background (scene load)
  Main->>Parallax: setup(Globals.settings)
  Main->>Player: connect player.speed_changed -> Parallax.update_speed
  Player-->>Parallax: speed_changed(new_speed, max_speed)
  Parallax->>Parallax: update _current_speed (prime/update)
  loop every frame
    Parallax->>Globals: read difficulty, current_fuel
    alt current_fuel <= 0
      Parallax-->>Parallax: set scroll_offset = Vector2.ZERO
    else
      Parallax-->>Parallax: scroll_offset.y += _current_speed * delta * difficulty * SCROLL_MULTIPLIER
      alt wrap_period > 0
        Parallax-->>Parallax: scroll_offset.y = wrapf(scroll_offset.y, 0, wrap_period)
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

testing

Poem

🐰 I hopped from main into a manager bright,
I cached the speed and tuned the scrolling flight,
When fuel runs out I stop the sky's slow race,
I prime, I wrap, I watch the stars keep pace,
Hop! Background tucked neatly in place.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main feature: implementing a parallax scrolling background linked to player speed. It accurately reflects the primary objective of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed PR description is comprehensive and well-structured, clearly outlining architectural improvements, technical safeguards, testing updates, and multiple contributors.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch implement-parallax-scrolling-background-linked-to-player-speed

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@deepsource-io
Copy link
Copy Markdown

deepsource-io Bot commented Apr 17, 2026

DeepSource Code Review

We reviewed changes in 82b0a4b...e6cfa37 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Apr 19, 2026 2:42a.m. Review ↗
JavaScript Apr 19, 2026 2:42a.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The new ParallaxManager drops the previous current_fuel <= 0 handling that reset scroll_offset, so if that behavior is still desired you should migrate that logic into the manager instead of just commenting it out in main_scene.gd.
  • ParallaxManager pulls difficulty from the global Globals.settings each frame, which introduces tight coupling and repeated lookups; consider injecting difficulty (or settings) via a property or signal from the main scene instead.
  • In _ready you call background._on_player_speed_changed(player.speed["speed"], 0.0) to prime the parallax, but the second argument is always 0.0 and unused—either remove the unused parameter from the signal/handler or pass the actual max speed if it is intended to be meaningful.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new ParallaxManager drops the previous `current_fuel <= 0` handling that reset `scroll_offset`, so if that behavior is still desired you should migrate that logic into the manager instead of just commenting it out in `main_scene.gd`.
- ParallaxManager pulls difficulty from the global `Globals.settings` each frame, which introduces tight coupling and repeated lookups; consider injecting difficulty (or settings) via a property or signal from the main scene instead.
- In `_ready` you call `background._on_player_speed_changed(player.speed["speed"], 0.0)` to prime the parallax, but the second argument is always `0.0` and unused—either remove the unused parameter from the signal/handler or pass the actual max speed if it is intended to be meaningful.

## Individual Comments

### Comment 1
<location path="scripts/main_scene.gd" line_range="55-59" />
<code_context>
 	# Setup decor layer with random instances
 	setup_decor_layer(viewport_size)

+	# Wire up the signal architecture for the parallax background
+	if player.has_signal("speed_changed") and background.has_method("_on_player_speed_changed"):
+		player.speed_changed.connect(background._on_player_speed_changed)
+		# Prime the background with the player's initial starting speed
+		background._on_player_speed_changed(player.speed["speed"], 0.0)
+

</code_context>
<issue_to_address>
**suggestion:** Consider handling the case where the signal or method is missing more explicitly.

If `player` lacks `speed_changed` or `background` lacks `_on_player_speed_changed`, the wiring is skipped and scrolling never happens, with no visible signal of the problem. Consider adding a clear fallback (e.g., reuse the previous `_process`-based scrolling) or at least a `push_warning` so misconfigurations are easier to detect and debug.

```suggestion
	# Wire up the signal architecture for the parallax background
	if player.has_signal("speed_changed") and background.has_method("_on_player_speed_changed"):
		player.speed_changed.connect(background._on_player_speed_changed)
		# Prime the background with the player's initial starting speed
		background._on_player_speed_changed(player.speed["speed"], 0.0)
	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("_on_player_speed_changed"):
		push_warning("Parallax background not wired: background is missing `_on_player_speed_changed` method. "
			+ "Ensure the background script implements `_on_player_speed_changed(speed: float, delta: float)`.")
```
</issue_to_address>

### Comment 2
<location path="scripts/main_scene.gd" line_range="236-241" />
<code_context>
 	# 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
+	# 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)
+	# if settings_res.current_fuel <= 0:
+	#	background.scroll_offset = Vector2(0, 0)

 	# 1. Critical unbound controls warning (shown ONCE per session)
</code_context>
<issue_to_address>
**question (bug_risk):** Previous fuel-depletion behavior for the background is now disabled; ensure this gameplay change is intentional.

This used to reset `background.scroll_offset` to `(0, 0)` when `settings_res.current_fuel <= 0`, giving a clear visual end-of-run cue. With the scroll now handled by `ParallaxManager` and this check commented out, the background will keep scrolling from its last offset. If that cue is still desired, consider adding an equivalent fuel check in `ParallaxManager` (or signalling it when fuel hits zero) to restore the previous behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/main_scene.gd Outdated
Comment thread scripts/main_scene.gd Outdated
ikostan and others added 2 commits April 16, 2026 22:00
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
scripts/main_scene.gd (1)

55-60: Guard against duplicate signal connections and avoid invoking a private-convention method from outside.

Two minor concerns:

  1. player.speed_changed.connect(background._on_player_speed_changed) has no is_connected guard. If _ready() is ever executed again (scene reload, tests), you'll get a "Signal already connected" error. Consider using CONNECT_REFERENCE_COUNTED or guarding with is_connected.
  2. Methods prefixed with _ are a GDScript convention for "private/virtual". Calling background._on_player_speed_changed(...) from another script leaks that convention. Consider exposing a public prime(initial_speed: float) (or similar) method on ParallaxManager that sets _current_speed internally — the signal handler stays private.
Proposed fix
-	if player.has_signal("speed_changed") and background.has_method("_on_player_speed_changed"):
-		player.speed_changed.connect(background._on_player_speed_changed)
-		# Prime the background with the player's initial starting speed
-		background._on_player_speed_changed(player.speed["speed"], 0.0)
+	if player.has_signal("speed_changed") and background.has_method("_on_player_speed_changed"):
+		if not player.speed_changed.is_connected(background._on_player_speed_changed):
+			player.speed_changed.connect(background._on_player_speed_changed)
+		# Prime the background with the player's initial starting speed
+		if background.has_method("prime"):
+			background.prime(player.speed["speed"])
+		else:
+			background._on_player_speed_changed(player.speed["speed"], 0.0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/main_scene.gd` around lines 55 - 60, Guard the signal connection to
avoid duplicate connects and stop calling a private method directly: before
calling player.speed_changed.connect(background._on_player_speed_changed) check
if player.speed_changed.is_connected(background, "_on_player_speed_changed") (or
connect with CONNECT_REFERENCE_COUNTED) to prevent "Signal already connected"
errors; and instead of invoking background._on_player_speed_changed(...) from
this script, add a public method on the ParallaxManager like
prime(initial_speed: float) that sets the internal _current_speed and call
background.prime(player.speed["speed"]) so the private handler
_on_player_speed_changed remains internal.
scripts/parallax_manager.gd (2)

29-30: is_instance_valid(Globals) on an autoload singleton is always true.

Globals is a Godot autoload, so the engine guarantees it exists for the lifetime of the scene tree. The check adds no safety and just makes the intent less clear — simplifying to only guard Globals.settings (which can legitimately be null during transitions per the pattern in scripts/player.gd:50-57) would be cleaner.

Proposed refactor
-	if is_instance_valid(Globals) and is_instance_valid(Globals.settings):
-		difficulty = Globals.settings.difficulty
+	if is_instance_valid(Globals.settings):
+		difficulty = Globals.settings.difficulty
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/parallax_manager.gd` around lines 29 - 30, The check
`is_instance_valid(Globals)` is redundant for an autoload singleton; remove it
and only guard access to `Globals.settings` so `difficulty =
Globals.settings.difficulty` runs only when `Globals.settings` is non-null.
Update the conditional around the assignment (where `difficulty` is set) to `if
is_instance_valid(Globals.settings):` (or equivalent null check) to match the
pattern used around `Globals.settings` elsewhere (e.g., in the player code) and
keep intent clear.

32-33: scroll_offset.y grows unboundedly — consider wrapping to preserve float precision over long sessions.

scroll_offset.y += scroll_amount accumulates indefinitely. At ~250 px/s × 0.8 × difficulty, the value can reach millions after extended play, at which point float32 precision degrades visibly (jitter/stutter in the parallax scroll). Since the ParallaxLayer children use motion_mirroring to tile, the offset can safely be taken modulo the mirroring period.

-	var scroll_amount: float = _current_speed * delta * difficulty * 0.8
-	scroll_offset.y += scroll_amount
+	var scroll_amount: float = _current_speed * delta * difficulty * 0.8
+	scroll_offset.y = fposmod(scroll_offset.y + scroll_amount, 100000.0)

Pick a wrap value that's a common multiple of your layers' motion_mirroring.y periods so visible tiling isn't disrupted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/parallax_manager.gd` around lines 32 - 33, The scroll_offset.y
accumulation in the parallax update (using _current_speed, delta, difficulty ->
scroll_amount) must be wrapped to avoid float32 precision loss; replace the
unbounded increment of scroll_offset.y with a modulo/wrap using a chosen
wrap_period (a common multiple of your ParallaxLayer.motion_mirroring.y values)
and set scroll_offset.y = positive_mod(scroll_offset.y + scroll_amount,
wrap_period) (use GDScript fposmod or equivalent) so tiling stays correct and
the value stays small.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@scripts/main_scene.gd`:
- Around line 55-60: Guard the signal connection to avoid duplicate connects and
stop calling a private method directly: before calling
player.speed_changed.connect(background._on_player_speed_changed) check if
player.speed_changed.is_connected(background, "_on_player_speed_changed") (or
connect with CONNECT_REFERENCE_COUNTED) to prevent "Signal already connected"
errors; and instead of invoking background._on_player_speed_changed(...) from
this script, add a public method on the ParallaxManager like
prime(initial_speed: float) that sets the internal _current_speed and call
background.prime(player.speed["speed"]) so the private handler
_on_player_speed_changed remains internal.

In `@scripts/parallax_manager.gd`:
- Around line 29-30: The check `is_instance_valid(Globals)` is redundant for an
autoload singleton; remove it and only guard access to `Globals.settings` so
`difficulty = Globals.settings.difficulty` runs only when `Globals.settings` is
non-null. Update the conditional around the assignment (where `difficulty` is
set) to `if is_instance_valid(Globals.settings):` (or equivalent null check) to
match the pattern used around `Globals.settings` elsewhere (e.g., in the player
code) and keep intent clear.
- Around line 32-33: The scroll_offset.y accumulation in the parallax update
(using _current_speed, delta, difficulty -> scroll_amount) must be wrapped to
avoid float32 precision loss; replace the unbounded increment of scroll_offset.y
with a modulo/wrap using a chosen wrap_period (a common multiple of your
ParallaxLayer.motion_mirroring.y values) and set scroll_offset.y =
positive_mod(scroll_offset.y + scroll_amount, wrap_period) (use GDScript fposmod
or equivalent) so tiling stays correct and the value stays small.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 01b56c05-7c47-4a98-824f-3671cdd53b90

📥 Commits

Reviewing files that changed from the base of the PR and between 82b0a4b and 6c8427d.

📒 Files selected for processing (4)
  • scenes/main_scene.tscn
  • scripts/main_scene.gd
  • scripts/parallax_manager.gd
  • scripts/parallax_manager.gd.uid
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-04-10T00:07:55.427Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 534
File: scripts/player.gd:146-149
Timestamp: 2026-04-10T00:07:55.427Z
Learning: In `scripts/globals.gd` and `scripts/player.gd` (GDScript, Godot 4), `current_fuel` is intentionally treated as volatile session data and should NOT be saved to or loaded from `settings.cfg`. Only `max_fuel` (tank capacity) is a persistent setting. `current_fuel` is always reset to `max_fuel` unconditionally in `player._ready()`. Persisting `current_fuel` is considered an architectural mistake by the project maintainer (ikostan). Mid-run fuel state persistence is planned via a separate `SaveGameResource` in PR `#535`.

Applied to files:

  • scripts/main_scene.gd
🪛 GitHub Actions: Pull Request Pipeline
scripts/main_scene.gd

[error] 226-226: gdlint reported: unused function argument 'delta' (unused-argument).

🔇 Additional comments (3)
scripts/parallax_manager.gd.uid (1)

1-1: LGTM — UID metadata file for the new script.

scenes/main_scene.tscn (1)

80-80: LGTM — external resource added and correctly assigned to the Background (ParallaxBackground) node via script = ExtResource("76_qj6t7"). Matches the new ParallaxManager class which extends ParallaxBackground.

Also applies to: 207-208

scripts/main_scene.gd (1)

226-242: The lint issue and dead code removal have already been completed in the current code. The _process method correctly uses _delta as the parameter, and the commented-out lines (236-241) have been removed.

Regarding the parallax behavior during fuel depletion: when fuel depletes, _on_player_out_of_fuel() calls _set_speed(0.0), which emits the speed_changed signal with speed 0. The ParallaxManager receives this via its observer callback and sets _current_speed = 0.0, causing the background to freeze at its current position on the next frame. While this differs from the old behavior (which reset scroll_offset to zero), it is the intended architectural design and not a problem.

			> Likely an incorrect or invalid review comment.

ikostan added 3 commits April 16, 2026 22:04
…ger.gd file.

Here is a comprehensive GUT unit test suite for the new parallax_manager.gd file.

This test file mirrors the high standards of your existing tests. It includes full static typing, proper setup/teardown encapsulation, file headers, and specific scenario testing (Observer updates, math calculations, and null safety).
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 18, 2026

Working on:

  • The new ParallaxManager drops the previous current_fuel <= 0 handling that reset scroll_offset, so if that behavior is still desired you should migrate that logic into the manager instead of just commenting it out in main_scene.gd.

To satisfy the PR reviewer's feedback while maintaining the clean architecture we established, we need to reintroduce the current_fuel <= 0 check into the ParallaxManager.

Since we are already safely accessing Globals.settings inside the _process loop to grab the game's difficulty, we can cleanly grab the current_fuel state in that exact same block and enforce the Vector2.ZERO offset reset.

The new ParallaxManager drops the previous current_fuel <= 0 handling that reset scroll_offset, so if that behavior is still desired you should migrate that logic into the manager instead of just commenting it out in main_scene.gd.

To satisfy the PR reviewer's feedback while maintaining the clean architecture we established, we need to reintroduce the current_fuel <= 0 check into the ParallaxManager.

Since we are already safely accessing Globals.settings inside the _process loop to grab the game's difficulty, we can cleanly grab the current_fuel state in that exact same block and enforce the Vector2.ZERO offset reset.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 18, 2026

Working on:

  • ParallaxManager pulls difficulty from the global Globals.settings each frame, which introduces tight coupling and repeated lookups; consider injecting difficulty (or settings) via a property or signal from the main scene instead.

We can completely eliminate the _process polling by using Dependency Injection alongside the Observer pattern we already set up. main_scene.gd will pass the settings resource into the manager exactly once, and the manager will listen for the difficulty and fuel changes via signals.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/gut/test_parallax_manager.gd`:
- Around line 20-21: The test attaches a ParallaxManager instance via
add_child_autofree(_parallax_manager), which lets Godot call its _process()
between setup and assertions and can change scroll_offset.y; disable automatic
processing on the node under test by calling set_process(false) (or
set_physics_process(false) if appropriate) on the _parallax_manager after
creation and before any assertions so only the manual _process(delta)
invocations in tests mutate state; locate the ParallaxManager.new() /
add_child_autofree(_parallax_manager) lines and add a call to
_parallax_manager.set_process(false) (and set_physics_process(false) if needed)
immediately after adding the child.
- Around line 15-128: Tests currently rely on an implicit default for
GameSettingsResource.current_fuel which couples unrelated tests to fuel state;
explicitly set Globals.settings.current_fuel to a positive value (e.g., 100.0)
in before_each or at the start of tests that expect normal scrolling
(referencing GameSettingsResource and ParallaxManager instances used in
before_each, and the methods _process and _on_player_speed_changed and
properties _current_speed and scroll_offset), and add a new dedicated test
(e.g., test_flameout_resets_scroll) that sets Globals.settings.current_fuel to
0.0 (or a negative value), calls ParallaxManager._process(delta) and asserts
that ParallaxManager.scroll_offset == Vector2.ZERO to cover the fuel-depletion
reset behavior; also update test_scroll_offset_math and
test_zero_speed_stops_scroll to explicitly set current_fuel > 0 before
exercising _process so they no longer depend on the resource default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0d0f3e1b-74d7-47f6-8abe-311ff9df751e

📥 Commits

Reviewing files that changed from the base of the PR and between 6c8427d and d6b27ed.

📒 Files selected for processing (4)
  • scripts/main_scene.gd
  • scripts/parallax_manager.gd
  • test/gut/test_parallax_manager.gd
  • test/gut/test_parallax_manager.gd.uid
✅ Files skipped from review due to trivial changes (1)
  • test/gut/test_parallax_manager.gd.uid
🚧 Files skipped from review as they are similar to previous changes (2)
  • scripts/main_scene.gd
  • scripts/parallax_manager.gd
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: GUT Unit Tests / unit-test
  • GitHub Check: GDUnit4 Unit Tests / unit-test
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-03-30T04:02:23.747Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 500
File: test/gut/test_audio_web_bridge.gd:131-145
Timestamp: 2026-03-30T04:02:23.747Z
Learning: In GUT (Godot Unit Test) for Godot 4, when using `assert_called` / `assert_called_count` with parameter matching, include *every* argument the mocked method accepts, including parameters with default values. GUT does not auto-fill default arguments during call matching. For example, if `JavaScriptBridgeWrapper.eval(script: String, global_exec: bool = false)` is invoked as `eval(js_string)`, the actual call recorded by GUT includes the default (`eval(js_string, false)`), so your assertion must match both arguments (e.g., `.bind(js_string, false)`, not `.bind(js_string)`). Apply this rule to GUT assertions in `test/gut` tests.

Applied to files:

  • test/gut/test_parallax_manager.gd

Comment thread test/gut/test_parallax_manager.gd
Comment thread test/gut/test_parallax_manager.gd
ikostan and others added 3 commits April 17, 2026 20:51
…ion alongside the Observer pattern

ParallaxManager pulls difficulty from the global Globals.settings each frame, which introduces tight coupling and repeated lookups; consider injecting difficulty (or settings) via a property or signal from the main scene instead.

We can completely eliminate the _process polling by using Dependency Injection alongside the Observer pattern we already set up. main_scene.gd will pass the settings resource into the manager exactly once, and the manager will listen for the difficulty and fuel changes via signals.
…epend on current_fuel's default.

The whole point of migrating logic into ParallaxManager (per the PR discussion) was to preserve the current_fuel <= 0 → scroll_offset = Vector2.ZERO behavior — but there is no test asserting this flameout reset. In addition, before_each() constructs a fresh GameSettingsResource without ever setting current_fuel, so:

test_scroll_offset_math only passes if GameSettingsResource.current_fuel happens to default to > 0. If the default is ever changed to 0.0 (common for "fuel to be filled at game start"), the production code would hit the current_fuel <= 0 branch, reset scroll_offset to Vector2.ZERO, and the 80.0 expectation would fail — for reasons unrelated to the math being tested.
test_zero_speed_stops_scroll is similarly coupled: with a fuel default of 0, scroll_offset.y would be reset to 0 rather than staying at 125.5, producing a misleading failure.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 18, 2026

Working on:

  • In _ready you call background._on_player_speed_changed(player.speed["speed"], 0.0) to prime the parallax, but the second argument is always 0.0 and unused—either remove the unused parameter from the signal/handler or pass the actual max speed if it is intended to be meaningful.

The reviewer caught a great detail. We have two options here, but only one is architecturally safe:

  1. Remove the parameter: We cannot do this. The speed_changed(new_speed, max_speed) signal is broadcast by player.gd, and the HUD relies heavily on that max_speed parameter to calculate the color lerping (Green -> Yellow -> Red) for the UI progress bars. If we change the signal signature, the HUD breaks.
  2. Pass the actual max speed: This is the correct approach. Since parallax_manager.gd connects to that signal, it must accept both parameters to match the signature (even if it only uses the first one). Therefore, when we manually prime it in main_scene.gd, we should pass the real max_speed instead of a lazy 0.0.

ikostan added 2 commits April 17, 2026 21:06
In _ready you call background._on_player_speed_changed(player.speed["speed"], 0.0) to prime the parallax, but the second argument is always 0.0 and unused—either remove the unused parameter from the signal/handler or pass the actual max speed if it is intended to be meaningful.

The reviewer caught a great detail. We have two options here, but only one is architecturally safe:

Remove the parameter: We cannot do this. The speed_changed(new_speed, max_speed) signal is broadcast by player.gd, and the HUD relies heavily on that max_speed parameter to calculate the color lerping (Green -> Yellow -> Red) for the UI progress bars. If we change the signal signature, the HUD breaks.
Pass the actual max speed: This is the correct approach. Since parallax_manager.gd connects to that signal, it must accept both parameters to match the signature (even if it only uses the first one). Therefore, when we manually prime it in main_scene.gd, we should pass the real max_speed instead of a lazy 0.0.
The Encapsulation Leak: Calling _on_player_speed_changed from main_scene.gd forces the main scene to "pretend" to be a signal emitter, which is dirty architecture. Creating a public prime_speed() method fixes this, and as a bonus, it means we don't have to clumsily pass the max_speed parameter just to satisfy the signal signature.
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • Consider exposing a public signal or method on ParallaxManager instead of wiring the player directly to the internal _on_player_speed_changed callback from main_scene.gd, so callers don’t need knowledge of an underscored/private-style handler signature.
  • The wrap period computation in main_scene.gd (based on bushes_layer.motion_scale.y and viewport_size) might be better encapsulated inside ParallaxManager or driven by a configuration value, so main_scene doesn’t need to know about the specific parallax layer structure.
  • When priming the manager with player.speed["speed"] in main_scene.gd, consider exposing a typed property or getter on the player instead of pulling from a dictionary key to avoid silent runtime errors if the key or data structure changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider exposing a public signal or method on `ParallaxManager` instead of wiring the player directly to the internal `_on_player_speed_changed` callback from `main_scene.gd`, so callers don’t need knowledge of an underscored/private-style handler signature.
- The wrap period computation in `main_scene.gd` (based on `bushes_layer.motion_scale.y` and `viewport_size`) might be better encapsulated inside `ParallaxManager` or driven by a configuration value, so main_scene doesn’t need to know about the specific parallax layer structure.
- When priming the manager with `player.speed["speed"]` in `main_scene.gd`, consider exposing a typed property or getter on the player instead of pulling from a dictionary key to avoid silent runtime errors if the key or data structure changes.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

…ic API for the Parallax Manager!

This is another excellent catch by your reviewer, focusing on encapsulation and API design.

In GDScript, methods starting with an underscore (like _on_player_speed_changed) are universally understood as "private" or "internal" methods. When main_scene.gd connects directly to a private method on background, it is breaking encapsulation—it "knows too much" about how the background works internally.

By replacing the private handler with a public update_speed method, we create a clean, public API that main_scene.gd is allowed to use.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

  • Consider exposing a public signal or method on ParallaxManager instead of wiring the player directly to the internal _on_player_speed_changed callback from main_scene.gd, so callers don’t need knowledge of an underscored/private-style handler signature.

This is another excellent catch by the reviewer, focusing on encapsulation and API design.

In GDScript, methods starting with an underscore (like _on_player_speed_changed) are universally understood as "private" or "internal" methods. When main_scene.gd connects directly to a private method on background, it is breaking encapsulation—it "knows too much" about how the background works internally.

By replacing the private handler with a public update_speed method, we create a clean, public API that main_scene.gd is allowed to use.

@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

  • The wrap period computation in main_scene.gd (based on bushes_layer.motion_scale.y and viewport_size) might be better encapsulated inside ParallaxManager or driven by a configuration value, so main_scene doesn’t need to know about the specific parallax layer structure.

This is a fantastic architectural critique from the reviewer. It touches on the principle of Encapsulation.

Right now, main_scene.gd is reaching into the background, grabbing a specific layer (bushes_layer), extracting its properties, doing the math, and handing the answer back to the background. main_scene.gd is "knowing too much" about how the background is structured.

The most elegant way to solve this is to let the ParallaxManager inspect its own children and calculate the maximum wrap period automatically. main_scene.gd just needs to say: "Hey, I'm done setting up the layers. Please calculate your wrap limits now."

ikostan added 2 commits April 18, 2026 18:57
…ximum wrap period automatically.

The wrap period computation in main_scene.gd (based on bushes_layer.motion_scale.y and viewport_size) might be better encapsulated inside ParallaxManager or driven by a configuration value, so main_scene doesn’t need to know about the specific parallax layer structure.

This is a fantastic architectural critique from the reviewer. It touches on the principle of Encapsulation.

Right now, main_scene.gd is reaching into the background, grabbing a specific layer (bushes_layer), extracting its properties, doing the math, and handing the answer back to the background. main_scene.gd is "knowing too much" about how the background is structured.

The most elegant way to solve this is to let the ParallaxManager inspect its own children and calculate the maximum wrap period automatically. main_scene.gd just needs to say: "Hey, I'm done setting up the layers. Please calculate your wrap limits now."

Why this is the perfect solution:
Zero Coupling: main_scene.gd no longer knows what a motion_scale is, nor does it care.

Future Proof: If you add 5 more parallax layers tomorrow with totally different speeds, you won't have to change any math. The ParallaxManager will automatically scan them all, find the one that requires the longest wrap period, and apply it seamlessly!
When priming the manager with player.speed["speed"] in main_scene.gd, consider exposing a typed property or getter on the player instead of pulling from a dictionary key to avoid silent runtime errors if the key or data structure changes.

This is an excellent catch by the reviewer. Using a Dictionary (speed["speed"]) for a single numerical value is a "code smell" known as stringly-typed data. It defeats Godot's static typing system. If someone accidentally renames the key or drops the dictionary, the compiler won't warn you, but the game will crash at runtime.

By refactoring this into a strongly typed float property (e.g., current_speed: float = 250.0), we gain full autocomplete, compiler safety, and we protect main_scene.gd from silent errors.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

  • When priming the manager with player.speed["speed"] in main_scene.gd, consider exposing a typed property or getter on the player instead of pulling from a dictionary key to avoid silent runtime errors if the key or data structure changes.

This is an excellent catch by the reviewer. Using a Dictionary (speed["speed"]) for a single numerical value is a "code smell" known as stringly-typed data. It defeats Godot's static typing system. If someone accidentally renames the key or drops the dictionary, the compiler won't warn you, but the game will crash at runtime.

By refactoring this into a strongly typed float property (e.g., current_speed: float = 250.0), we gain full autocomplete, compiler safety, and we protect main_scene.gd from silent errors.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
scripts/parallax_manager.gd (2)

51-65: auto_calculate_wrap_period() silently disables wrap when no layers are found.

If get_children() contains no ParallaxLayers (or all of them have zero motion_scale.y/motion_mirroring.y), max_period stays 0.0 and Line 65 overwrites the exported wrap_period, silently disabling the float-precision safeguard — even when a caller configured it via the inspector. Guard the assignment:

♻️ Proposed fix
-	wrap_period = max_period
+	if max_period > 0.0:
+		wrap_period = max_period
+	else:
+		push_warning(
+			"ParallaxManager.auto_calculate_wrap_period: no valid ParallaxLayer children; "
+			+ "keeping existing wrap_period=%s." % wrap_period
+		)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/parallax_manager.gd` around lines 51 - 65, auto_calculate_wrap_period
currently sets wrap_period to 0.0 if no ParallaxLayer children (or all have zero
motion_scale.y/motion_mirroring.y), silently overwriting an inspector-configured
value; change the function (auto_calculate_wrap_period) to only assign
wrap_period when max_period > 0.0 (i.e., after iterating get_children() and
computing period from each ParallaxLayer using motion_scale.y and
motion_mirroring.y), otherwise leave the existing exported wrap_period untouched
so the inspector/default precision safeguard is preserved.

51-65: Optional: max-of-periods is only safe when layer periods are commensurate.

wrap_period needs to be a common multiple of every layer's motion_mirroring.y / motion_scale.y for all layers to wrap seamlessly. Picking the maximum works only if the other layers' periods evenly divide it. In main_scene.gd, the sand layer uses motion_mirroring.y = 2*tiles_y*tex_size.y while bushes_layer/decor_layer use motion_mirroring.y = viewport.y * parallax_screens_tall, so the periods are generally non-commensurate and a reset at max will cause a visible jump on the shorter-period layer. Consider computing an LCM (over integer-quantized periods) instead, or pad max_period to an integer multiple of each layer's period.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/parallax_manager.gd` around lines 51 - 65, The current
auto_calculate_wrap_period() sets wrap_period to the maximum period which can
cause visible jumps when layer periods are non-commensurate; instead compute a
common multiple of every layer's period (period = motion_mirroring.y /
motion_scale.y) by quantizing periods to integers (choose a precision
multiplier, e.g. factor = 1000), convert each period to an integer n =
round(period * factor), compute the integer LCM across all n values, then set
wrap_period = lcm / factor; update auto_calculate_wrap_period() to iterate
ParallaxLayer children, collect quantized periods, compute LCM, and fall back to
max_period if LCM would overflow or no layers found.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/gut/test_parallax_manager.gd`:
- Around line 198-199: The test creates an uninitialized ParallaxManager
instance (uninitialized_manager) and adds it via add_child_autofree without
disabling its automatic processing, allowing Godot to run _process() and mutate
scroll_offset.y; call set_process(false) on uninitialized_manager immediately
after construction (before add_child_autofree) to match the guard used for
_parallax_manager so the test's manual process invocation and subsequent
assertions remain deterministic.

---

Nitpick comments:
In `@scripts/parallax_manager.gd`:
- Around line 51-65: auto_calculate_wrap_period currently sets wrap_period to
0.0 if no ParallaxLayer children (or all have zero
motion_scale.y/motion_mirroring.y), silently overwriting an inspector-configured
value; change the function (auto_calculate_wrap_period) to only assign
wrap_period when max_period > 0.0 (i.e., after iterating get_children() and
computing period from each ParallaxLayer using motion_scale.y and
motion_mirroring.y), otherwise leave the existing exported wrap_period untouched
so the inspector/default precision safeguard is preserved.
- Around line 51-65: The current auto_calculate_wrap_period() sets wrap_period
to the maximum period which can cause visible jumps when layer periods are
non-commensurate; instead compute a common multiple of every layer's period
(period = motion_mirroring.y / motion_scale.y) by quantizing periods to integers
(choose a precision multiplier, e.g. factor = 1000), convert each period to an
integer n = round(period * factor), compute the integer LCM across all n values,
then set wrap_period = lcm / factor; update auto_calculate_wrap_period() to
iterate ParallaxLayer children, collect quantized periods, compute LCM, and fall
back to max_period if LCM would overflow or no layers found.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b568be64-b54d-441e-aba3-44bdd597f58b

📥 Commits

Reviewing files that changed from the base of the PR and between 19467a2 and ba0fb22.

📒 Files selected for processing (5)
  • scripts/main_scene.gd
  • scripts/parallax_manager.gd
  • scripts/player.gd
  • test/gut/test_parallax_manager.gd
  • test/gut/test_player_fuel_logic.gd
📜 Review details
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2026-04-10T00:07:55.427Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 534
File: scripts/player.gd:146-149
Timestamp: 2026-04-10T00:07:55.427Z
Learning: In `scripts/globals.gd` and `scripts/player.gd` (GDScript, Godot 4), `current_fuel` is intentionally treated as volatile session data and should NOT be saved to or loaded from `settings.cfg`. Only `max_fuel` (tank capacity) is a persistent setting. `current_fuel` is always reset to `max_fuel` unconditionally in `player._ready()`. Persisting `current_fuel` is considered an architectural mistake by the project maintainer (ikostan). Mid-run fuel state persistence is planned via a separate `SaveGameResource` in PR `#535`.

Applied to files:

  • test/gut/test_player_fuel_logic.gd
  • scripts/main_scene.gd
  • scripts/parallax_manager.gd
  • test/gut/test_parallax_manager.gd
📚 Learning: 2026-03-30T04:02:23.747Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 500
File: test/gut/test_audio_web_bridge.gd:131-145
Timestamp: 2026-03-30T04:02:23.747Z
Learning: In GUT (Godot Unit Test) for Godot 4, when using `assert_called` / `assert_called_count` with parameter matching, include *every* argument the mocked method accepts, including parameters with default values. GUT does not auto-fill default arguments during call matching. For example, if `JavaScriptBridgeWrapper.eval(script: String, global_exec: bool = false)` is invoked as `eval(js_string)`, the actual call recorded by GUT includes the default (`eval(js_string, false)`), so your assertion must match both arguments (e.g., `.bind(js_string, false)`, not `.bind(js_string)`). Apply this rule to GUT assertions in `test/gut` tests.

Applied to files:

  • test/gut/test_player_fuel_logic.gd
  • test/gut/test_parallax_manager.gd
📚 Learning: 2026-04-10T00:07:53.308Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 534
File: scripts/player.gd:146-149
Timestamp: 2026-04-10T00:07:53.308Z
Learning: For Godot 4 GDScript files scripts/globals.gd and scripts/player.gd, treat `current_fuel` as volatile session state: do not read from or write `current_fuel` to `settings.cfg`, and do not attempt mid-run persistence via settings. `current_fuel` should always be reset to `max_fuel` unconditionally in `player._ready()`. Only `max_fuel` is a persistent setting; persisting `current_fuel` in settings.cfg is considered an architectural mistake by the project maintainer. Mid-run fuel persistence should be handled via a separate `SaveGameResource` (planned/tracked in PR `#535`), not `settings.cfg`.

Applied to files:

  • scripts/player.gd
🔇 Additional comments (4)
test/gut/test_player_fuel_logic.gd (1)

97-97: LGTM.

Scalar current_speed write matches the new field in scripts/player.gd (Line 33) and preserves the original test intent.

scripts/player.gd (1)

30-33: LGTM on scalar speed migration.

Replacing the speed["speed"] dictionary with a typed current_speed: float is cleaner, avoids repeated dict lookups in the hot path (_physics_process, _on_fuel_timer_timeout), and preserves the 250.0 default. All downstream reads/writes (Lines 142, 146-148, 151-155, 163, 228, 244, 259) are consistently updated.

scripts/main_scene.gd (1)

55-107: Parallax DI/observer wiring LGTM.

The settings injection, duplicate-connect guard via is_connected, public prime_speed / update_speed API usage, and delegated auto_calculate_wrap_period all align with the ParallaxManager contract. The is_instance_valid(Globals) guard (Line 62) keeps this path GUT-friendly, matching the pattern already used in scripts/player.gd (Line 54). Warning messages correctly distinguish missing-signal vs. missing-method failure modes.

Minor nit (non-blocking): if both speed_changed and update_speed are missing, only the first elif warning fires — acceptable since either message indicates breakage.

test/gut/test_parallax_manager.gd (1)

105-158: Good coverage for flameout and recovery paths.

test_flameout_resets_offset and test_flameout_recovery_resumes_scroll directly exercise the _out_of_fuel state machine including the _on_setting_changed("current_fuel", >0) clearing path and the subsequent scroll math — this closes the gap called out earlier about preserving the current_fuel <= 0 → Vector2.ZERO contract inside ParallaxManager.

Comment thread test/gut/test_parallax_manager.gd
ikostan and others added 7 commits April 18, 2026 19:19
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
…r-speed' of https://github.com/ikostan/SkyLockAssault into implement-parallax-scrolling-background-linked-to-player-speed
…e found.

51-65: auto_calculate_wrap_period() silently disables wrap when no layers are found.

If get_children() contains no ParallaxLayers (or all of them have zero motion_scale.y/motion_mirroring.y), max_period stays 0.0 and Line 65 overwrites the exported wrap_period, silently disabling the float-precision safeguard — even when a caller configured it via the inspector.
…urate.

51-65: Optional: max-of-periods is only safe when layer periods are commensurate.

wrap_period needs to be a common multiple of every layer's motion_mirroring.y / motion_scale.y for all layers to wrap seamlessly. Picking the maximum works only if the other layers' periods evenly divide it. In main_scene.gd, the sand layer uses motion_mirroring.y = 2*tiles_y*tex_size.y while bushes_layer/decor_layer use motion_mirroring.y = viewport.y * parallax_screens_tall, so the periods are generally non-commensurate and a reset at max will cause a visible jump on the shorter-period layer. Consider computing an LCM (over integer-quantized periods) instead, or pad max_period to an integer multiple of each layer's period.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In ParallaxManager.auto_calculate_wrap_period() you're rounding float periods to ints and computing an LCM; if motion_mirroring.y / motion_scale.y isn't an exact integer this can introduce subtle drift—consider either documenting the requirement that those ratios be integral or using a float-based approach with an epsilon instead of integer rounding.
  • Several tests reach into _parallax_manager._current_speed and other underscored members; if these are intended to stay private, it may be more robust to expose minimal public accessors or to assert against observable behavior (e.g., scroll_offset) to avoid coupling tests to internal implementation details.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `ParallaxManager.auto_calculate_wrap_period()` you're rounding float periods to ints and computing an LCM; if `motion_mirroring.y / motion_scale.y` isn't an exact integer this can introduce subtle drift—consider either documenting the requirement that those ratios be integral or using a float-based approach with an epsilon instead of integer rounding.
- Several tests reach into `_parallax_manager._current_speed` and other underscored members; if these are intended to stay private, it may be more robust to expose minimal public accessors or to assert against observable behavior (e.g., `scroll_offset`) to avoid coupling tests to internal implementation details.

## Individual Comments

### Comment 1
<location path="scripts/main_scene.gd" line_range="58-63" />
<code_context>
+	# =========================================================
+	# 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
+	)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Handle the case where `Globals` exists but `Globals.settings` is null or invalid.

As written, `settings_res` can reference `Globals.settings` even when the singleton exists but `Globals.settings` is null or freed, so `background.setup(settings_res)` may receive an invalid resource. Consider also checking `Globals.settings` itself (e.g., `is_instance_valid(Globals.settings)` or a null check) so `settings_res` is guaranteed to be either a valid `GameSettingsResource` or `null`.

```suggestion
	# 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.
	# Also guard against a null or freed Globals.settings so background.setup
	# only ever receives a valid GameSettingsResource or null.
	var settings_res: GameSettingsResource = (
		Globals.settings
		if is_instance_valid(Globals)
		and Globals.settings != null
		and is_instance_valid(Globals.settings)
		else null
	)
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/main_scene.gd
ikostan and others added 3 commits April 18, 2026 19:38
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
> * In `ParallaxManager.auto_calculate_wrap_period()` you're rounding float periods to ints and computing an LCM; if `motion_mirroring.y / motion_scale.y` isn't an exact integer this can introduce subtle drift—consider either documenting the requirement that those ratios be integral or using a float-based approach with an epsilon instead of integer rounding.

This feedback is incredibly sharp. The reviewer is pointing out a "silent failure" that happens when math precision slowly degrades the visual quality of the game over time, known as "sub-pixel drift."

Here is why it happens:
Imagine one of your layers has a calculated period of 1000.5. By using roundi(), we force it to 1001. The LCM math works perfectly using 1001, but the actual graphic on the screen is still wrapping every 1000.5 pixels. Every time the background loops, that layer will visually "slip" by 0.5 pixels. After 10 loops, the graphic has drifted 5 full pixels out of alignment, causing noticeable stuttering or seams.

In 2D game development, the best practice is to always use exact integers for texture mirroring to prevent artifacting anyway. Therefore, instead of rewriting the LCM math to use complex float-epsilons, we should do exactly what the reviewer suggested as their first option: document the requirement and throw a warning if the rule is broken.
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

  • In ParallaxManager.auto_calculate_wrap_period() you're rounding float periods to ints and computing an LCM; if motion_mirroring.y / motion_scale.y isn't an exact integer this can introduce subtle drift—consider either documenting the requirement that those ratios be integral or using a float-based approach with an epsilon instead of integer rounding.

This feedback is incredibly sharp. The reviewer is pointing out a "silent failure" that happens when math precision slowly degrades the visual quality of the game over time, known as "sub-pixel drift."

Here is why it happens:
Imagine one of your layers has a calculated period of 1000.5. By using roundi(), we force it to 1001. The LCM math works perfectly using 1001, but the actual graphic on the screen is still wrapping every 1000.5 pixels. Every time the background loops, that layer will visually "slip" by 0.5 pixels. After 10 loops, the graphic has drifted 5 full pixels out of alignment, causing noticeable stuttering or seams.

In 2D game development, the best practice is to always use exact integers for texture mirroring to prevent artifacting anyway. Therefore, instead of rewriting the LCM math to use complex float-epsilons, we should do exactly what the reviewer suggested as their first option: document the requirement and throw a warning if the rule is broken.

@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Apr 19, 2026

  • Several tests reach into _parallax_manager._current_speed and other underscored members; if these are intended to stay private, it may be more robust to expose minimal public accessors or to assert against observable behavior (e.g., scroll_offset) to avoid coupling tests to internal implementation details.

It is a "clean code" suggestion rather than a logic bug or a performance risk. Won't fix.

@ikostan ikostan merged commit 5c46405 into main Apr 19, 2026
16 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Sky Lock Assault Project Apr 19, 2026
@ikostan ikostan deleted the implement-parallax-scrolling-background-linked-to-player-speed branch April 19, 2026 02:59
@ikostan ikostan changed the title [FEATURE] Implement Parallax Scrolling Background linked to Player Sp… Implement Parallax Scrolling Background linked to Player Speed Apr 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

controls enhancement New feature or request GUI

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEATURE] Implement Parallax Scrolling Background linked to Player Speed [FEATURE] Convert Hard-Coded Speed Elements to Godot Resource

1 participant