Files
urm03/scripts/hand.gd
T
vh 1976149ce5 feat: KINETIC+HEAT chemistry row + self-heating burster — greybox build complete
Chemistry row *Spread* (canon CONCEPT.md § tag grammar): a heated
enemy slammed into another (knockback impact >= threshold, not a
walking bump) spreads its heat on contact and passes momentum, so
chains cascade with decaying force — chemistry feeding chemistry.
Authorship-loud feedback stack per canon: ~80ms hitstop (owned by the
hand, capped so chain procs can't lock the world; rides on top of
freeze/thaw states), the two tag hues meeting at the point of impact
(oriented fx, no proc text), and a per-pair audio sting (procedural
placeholder, _ph-marked per asset policy).

Burster: second enemy type, HEAT-tagged walking bomb (orange base,
literal tag mirror) — self-heats ~0.5/s toward threshold, big
detonation. Every 4th spawn. Detonations are now symmetric: they
damage enemies AND the mech in radius (one grammar on both sides;
heat play prices standing close). Grunt scene exports drive both
enemy types — no subclass.

Smoke gate extended: burster self-heat observed (retry-tolerant),
deterministic staged chemistry proc (heated striker slammed into cold
neighbor outside weapon range -> proc counter, heat spread). 3/3
green on nh3-dev headless 4.7.1. Known cosmetic: 2 ObjectDB instances
reported leaked at exit (constant, exit-order artifact — audio stream
alive at quit), not a runtime leak.
2026-08-07 11:47:21 -07:00

293 lines
9.3 KiB
GDScript

extends CanvasLayer
## 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 stops or dilates time
## (flow -> freeze -> think -> release), charges are one universal
## currency.
##
## 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
## 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
## Chemistry-proc hitstop length in real seconds (the hand owns time,
## so it owns the stop; capped so chain procs can't lock the world).
@export var hitstop_s := 0.08
@export_group("KINETIC card")
@export var kinetic_cost := 1
@export var kinetic_radius := 160.0
@export var kinetic_impulse := 480.0
@export var kinetic_damage := 0.5
@export_group("HEAT card")
@export var heat_cost := 1
@export var heat_radius := 140.0
@export var heat_amount := 2.0
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
var _release_from := 1.0
var _release_t := -1.0
var _hitstop_t := 0.0
# Slot order is the hand: Q = slot 1, E = slot 2.
var cards: Array[Dictionary] = []
func _ready() -> void:
add_to_group("hand")
# 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},
]
func _exit_tree() -> void:
# Scene reload while frozen must never leave the world stopped.
Engine.time_scale = 1.0
get_tree().paused = false
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 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()
# 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
# Hitstop rides on top of whatever time state is current (it can
# only slow, never speed up); expiry re-derives the proper state.
if _hitstop_t > 0.0:
_hitstop_t -= real_dt
if not get_tree().paused:
Engine.time_scale = minf(Engine.time_scale, 0.05)
if _hitstop_t <= 0.0:
_apply_time_state()
$HUD.queue_redraw()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
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:
_hit_progress += 1
if _hit_progress >= hits_per_charge:
_hit_progress = 0
charges = mini(charges + 1, max_charges)
func hitstop(dur := -1.0) -> void:
_hitstop_t = minf(maxf(_hitstop_t, dur if dur > 0.0 else hitstop_s), 0.15)
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()
if charges < cost:
return
charges -= cost
card.play.call()
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()
func _apply_time_state() -> void:
var tree := get_tree()
if not hand_open:
tree.paused = false
Engine.time_scale = _release_from if _release_t >= 0.0 else 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 := 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:
tree.paused = false
Engine.time_scale = 1.0
_set_dim(0.0)
else:
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:
return get_tree().get_first_node_in_group("mech") as Node2D
func _play_kinetic() -> void:
# Shockwave: radial shove from M03, falling off to the rim. No aim —
# where you're standing IS the targeting (positioning <-> build bet).
var mech := _mech()
if mech == null:
return
for e in get_tree().get_nodes_in_group("enemies"):
var offset: Vector2 = e.global_position - mech.global_position
var dist := offset.length()
if dist > kinetic_radius:
continue
var falloff := 1.0 - dist / kinetic_radius * 0.6
e.hit(kinetic_damage, offset.normalized() * kinetic_impulse * falloff)
_ring(mech.global_position, kinetic_radius, Color(0.75, 0.85, 0.95))
func _play_heat() -> void:
# Heat wave: paints heat on everything near M03. Heat is a status —
# it detonates at threshold (grunt-side), and next slice's
# KINETIC+HEAT chemistry row reads the same state.
var mech := _mech()
if mech == null:
return
for e in get_tree().get_nodes_in_group("enemies"):
if e.global_position.distance_to(mech.global_position) <= heat_radius:
e.add_heat(heat_amount)
_ring(mech.global_position, heat_radius, Color(0.95, 0.55, 0.2))
func _ring(at: Vector2, radius: float, color: Color) -> void:
var ring: Node2D = FX_RING.new()
ring.position = at
ring.max_radius = radius
ring.color = color
_mech().get_parent().add_child(ring)