From 3d2410c8fdd9e21c7a6083b6fc7204bdad155fcc Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Fri, 7 Aug 2026 11:37:57 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20release=20spools=20time=20back=20up=20?= =?UTF-8?q?=E2=80=94=20thaw=20ramp=20on=20hand=20close,=20curved=20in-band?= =?UTF-8?q?=20thaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/hand.gd | 29 +++++++++++++++++++++++++++-- tests/smoke.gd | 14 ++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/scripts/hand.gd b/scripts/hand.gd index 21dc2d0..7a36b89 100644 --- a/scripts/hand.gd +++ b/scripts/hand.gd @@ -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: diff --git a/tests/smoke.gd b/tests/smoke.gd index f510bb9..303d2f1 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -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: