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 kills_per_charge := 2 @export var max_charges := 5 @export_group("Salvage / card levels") ## Cumulative collected scrap gates card levels (canon 2026-08-07: ## same-currency rule — this is the run-timescale reading of the same ## scrap the meta will bank; it is NOT a separate XP). @export var level2_salvage := 12 @export var level3_salvage := 30 ## Per-level scaling, index = level - 1. Radius grows reach; power ## scales impulse/heat (HEAT at L3 reaches one-cast threshold). @export var level_radius_scale: Array[float] = [1.0, 1.25, 1.5] @export var level_power_scale: Array[float] = [1.0, 1.15, 1.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 cards") @export var kinetic_radius := 160.0 @export var kinetic_impulse := 480.0 @export var kinetic_damage := 0.5 @export var dash_impulse := 420.0 @export_group("HEAT cards") @export var heat_radius := 140.0 @export var heat_amount := 2.0 @export var ignite_range := 200.0 @export var ignite_heat := 3.5 @export_group("Belt") ## Starting belt as CARD_TYPES indices; cards are consumed on play, ## the next slides into the hand (canon: charges are the cost, the ## belt is the ammo). Drafting between rooms refills it. @export var belt_start: Array[int] = [0, 1, 0, 1] @export var belt_cap := 8 const FX_RING := preload("res://scripts/fx_ring.gd") ## The greybox card pool. Content prototypes inside ratified card-system ## canon — flag DASH/IGNITE for the canon card table when they earn it. const CARD_TYPES: Array[Dictionary] = [ {"name": "SHOCKWAVE", "glyph": "S", "hue": Color(0.75, 0.85, 0.95), "cost": 1, "method": "_play_kinetic"}, {"name": "HEATWAVE", "glyph": "H", "hue": Color(0.95, 0.55, 0.2), "cost": 1, "method": "_play_heat"}, {"name": "DASH", "glyph": "D", "hue": Color(0.85, 0.92, 1.0), "cost": 1, "method": "_play_dash"}, {"name": "IGNITE", "glyph": "I", "hue": Color(1.0, 0.45, 0.15), "cost": 1, "method": "_play_ignite"}, ] var charges := 0 var hand_open := false var meter := 0.0 var salvage_total := 0 # The belt: CARD_TYPES indices; belt[0] and belt[1] are the hand # (Q = slot 1, E = slot 2). Playing consumes the entry. var belt: Array[int] = [] var _kill_progress := 0 var _last_ms := 0 var _release_from := 1.0 var _release_t := -1.0 var _hitstop_t := 0.0 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() belt = belt_start.duplicate() 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) # While the interstitial owns the screen (and the pause), the hand # does not fight it for input or time authority. if not _interstitial_active(): 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_kill() -> void: # Charges generate on kills — build-generated (the build does the # killing), and chemistry-chain kills feed the hand (canon 2026-08-07). _kill_progress += 1 if _kill_progress >= kills_per_charge: _kill_progress = 0 charges = mini(charges + 1, max_charges) func add_salvage(value: int) -> void: salvage_total += value func card_level() -> int: return 1 + int(salvage_total >= level2_salvage) + int(salvage_total >= level3_salvage) 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: if slot >= belt.size(): return var card := CARD_TYPES[belt[slot]] var cost: int = card.cost if charges < cost: return charges -= cost belt.remove_at(slot) call(card.method) func belt_append(card_id: int) -> void: if belt.size() < belt_cap: belt.append(card_id) func force_close() -> void: if hand_open: _set_open(false) func _interstitial_active() -> bool: var i := get_tree().get_first_node_in_group("interstitial") return i != null and i.active 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: if _interstitial_active(): return 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 var lvl := card_level() var radius: float = kinetic_radius * level_radius_scale[lvl - 1] var impulse: float = kinetic_impulse * level_power_scale[lvl - 1] 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 > radius: continue var falloff := 1.0 - dist / radius * 0.6 e.hit(kinetic_damage, offset.normalized() * impulse * falloff) _ring(mech.global_position, 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 var lvl := card_level() var radius: float = heat_radius * level_radius_scale[lvl - 1] var amount: float = heat_amount * level_power_scale[lvl - 1] for e in get_tree().get_nodes_in_group("enemies"): if e.global_position.distance_to(mech.global_position) <= radius: e.add_heat(amount) _ring(mech.global_position, radius, Color(0.95, 0.55, 0.2)) func _play_dash() -> void: # Dash: impulse to M03 along the HELD MOVEMENT DIRECTION — no aim, # and direction can be set while frozen (movement keys read during # pause). Mobility verb; positioning stays the whole control surface. var mech := _mech() if mech == null: return var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") if dir == Vector2.ZERO: dir = mech.velocity.normalized() if mech.velocity.length() > 1.0 else Vector2.UP mech.impulse(dir * dash_impulse) _ring(mech.global_position, 30.0, Color(0.85, 0.92, 1.0)) func _play_ignite() -> void: # Ignite: the nearest enemy in range takes a heat spike past the # grunt threshold — a targeted pop, no aim (nearest = positioning). var mech := _mech() if mech == null: return var lvl := card_level() var best: Node2D = null var best_d := ignite_range * ignite_range for e in get_tree().get_nodes_in_group("enemies"): var d: float = mech.global_position.distance_squared_to(e.global_position) if d < best_d: best_d = d best = e if best == null: return best.add_heat(ignite_heat * level_power_scale[lvl - 1]) var ring: Node2D = FX_RING.new() ring.position = best.global_position ring.max_radius = 18.0 ring.color = Color(1.0, 0.45, 0.15) _mech().get_parent().add_child(ring) 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)