extends Node class_name Rage ## The tilt meter — the thing the whole game now hangs off. ## ## You are at work. Work is provoking. The meter fills. When it fills all the way you ## SNAP, and for the next half minute you are not allowed to be a functioning employee: ## you have to wreck things until it drains. Then you go back to your desk and try again. ## ## THE SCORING TENSION (this is the design, not an implementation detail): ## rage drains a FIXED amount per thing destroyed, but the damages bill is that thing's ## value. So during a meltdown the optimal play is to smash a lot of cheap rubbish very ## fast — cartons, paperwork, monitors — rather than putting the sledgehammer through a ## filing cabinet. You are forced to lose your temper; you get to choose how expensive ## losing your temper is. ## ## States: ## WORKING ..... doing tasks. Provocations raise the meter, clean work lowers it. ## MELTDOWN .... forced destruction window. Destroying drains. Standing still does not ## — the timer stalls if you aren't actually breaking anything, because ## "you MUST destroy shit when you are falling down". ## COOLDOWN .... a couple of seconds of shaking hands before work resumes. ## ## Godot 4.7 GDScript 2.0. enum State { WORKING, MELTDOWN, COOLDOWN } ## Seconds a meltdown lasts if you never stop swinging. @export var meltdown_time: float = 32.0 ## Seconds of trembling before you're expected to be productive again. @export var cooldown_time: float = 2.5 ## Fraction of the meter one destroyed object drains, regardless of what it was worth. @export var drain_per_smash: float = 0.075 ## Meter gained per second just from existing in an office. @export var ambient_provoke: float = 0.008 ## Meter lost per second while a task is going cleanly. @export var focus_soothe: float = 0.010 ## If you go this long during a meltdown without breaking anything, the clock stalls. @export var idle_stall_after: float = 2.0 var state: int = State.WORKING var value: float = 0.0 ## 0..1 var meltdown_left: float = 0.0 var last_reason: String = "" var _phase: float = 0.0 # heartbeat phase, 0..1, drives the throb var _since_smash: float = 0.0 var _cooldown_left: float = 0.0 var _stalled: bool = false signal snapped() ## the meter hit 1.0 — meltdown begins signal calmed() ## back to work signal provoked(amount: float, reason: String) # ---------------------------------------------------------------- inputs ## Something at work just happened to you. func provoke(amount: float, reason: String) -> void: if state != State.WORKING: return value = clampf(value + amount, 0.0, 1.0) last_reason = reason provoked.emit(amount, reason) if value >= 1.0: _snap() ## A task went right. Small relief — never enough to keep up on its own. func soothe(amount: float) -> void: if state != State.WORKING: return value = clampf(value - amount, 0.0, 1.0) ## Main calls this for every object destroyed, in any state. func on_smashed() -> void: _since_smash = 0.0 _stalled = false if state == State.MELTDOWN: value = clampf(value - drain_per_smash, 0.0, 1.0) if value <= 0.0: _calm() func _snap() -> void: state = State.MELTDOWN value = 1.0 meltdown_left = meltdown_time _since_smash = 0.0 _stalled = false snapped.emit() func _calm() -> void: state = State.COOLDOWN _cooldown_left = cooldown_time value = clampf(value, 0.0, 0.25) meltdown_left = 0.0 calmed.emit() # ---------------------------------------------------------------- per-frame func _process(dt: float) -> void: # heartbeat runs in every state; it's the player's read on how close they are _phase = fposmod(_phase + dt * bpm() / 60.0, 1.0) match state: State.WORKING: value = clampf(value + ambient_provoke * dt, 0.0, 1.0) if value >= 1.0: _snap() State.MELTDOWN: _since_smash += dt # the clock only runs while you're actually wrecking something _stalled = _since_smash > idle_stall_after if not _stalled: meltdown_left -= dt if meltdown_left <= 0.0: _calm() State.COOLDOWN: _cooldown_left -= dt value = maxf(0.0, value - dt * 0.10) if _cooldown_left <= 0.0: state = State.WORKING # ---------------------------------------------------------------- reads ## Resting 62 bpm, 168 at the edge — the audible version of the meter. func bpm() -> float: if state == State.MELTDOWN: return 172.0 return lerpf(62.0, 168.0, value * value) ## 0..1 within the current heartbeat. Two thumps per cycle (lub-dub). func pulse() -> float: var p := _phase var lub := exp(-pow((p - 0.02) / 0.055, 2.0)) var dub := exp(-pow((p - 0.20) / 0.070, 2.0)) * 0.6 return clampf(lub + dub, 0.0, 1.0) func is_meltdown() -> bool: return state == State.MELTDOWN func is_stalled() -> bool: return state == State.MELTDOWN and _stalled func state_name() -> String: match state: State.MELTDOWN: return "MELTDOWN" State.COOLDOWN: return "COOLDOWN" return "WORKING" ## Short line for the HUD, in the voice of the game. func status_line() -> String: match state: State.MELTDOWN: if _stalled: return "BREAK SOMETHING" return "%02ds" % int(ceil(meltdown_left)) State.COOLDOWN: return "…breathe…" if value > 0.86: return "you are about to do something" if value > 0.62: return "unclench your jaw" if value > 0.34: return "it's fine. it's fine." return ""