From 651f8b787eac40f9fa699a0d7302a32cde3852aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 19:21:54 +0200 Subject: [PATCH] Space-time distorder drains energy while boosting (#62) GameState._advance_distorder_drain() runs in GameState._process() - not the distorder component's tick() - alongside extractor mining and production, so it (and the fleet's travel) keeps working across the space<->surface scene swap. Drain scales with how far above 1.0 the multiplier is: (fleet_speed_multiplier - 1.0) / (MAX - 1.0) * ENERGY_DRAIN_PER_SECOND (2.0 at full boost), accumulated fractionally the same way extractor mining_progress is so small multipliers still drain a nonzero amount instead of rounding to zero every frame. Running the fleet hold's energy to exactly zero force-resets fleet_speed_multiplier to 1.0 and raises a one-shot boost_depleted_notice flag, since GameState can't reach into whichever scene's SpeedFader may or may not currently exist. SpeedFader.gd consumes the flag on its next _process(), snaps its knob down (it was already reading the multiplier live, so this falls out for free) and shows "Out of energy!" - also clearing active_touch_index so a still-held drag can't immediately re-boost from the position the finger never moved away from. test_space_time_distorder.gd (#59) updated to stock fleet energy first, for the same reason #60/#61 needed it: boosting is no longer free, and that test's own multi-second boosted phases would otherwise get cut short mid-measurement by running out. --- GameState.gd | 38 ++++++++ SpeedFader.gd | 17 +++- tests/test_distorder_energy.gd | 150 +++++++++++++++++++++++++++++ tests/test_space_time_distorder.gd | 7 ++ 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/test_distorder_energy.gd diff --git a/GameState.gd b/GameState.gd index 53a1c4f..6f1f295 100644 --- a/GameState.gd +++ b/GameState.gd @@ -29,6 +29,17 @@ var planet_definition_id: String = "rocky" const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 var fleet_speed_multiplier: float = 1.0 +# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the +# multiplier is - a small boost costs proportionally less, full boost costs +# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing. +const ENERGY_DRAIN_PER_SECOND: float = 2.0 +var _distorder_drain_progress: float = 0.0 +# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant +# a sustained boost is force-reset for running the fleet hold's energy +# to zero, so the control (which may not even be part of the current +# scene tree at that exact frame) can show why on its next _process(). +var boost_depleted_notice: bool = false + func set_fleet_speed_multiplier(value: float) -> void: fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) @@ -1165,6 +1176,32 @@ func _advance_gas_burners(delta: float) -> void: b.gas_burn_progress = progress +func _advance_distorder_drain(delta: float) -> void: + if fleet_speed_multiplier <= 1.0: + # Nothing owed at 1.0x - and no stale fractional progress should + # carry over into a LATER, unrelated boost. + _distorder_drain_progress = 0.0 + return + var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \ + * ENERGY_DRAIN_PER_SECOND + _distorder_drain_progress += rate * delta + var whole: int = int(floor(_distorder_drain_progress)) + if whole <= 0: + return + _distorder_drain_progress -= whole + var available: int = fleet_inventory.amount(RESOURCE_ENERGY) + if available >= whole: + fleet_inventory.withdraw(RESOURCE_ENERGY, whole) + return + # Ran out mid-drain: take whatever is left (down to exactly zero) and + # force the boost back to its default, rather than let the fleet keep + # travelling fast on credit. + fleet_inventory.withdraw(RESOURCE_ENERGY, available) + fleet_speed_multiplier = 1.0 + _distorder_drain_progress = 0.0 + boost_depleted_notice = true + + # Planet conquest: a planet must have every one of its capture points held # before an extractor can be built there (see BuildController's placement # gates). Keyed by the same stable planet_id as `extractors`, entirely @@ -1240,6 +1277,7 @@ func _process(delta: float) -> void: _advance_lab_research(delta) _advance_vessel_deliveries(delta) _advance_gas_burners(delta) + _advance_distorder_drain(delta) for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE var whole: int = int(floor(float(e.mining_progress))) diff --git a/SpeedFader.gd b/SpeedFader.gd index 6449b77..a70bd68 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -22,6 +22,7 @@ extends Control var active_touch_index: int = -1 # 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). var knob_t: float = 0.0 +var status_message_until: int = 0 func _ready() -> void: @@ -44,7 +45,21 @@ func _process(_delta: float) -> void: return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) - label.text = "x%.1f" % GameState.fleet_speed_multiplier + # #62: GameState._process() (not this node's own _process()) is what + # drains energy and force-resets the multiplier, since it has to keep + # running across the space<->surface swap and while this control might + # not even be in the current scene tree. It leaves a one-shot flag for + # whichever SpeedFader picks it up next. + if GameState.boost_depleted_notice: + GameState.boost_depleted_notice = false + # Still holding the drag at the moment energy ran out would + # otherwise re-boost on the very next drag event, from a knob + # position the finger never actually moved away from. + active_touch_index = -1 + label.text = "Out of energy!" + status_message_until = Time.get_ticks_msec() + 3000 + elif Time.get_ticks_msec() >= status_message_until: + label.text = "x%.1f" % GameState.fleet_speed_multiplier queue_redraw() diff --git a/tests/test_distorder_energy.gd b/tests/test_distorder_energy.gd new file mode 100644 index 0000000..ea73206 --- /dev/null +++ b/tests/test_distorder_energy.gd @@ -0,0 +1,150 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_distorder_energy.gd +# Forgejo #62: sustaining a boost above the fleet's default speed drains +# energy from the fleet hold, continuously, proportional to how far above +# 1.0x the multiplier is. +# +# Most of this is pure GameState math (GameState._advance_distorder_drain, +# called directly the same way other tests call _advance_lab_research/ +# _advance_production) - no Main.tscn/Fleet needed for it, which is itself +# proof the drain is scene-independent (it lives in the autoload's own +# _process(), unlike a component's tick() which only runs while its ship's +# scene is loaded) and so "continues correctly across a landing and +# return" without a scene-swap simulation to re-prove that. +# +# Covers: +# A. sustained boost drains energy at the configured rate; half boost +# drains proportionally less than full boost +# B. a small multiplier still drains a nonzero amount over time - no +# fractional rounding loss +# D. at exactly 1.0x, energy is never consumed +# C. hitting zero energy forces the multiplier back to 1.0, and the +# SpeedFader visibly follows it down with the reason shown + +var game_state: Node +var main: Node +var fleet: Node +var fader: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_progression() + game_state.fleet_speed_multiplier = 1.0 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 1000) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- D. nothing drains at exactly 1.0x ---------------------------- + var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(60): + game_state._advance_distorder_drain(1.0) + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != before: + return _fail_now("energy drained at 1.0x: %d -> %d" \ + % [before, game_state.fleet_resource(GameState.RESOURCE_ENERGY)]) + print("no drain at 1.0x, even over 60 simulated seconds") + + # --- A. full boost drains at the configured rate ------------------ + game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER + var full_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(10): + game_state._advance_distorder_drain(1.0) + var full_drained: int = full_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if full_drained != 20: + return _fail_now("full boost drained %d over 10s, expected 20 (ENERGY_DRAIN_PER_SECOND=2.0)" \ + % full_drained) + print("full boost drained %d over 10s, matches ENERGY_DRAIN_PER_SECOND" % full_drained) + + # --- A continued: half boost drains proportionally less ----------- + game_state.fleet_speed_multiplier = 2.0 + var half_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(10): + game_state._advance_distorder_drain(1.0) + var half_drained: int = half_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if half_drained <= 0 or half_drained >= full_drained: + return _fail_now("half boost drained %d over 10s, expected between 0 and full's %d" \ + % [half_drained, full_drained]) + print("half boost drained %d over 10s - less than full's %d, still nonzero" \ + % [half_drained, full_drained]) + + # --- B. a small multiplier still drains a nonzero amount ---------- + game_state.fleet_speed_multiplier = 1.05 + var small_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + for i in range(600): + game_state._advance_distorder_drain(0.1) + var small_drained: int = small_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + if small_drained <= 0: + return _fail_now("a small (1.05x) sustained boost drained nothing over 60s - fractional rounding loss") + print("small boost (1.05x) still drained %d over 60s (no rounding loss)" % small_drained) + + # --- C. running out resets the multiplier and raises the notice --- + game_state.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, + game_state.fleet_resource(GameState.RESOURCE_ENERGY)) + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 5) + game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER + game_state.boost_depleted_notice = false + for i in range(10): + game_state._advance_distorder_drain(1.0) + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != 0: + return _fail_now("fleet energy did not reach exactly 0, got %d" \ + % game_state.fleet_resource(GameState.RESOURCE_ENERGY)) + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier was not forced back to 1.0 after running out: %f" \ + % game_state.fleet_speed_multiplier) + if not game_state.boost_depleted_notice: + return _fail_now("no depleted notice was raised when energy ran out") + print("running out of energy dropped the multiplier to 1.0 and raised the notice") + + stage = 1 + elapsed = 0.0 + return false + 1: + # --- C continued: the SpeedFader visibly follows it down ---------- + if elapsed < 0.2: + return false + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_DISTORDER])) + fader._process(0.0) + if not fader.visible: + return _fail_now("fader hidden with a distorder ship in the fleet") + + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 4) + game_state.set_fleet_speed_multiplier(GameState.MAX_FLEET_SPEED_MULTIPLIER) + fader._process(0.0) + if fader.knob_t < 0.9: + return _fail_now("fader knob did not reflect the boosted multiplier before draining: %f" \ + % fader.knob_t) + + for i in range(10): + game_state._advance_distorder_drain(1.0) + if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0): + return _fail_now("multiplier was not forced back to 1.0 after running out (with a live fleet)") + fader._process(0.0) + if fader.knob_t > 0.05: + return _fail_now("fader knob did not snap down after the boost was depleted: %f" % fader.knob_t) + if fader.label.text != "Out of energy!": + return _fail_now("fader did not show the depleted reason, text was '%s'" % fader.label.text) + if game_state.boost_depleted_notice: + return _fail_now("boost_depleted_notice was not consumed by the fader") + print("fader visibly snapped its knob down and explained the depletion") + + print("TEST_DISTORDER_ENERGY: PASS") + quit(0) + return true + return false + + +func _fail_now(message: String) -> bool: + print("TEST_DISTORDER_ENERGY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index 8aff0e7..6890399 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -37,6 +37,13 @@ func _initialize() -> void: game_state.stored_vessels.clear() game_state.reset_progression() game_state.vessel_levels["unlock_distorder"] = 1 + # #62: sustaining a boost now drains fleet energy continuously - stock + # plenty so this test's own boosted phases aren't cut short by running + # out mid-measurement. Set BEFORE the first spawn_ships() call (inside + # Main.tscn's own _ready(), triggered by instantiate() below): the fleet + # hold has no capacity cap yet at this point, so this can't be clamped + # by the soon-to-exist fleet's cargo capability. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet")