fc4fc59a2b
Belt (canon: charges are the cost, the belt is the ammo): cards are CARD_TYPES indices consumed on play, next slides into the 2-slot hand; belt cap 8; HUD shows hand glyphs + upcoming-card ticks. Card pool grows to 4: SHOCKWAVE, HEATWAVE, DASH (impulse along held movement direction — aimable while frozen, no aim input), IGNITE (nearest-enemy heat spike past threshold). Rooms: spawn-budget waves (base 8 +4/room, 4 rooms/run); clear -> interstitial fork (CARD vs SCRAP +8) -> CARD opens a 3-option draft + scrap decline (+4) -> next room; final clear or mech destruction -> tally overlay (rooms/kills/scrap/chem) with relaunch on 1. Interstitial owns the pause while active; the hand yields input and time authority to it. Mech death now signals the run manager instead of hard-reloading. Chain semantics (same-tag surge / cross-tag chemistry trigger) deliberately deferred to M3. Smoke gate: belt consumption white-boxed in the card phase, _until moved to process_frame (physics awaits deadlock under pause), run phase covers clear->fork->draft->belt growth->room advance->tally. 3/3 green on nh3-dev headless 4.7.1.
396 lines
13 KiB
GDScript
396 lines
13 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 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)
|