feat: release spools time back up — thaw ramp on hand close, curved in-band thaw

Closing the hand no longer snaps to full speed: time ramps from where
it stood (near-zero out of a freeze, current slow-mo out of a thaw,
x1 out of realtime — depletion force-close gifts no slow-mo) up to x1
over release_thaw_s wall-clock seconds (default 0.5; 0 = snap).
Reopening mid-ramp cancels it. In-band thaw gains an acceleration
curve (thaw_curve, default 2.2): hangs slow longer, then rushes to
full speed, instead of linear.

Smoke gate: release must NOT be at x1 immediately after close (ramp
present) and must reach x1 within 3s; both modes covered. 3/3 green
on nh3-dev headless 4.7.1.
This commit is contained in:
2026-08-07 11:37:57 -07:00
parent 73f84e7d0d
commit 3d2410c8fd
2 changed files with 37 additions and 6 deletions
+27 -2
View File
@@ -51,6 +51,12 @@ extends CanvasLayer
@export var band_unavailable := 0.1
## Slowest thaw speed (time_scale at the top of the thaw band).
@export var thaw_min_scale := 0.05
## Closing the hand ramps time back to full speed over this many real
## seconds instead of snapping (applies in both modes; 0 = snap).
@export var release_thaw_s := 0.5
## In-band thaw acceleration: 1 = linear; higher hangs slow longer,
## then rushes to full speed.
@export var thaw_curve := 2.2
@export_group("KINETIC card")
@export var kinetic_cost := 1
@@ -70,6 +76,8 @@ var hand_open := false
var meter := 0.0
var _hit_progress := 0
var _last_ms := 0
var _release_from := 1.0
var _release_t := -1.0
# Slot order is the hand: Q = slot 1, E = slot 2.
var cards: Array[Dictionary] = []
@@ -123,6 +131,15 @@ func _process(_delta: float) -> void:
if meter_mode:
# Bands shift as the meter drains under an open hand.
_apply_time_state()
# Release ramp: after the hand closes, time spools back up to full
# speed on the wall clock instead of snapping.
if not hand_open and _release_t >= 0.0:
_release_t += real_dt
var k := clampf(_release_t / release_thaw_s, 0.0, 1.0)
Engine.time_scale = lerpf(_release_from, 1.0, k)
if k >= 1.0:
_release_t = -1.0
$HUD.queue_redraw()
@@ -165,6 +182,14 @@ func try_play(slot: int) -> void:
func _set_open(open: bool) -> void:
if open:
_release_t = -1.0
elif release_thaw_s > 0.0:
# Spool up from wherever time stood: near-zero out of a freeze,
# the current slow-mo out of a thaw, x1 out of realtime (so a
# depletion force-close never gifts free slow-mo).
_release_from = thaw_min_scale if get_tree().paused else Engine.time_scale
_release_t = 0.0
hand_open = open
_apply_time_state()
@@ -173,7 +198,7 @@ func _apply_time_state() -> void:
var tree := get_tree()
if not hand_open:
tree.paused = false
Engine.time_scale = 1.0
Engine.time_scale = _release_from if _release_t >= 0.0 else 1.0
$Dim.visible = false
return
$Dim.visible = true
@@ -185,7 +210,7 @@ func _apply_time_state() -> void:
_set_dim(1.0)
elif f >= band_realtime:
tree.paused = false
var t := (band_thaw - f) / (band_thaw - band_realtime)
var t := pow((band_thaw - f) / (band_thaw - band_realtime), thaw_curve)
Engine.time_scale = clampf(t, thaw_min_scale, 1.0)
_set_dim(1.0 - t)
else:
+10 -4
View File
@@ -124,11 +124,16 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
if target.heat < target.heat_threshold:
_failures.append("HEAT x2 left target below threshold (heat %.1f)" % target.heat)
# Release: time resumes, the over-threshold grunt detonates.
# Release: the world unpauses into the spool-up ramp (not a snap),
# reaches full speed, and the over-threshold grunt detonates.
await _tap("hand_toggle")
if hand.hand_open or get_tree().paused or Engine.time_scale != 1.0:
if hand.hand_open or get_tree().paused:
_failures.append("hand did not close / world did not resume")
return
if hand.release_thaw_s > 0.0 and Engine.time_scale >= 1.0:
_failures.append("release snapped straight to full speed — no ramp")
if not await _until(func() -> bool: return Engine.time_scale >= 0.999, 3.0, "release ramp never reached full speed"):
return
for i in 10:
await get_tree().physics_frame
if is_instance_valid(target):
@@ -184,8 +189,9 @@ func _test_meter(hand: Node) -> void:
_failures.append("toggle mode at knob 0 did not open+pause")
await _tap("hand_toggle")
hand.meter_mode = true
if get_tree().paused or Engine.time_scale != 1.0:
_failures.append("world did not resume after toggle-mode close")
if get_tree().paused:
_failures.append("world stayed paused after toggle-mode close")
await _until(func() -> bool: return Engine.time_scale >= 0.999, 3.0, "toggle-mode release ramp never reached full speed")
func _nearest_grunt(mech: CharacterBody2D) -> Node2D: