feat: Freeze charge meter — banded card-time (freeze/thaw/realtime/locked), A/B vs toggle
Meter variant (default; M flips to the original unlimited toggle live): a Freeze charge regenerates while the hand is closed and drains while open. Level maps to bands — full/high: true pause; mid: thaw slow-mo ramping back to realtime as it drains; low: realtime card draw; empty: hand force-closes and will not reopen until regrown past the floor. Opening with a full meter buys the whole ladder (full freeze before thaw); empty-meter Space-spam cannot refreeze. Dim overlay depth telegraphs the band wordlessly; HUD meter bar shows band boundaries and fill colored by openable band. All rates/bands exported. Meter runs on the wall clock (Time.get_ticks_msec) — process delta is scaled by the very time_scale the hand animates, and pause zeroes physics delta. Design rationale (KB-grounded, pressure-tested 2026-08-07): prices the freeze-scout dominant strategy, adds temporal triangularity to chains, and yields a canon-safe difficulty/accessibility axis (meter size, regen, thaw length). Playtest variant — canon ratification pending operator verdict; CONCEPT.md entry lands in the advisor repo after. Smoke gate extended: band ladder (thaw slow-mo in (0,1), realtime x1, depletion force-close, empty-reopen refusal), regen while closed, toggle-mode A/B still hard-freezes. 3/3 green on nh3-dev headless 4.7.1.
This commit is contained in:
+120
-23
@@ -1,23 +1,57 @@
|
|||||||
extends CanvasLayer
|
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
|
## Canon mapping (CONCEPT.md § loop stack / § tag grammar): the build
|
||||||
## generates charges (auto-fire hits feed the pool — tightest
|
## 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
|
## (flow -> freeze -> think -> release), charges are one universal
|
||||||
## currency. The freeze/dilate scale is THE exposed playtest knob:
|
## currency.
|
||||||
## [ and ] step it live, 0.0 = full freeze.
|
|
||||||
##
|
##
|
||||||
## 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 a method each, not card resources — structured cards arrive
|
||||||
## with the belt (post-greybox, parked).
|
## with the belt (post-greybox, parked).
|
||||||
|
|
||||||
|
@export var meter_mode := true
|
||||||
@export var dilation_scale := 0.0
|
@export var dilation_scale := 0.0
|
||||||
@export var dilation_step := 0.05
|
@export var dilation_step := 0.05
|
||||||
@export var dilation_max := 0.3
|
@export var dilation_max := 0.3
|
||||||
@export var hits_per_charge := 4
|
@export var hits_per_charge := 4
|
||||||
@export var max_charges := 5
|
@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_group("KINETIC card")
|
||||||
@export var kinetic_cost := 1
|
@export var kinetic_cost := 1
|
||||||
@export var kinetic_radius := 160.0
|
@export var kinetic_radius := 160.0
|
||||||
@@ -33,7 +67,9 @@ const FX_RING := preload("res://scripts/fx_ring.gd")
|
|||||||
|
|
||||||
var charges := 0
|
var charges := 0
|
||||||
var hand_open := false
|
var hand_open := false
|
||||||
|
var meter := 0.0
|
||||||
var _hit_progress := 0
|
var _hit_progress := 0
|
||||||
|
var _last_ms := 0
|
||||||
|
|
||||||
# Slot order is the hand: Q = slot 1, E = slot 2.
|
# Slot order is the hand: Q = slot 1, E = slot 2.
|
||||||
var cards: Array[Dictionary] = []
|
var cards: Array[Dictionary] = []
|
||||||
@@ -44,6 +80,8 @@ func _ready() -> void:
|
|||||||
# The hand must keep processing (and receiving input) while the
|
# The hand must keep processing (and receiving input) while the
|
||||||
# world is paused for a full freeze.
|
# world is paused for a full freeze.
|
||||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
meter = meter_max
|
||||||
|
_last_ms = Time.get_ticks_msec()
|
||||||
cards = [
|
cards = [
|
||||||
{"name": "KINETIC", "hue": Color(0.75, 0.85, 0.95), "cost_of": func() -> int: return kinetic_cost, "play": _play_kinetic},
|
{"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},
|
{"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:
|
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"):
|
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 hand_open:
|
||||||
if Input.is_action_just_pressed("card_1"):
|
if Input.is_action_just_pressed("card_1"):
|
||||||
try_play(0)
|
try_play(0)
|
||||||
if Input.is_action_just_pressed("card_2"):
|
if Input.is_action_just_pressed("card_2"):
|
||||||
try_play(1)
|
try_play(1)
|
||||||
|
if meter_mode:
|
||||||
|
# Bands shift as the meter drains under an open hand.
|
||||||
|
_apply_time_state()
|
||||||
$HUD.queue_redraw()
|
$HUD.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and not event.echo:
|
if event is InputEventKey and event.pressed and not event.echo:
|
||||||
if event.physical_keycode == KEY_BRACKETLEFT:
|
match event.physical_keycode:
|
||||||
dilation_scale = maxf(0.0, dilation_scale - dilation_step)
|
KEY_BRACKETLEFT:
|
||||||
_apply_time_scale()
|
dilation_scale = maxf(0.0, dilation_scale - dilation_step)
|
||||||
elif event.physical_keycode == KEY_BRACKETRIGHT:
|
_apply_time_state()
|
||||||
dilation_scale = minf(dilation_max, dilation_scale + dilation_step)
|
KEY_BRACKETRIGHT:
|
||||||
_apply_time_scale()
|
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:
|
func on_build_hit() -> void:
|
||||||
@@ -84,6 +147,14 @@ func on_build_hit() -> void:
|
|||||||
charges = mini(charges + 1, max_charges)
|
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:
|
func try_play(slot: int) -> void:
|
||||||
var card := cards[slot]
|
var card := cards[slot]
|
||||||
var cost: int = card.cost_of.call()
|
var cost: int = card.cost_of.call()
|
||||||
@@ -95,21 +166,47 @@ func try_play(slot: int) -> void:
|
|||||||
|
|
||||||
func _set_open(open: bool) -> void:
|
func _set_open(open: bool) -> void:
|
||||||
hand_open = open
|
hand_open = open
|
||||||
$Dim.visible = open
|
_apply_time_state()
|
||||||
_apply_time_scale()
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_time_scale() -> void:
|
func _apply_time_state() -> void:
|
||||||
# Knob 0 = a REAL pause: Engine.time_scale = 0 still steps physics
|
var tree := get_tree()
|
||||||
# with delta 0 on 4.7 (probed headless — delta-independent logic
|
if not hand_open:
|
||||||
# like heat thresholds kept firing "frozen"). SceneTree.paused
|
tree.paused = false
|
||||||
# stops physics outright; time_scale covers dilation > 0.
|
|
||||||
if hand_open and dilation_scale == 0.0:
|
|
||||||
get_tree().paused = true
|
|
||||||
Engine.time_scale = 1.0
|
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:
|
else:
|
||||||
get_tree().paused = false
|
if dilation_scale == 0.0:
|
||||||
Engine.time_scale = dilation_scale if hand_open else 1.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:
|
func _mech() -> Node2D:
|
||||||
|
|||||||
+22
-1
@@ -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)
|
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)
|
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
|
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))
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 8, Color(0.6, 0.65, 0.7, 0.7))
|
||||||
|
|||||||
+57
-5
@@ -103,11 +103,10 @@ func _test_hand(arena: Node, mech: CharacterBody2D) -> void:
|
|||||||
if not hand.hand_open:
|
if not hand.hand_open:
|
||||||
_failures.append("hand did not open on hand_toggle")
|
_failures.append("hand did not open on hand_toggle")
|
||||||
return
|
return
|
||||||
if hand.dilation_scale == 0.0:
|
var expect_pause: bool = (hand.meter_mode and hand.frac() >= hand.band_thaw) \
|
||||||
if not get_tree().paused:
|
or (not hand.meter_mode and hand.dilation_scale == 0.0)
|
||||||
_failures.append("hand open at knob 0 did not pause the world")
|
if expect_pause and not get_tree().paused:
|
||||||
elif absf(Engine.time_scale - hand.dilation_scale) > 0.001:
|
_failures.append("hand open in freeze state did not pause the world")
|
||||||
_failures.append("time_scale %.3f != dilation knob %.3f with hand open" % [Engine.time_scale, hand.dilation_scale])
|
|
||||||
|
|
||||||
# Pick the target NOW — the world is frozen, so it can neither die
|
# Pick the target NOW — the world is frozen, so it can neither die
|
||||||
# nor wander out of the heat radius before the casts land.
|
# 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):
|
if is_instance_valid(target):
|
||||||
_failures.append("heated grunt failed to detonate after release (heat %.1f)" % target.heat)
|
_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:
|
func _nearest_grunt(mech: CharacterBody2D) -> Node2D:
|
||||||
var best: Node2D = null
|
var best: Node2D = null
|
||||||
|
|||||||
Reference in New Issue
Block a user