diff --git a/scripts/hand.gd b/scripts/hand.gd index 1153f90..21dc2d0 100644 --- a/scripts/hand.gd +++ b/scripts/hand.gd @@ -1,23 +1,57 @@ extends CanvasLayer -## 2-slot hand + universal charge pool + card-time dilation. +## 2-slot hand + universal charge pool + card-time control. ## ## Canon mapping (CONCEPT.md § loop stack / § tag grammar): the build ## generates charges (auto-fire hits feed the pool — tightest -## build<->hand coupling), opening the hand dilates or freezes time +## build<->hand coupling), opening the hand stops or dilates time ## (flow -> freeze -> think -> release), charges are one universal -## currency. The freeze/dilate scale is THE exposed playtest knob: -## [ and ] step it live, 0.0 = full freeze. +## currency. ## -## Greybox shortcut, deliberate: the two cards are entries in CARDS +## Two time modes, A/B-flippable live with M (playtest variant, +## operator-directed 2026-08-07 — canon ratification pending verdict): +## +## - METER (default): a Freeze charge that grows while the hand is +## closed and drains while open. Its level maps to bands, best to +## worst: FROZEN (true pause) -> THAW (slow-mo ramping back to full +## speed as it drains) -> REALTIME (hand open, world at full speed) +## -> UNAVAILABLE (empty: the hand will not open at all). Opening +## with a full meter buys the whole ladder; opening early buys only +## the lower bands. Prices freeze-scouting (anti-dominant-strategy) +## and adds temporal triangularity to chains. +## - TOGGLE: unlimited freeze/dilation on Space, the original variant. +## [ and ] step its dilation knob (0 = full freeze). +## +## Freeze uses SceneTree.paused, never Engine.time_scale = 0: probed +## on 4.7 headless — time_scale 0 still steps physics with delta 0, +## so delta-independent logic kept firing while nominally frozen. +## +## Greybox shortcut, deliberate: the two cards are entries in `cards` ## with a method each, not card resources — structured cards arrive ## with the belt (post-greybox, parked). +@export var meter_mode := true @export var dilation_scale := 0.0 @export var dilation_step := 0.05 @export var dilation_max := 0.3 @export var hits_per_charge := 4 @export var max_charges := 5 +@export_group("Freeze meter") +## Seconds of open-hand time a full meter holds. +@export var meter_max := 3.0 +## Meter seconds regained per real second while the hand is closed. +@export var regen_rate := 0.4 +## Meter seconds spent per real second while the hand is open. +@export var drain_rate := 1.0 +## Band boundaries as fractions of the full meter, ordered: +## >= band_thaw: FROZEN · >= band_realtime: THAW · >= band_unavailable: +## REALTIME · below: hand will not open. +@export var band_thaw := 0.45 +@export var band_realtime := 0.18 +@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 + @export_group("KINETIC card") @export var kinetic_cost := 1 @export var kinetic_radius := 160.0 @@ -33,7 +67,9 @@ const FX_RING := preload("res://scripts/fx_ring.gd") var charges := 0 var hand_open := false +var meter := 0.0 var _hit_progress := 0 +var _last_ms := 0 # Slot order is the hand: Q = slot 1, E = slot 2. var cards: Array[Dictionary] = [] @@ -44,6 +80,8 @@ func _ready() -> void: # The hand must keep processing (and receiving input) while the # world is paused for a full freeze. process_mode = Node.PROCESS_MODE_ALWAYS + meter = meter_max + _last_ms = Time.get_ticks_msec() cards = [ {"name": "KINETIC", "hue": Color(0.75, 0.85, 0.95), "cost_of": func() -> int: return kinetic_cost, "play": _play_kinetic}, {"name": "HEAT", "hue": Color(0.95, 0.55, 0.2), "cost_of": func() -> int: return heat_cost, "play": _play_heat}, @@ -57,24 +95,49 @@ func _exit_tree() -> void: func _process(_delta: float) -> void: + # The meter runs on the wall clock: _delta is scaled by the very + # time_scale this node is animating (and pause zeroes physics), + # so scaled delta would freeze the meter along with the world. + var now := Time.get_ticks_msec() + var real_dt := clampf(float(now - _last_ms) / 1000.0, 0.0, 0.1) + _last_ms = now + + if meter_mode: + if hand_open: + meter = maxf(meter - drain_rate * real_dt, 0.0) + if meter <= 0.0: + _set_open(false) + else: + meter = minf(meter + regen_rate * real_dt, meter_max) + if Input.is_action_just_pressed("hand_toggle"): - _set_open(not hand_open) + if hand_open: + _set_open(false) + elif can_open(): + _set_open(true) if hand_open: if Input.is_action_just_pressed("card_1"): try_play(0) if Input.is_action_just_pressed("card_2"): try_play(1) + if meter_mode: + # Bands shift as the meter drains under an open hand. + _apply_time_state() $HUD.queue_redraw() func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: - if event.physical_keycode == KEY_BRACKETLEFT: - dilation_scale = maxf(0.0, dilation_scale - dilation_step) - _apply_time_scale() - elif event.physical_keycode == KEY_BRACKETRIGHT: - dilation_scale = minf(dilation_max, dilation_scale + dilation_step) - _apply_time_scale() + match event.physical_keycode: + KEY_BRACKETLEFT: + dilation_scale = maxf(0.0, dilation_scale - dilation_step) + _apply_time_state() + KEY_BRACKETRIGHT: + dilation_scale = minf(dilation_max, dilation_scale + dilation_step) + _apply_time_state() + KEY_M: + meter_mode = not meter_mode + _apply_time_state() func on_build_hit() -> void: @@ -84,6 +147,14 @@ func on_build_hit() -> void: charges = mini(charges + 1, max_charges) +func can_open() -> bool: + return not meter_mode or meter / meter_max >= band_unavailable + + +func frac() -> float: + return meter / meter_max + + func try_play(slot: int) -> void: var card := cards[slot] var cost: int = card.cost_of.call() @@ -95,21 +166,47 @@ func try_play(slot: int) -> void: func _set_open(open: bool) -> void: hand_open = open - $Dim.visible = open - _apply_time_scale() + _apply_time_state() -func _apply_time_scale() -> void: - # Knob 0 = a REAL pause: Engine.time_scale = 0 still steps physics - # with delta 0 on 4.7 (probed headless — delta-independent logic - # like heat thresholds kept firing "frozen"). SceneTree.paused - # stops physics outright; time_scale covers dilation > 0. - if hand_open and dilation_scale == 0.0: - get_tree().paused = true +func _apply_time_state() -> void: + var tree := get_tree() + if not hand_open: + tree.paused = false Engine.time_scale = 1.0 + $Dim.visible = false + return + $Dim.visible = true + if meter_mode: + var f := frac() + if f >= band_thaw: + tree.paused = true + Engine.time_scale = 1.0 + _set_dim(1.0) + elif f >= band_realtime: + tree.paused = false + var t := (band_thaw - f) / (band_thaw - band_realtime) + Engine.time_scale = clampf(t, thaw_min_scale, 1.0) + _set_dim(1.0 - t) + else: + tree.paused = false + Engine.time_scale = 1.0 + _set_dim(0.0) else: - get_tree().paused = false - Engine.time_scale = dilation_scale if hand_open else 1.0 + if dilation_scale == 0.0: + tree.paused = true + Engine.time_scale = 1.0 + _set_dim(1.0) + else: + tree.paused = false + Engine.time_scale = dilation_scale + _set_dim(0.6) + + +func _set_dim(k: float) -> void: + # Dim depth doubles as the time-state telegraph: dark = frozen, + # lifting = thawing, barely-there = realtime. Wordless. + $Dim.color = Color(0, 0, 0, lerpf(0.08, 0.35, k)) func _mech() -> Node2D: diff --git a/scripts/hand_hud.gd b/scripts/hand_hud.gd index 7333240..01b00c1 100644 --- a/scripts/hand_hud.gd +++ b/scripts/hand_hud.gd @@ -35,6 +35,27 @@ func _draw() -> void: var c := Color(0.9, 0.9, 0.95) if filled else Color(0.9, 0.9, 0.95, 0.18) draw_rect(Rect2(px0 + i * (pip + 3.0), y0 - 10.0, pip, pip), c) + if _hand.meter_mode: + # Freeze meter: fill color names the band you'd open into. + var bw := 84.0 + var bh := 4.0 + var bx := (640.0 - bw) / 2.0 + var by := y0 - 20.0 + draw_rect(Rect2(bx, by, bw, bh), Color(0.9, 0.9, 0.95, 0.12)) + var f: float = _hand.frac() + var col := Color(0.85, 0.95, 1.0) + if f < _hand.band_unavailable: + col = Color(0.8, 0.3, 0.25) + elif f < _hand.band_realtime: + col = Color(0.55, 0.57, 0.6) + elif f < _hand.band_thaw: + col = Color(0.55, 0.75, 0.95) + draw_rect(Rect2(bx, by, bw * f, bh), col) + for b: float in [_hand.band_unavailable, _hand.band_realtime, _hand.band_thaw]: + var tx := bx + bw * b + draw_line(Vector2(tx, by - 1), Vector2(tx, by + bh + 1), Color(0.1, 0.1, 0.12, 0.9), 1.0) + var font := ThemeDB.fallback_font - draw_string(font, Vector2(560, 352), "dt x%.2f" % _hand.dilation_scale, + var label: String = "frz %.1f/%.1fs" % [_hand.meter, _hand.meter_max] if _hand.meter_mode else "dt x%.2f" % _hand.dilation_scale + draw_string(font, Vector2(560, 352), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 8, Color(0.6, 0.65, 0.7, 0.7)) diff --git a/tests/smoke.gd b/tests/smoke.gd index f268e3d..f510bb9 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -103,11 +103,10 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void: if not hand.hand_open: _failures.append("hand did not open on hand_toggle") return - if hand.dilation_scale == 0.0: - if not get_tree().paused: - _failures.append("hand open at knob 0 did not pause the world") - elif absf(Engine.time_scale - hand.dilation_scale) > 0.001: - _failures.append("time_scale %.3f != dilation knob %.3f with hand open" % [Engine.time_scale, hand.dilation_scale]) + var expect_pause: bool = (hand.meter_mode and hand.frac() >= hand.band_thaw) \ + or (not hand.meter_mode and hand.dilation_scale == 0.0) + if expect_pause and not get_tree().paused: + _failures.append("hand open in freeze state did not pause the world") # Pick the target NOW — the world is frozen, so it can neither die # nor wander out of the heat radius before the casts land. @@ -135,6 +134,59 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void: if is_instance_valid(target): _failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat) + await _test_meter(hand) + + +func _test_meter(hand: Node) -> void: + # Band ladder, white-box: set the meter, open, check the time state. + # THAW band: open, unpaused, slow-mo strictly between 0 and 1. + hand.meter = hand.meter_max * 0.3 + await _tap("hand_toggle") + if not hand.hand_open: + _failures.append("hand refused to open in thaw band") + return + if get_tree().paused: + _failures.append("thaw band paused the world") + if Engine.time_scale <= 0.0 or Engine.time_scale >= 1.0: + _failures.append("thaw band time_scale %.2f not in (0,1)" % Engine.time_scale) + + # REALTIME band: open, world at full speed. + hand.meter = hand.meter_max * 0.14 + await get_tree().process_frame + await get_tree().process_frame + if get_tree().paused or Engine.time_scale != 1.0: + _failures.append("realtime band: paused=%s time_scale=%.2f, expected live x1" % [get_tree().paused, Engine.time_scale]) + + # Depletion force-closes the hand... + hand.meter = 0.0 + await get_tree().process_frame + await get_tree().process_frame + if hand.hand_open: + _failures.append("hand did not force-close at meter depletion") + return + # ...and it will not reopen while empty (no Space-spam refreeze). + await _tap("hand_toggle") + if hand.hand_open: + _failures.append("hand reopened on an empty meter") + return + + # The Freeze charge grows while the hand is closed. + var m0: float = hand.meter + for i in 30: + await get_tree().process_frame + if hand.meter <= m0: + _failures.append("meter did not regenerate while closed") + + # TOGGLE mode (the A/B flip) still hard-freezes at knob 0. + hand.meter_mode = false + await _tap("hand_toggle") + if not hand.hand_open or not get_tree().paused: + _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") + func _nearest_grunt(mech: CharacterBody2D) -> Node2D: var best: Node2D = null