Every time I add a new attack or ability I end up writing the same three things: a brief Engine.time_scale dip for hit stop, a screen flash overlay, and a camera shake impulse. Been doing this per-mechanic for months. Finally sat down and consolidated it into a single autoload.
The API is just GameFeel.trigger(cfg) where cfg is a dictionary with the relevant keys. Hit stop duration, flash color and alpha, impulse strength and duration. Call it from anywhere and all three run concurrently.
extends Node
func trigger(cfg: Dictionary) -> void:
if cfg.get("hit_stop", 0.0) > 0.0:
_do_hit_stop(cfg["hit_stop"])
if cfg.get("flash_color"):
_do_flash(cfg["flash_color"], cfg.get("flash_duration", 0.08))
if cfg.get("impulse_strength", 0.0) > 0.0:
_do_camera_impulse(cfg["impulse_strength"], cfg.get("impulse_duration", 0.12))
func _do_hit_stop(duration: float) -> void:
Engine.time_scale = 0.05
await get_tree().create_timer(duration, true, false, true).timeout
Engine.time_scale = 1.0
func _do_flash(color: Color, duration: float) -> void:
var flash := get_tree().root.find_child("FlashRect", true, false) as ColorRect
if not flash:
return
flash.color = color
var tween := create_tween()
tween.tween_property(flash, "color:a", 0.0, duration)
func _do_camera_impulse(strength: float, duration: float) -> void:
var cam := get_viewport().get_camera_2d()
if not cam:
return
var dir := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)).normalized()
var tween := create_tween()
tween.tween_property(cam, "offset", dir * strength, duration * 0.3)
tween.tween_property(cam, "offset", Vector2.ZERO, duration * 0.7)
The gotcha with hit stop: create_timer's fourth argument is ignore_time_scale. Pass true there, otherwise your timer also slows down and the hit stop runs way longer than intended. Wasted an embarrassing amount of time on that before I actually read the docs.
The flash currently relies on finding a ColorRect named FlashRect in a persistent CanvasLayer somewhere in your scene tree. It works but it's fragile. I'm probably switching to a shader-based overlay on the autoload itself so I don't need that external dependency.
Camera impulse is still naive. One random direction, smooth return tween. No trauma system or perlin noise decay for sustained effects. Fine for individual hits, feels wrong for explosions or getting hit repeatedly. Planning to add a trauma value that accumulates on each trigger and decays over time so rapid hits stack properly instead of just retriggering the same jerk.
Anyone doing something similar? Particularly curious if you're using a Resource-based config so designers can tweak per-ability feel values in the inspector without touching code.