Game feel

composing shake, hitstop, punch, flash and particles

▶ Run in browser

Tags: 2d juice camera coroutines particles

A target sits mid-screen. Space, a click or a tap hits it, and each hit stacks five “juice” ingredients. The buttons down the left switch each one on or off (keys 1 to 5 do the same), so its own contribution can be felt in isolation.

  • Camera shake, Camera2D.shake(intensity, duration): jolting the whole view sells the impact’s energy without moving anything in the scene.

  • Hitstop, App.time_scale: dropping time to a crawl for about a tenth of a second makes the frame of contact linger, which reads as weight. Every dt the tree hands out is scaled, so the freeze is timed on the wall clock (time.perf_counter); on the scaled clock it would last twenty times longer.

  • Punch, the coroutines punch_position and punch_rotation: a damped-sine recoil on the target says the hit landed on this object, not on the camera. Stopping a punch coroutine snaps the target back to where it started, so a new hit stops the old punch first and never accumulates drift.

  • Flash: a white overlay that fades out marks the instant of contact. It hangs off a CanvasLayer, so it covers the window however hard the camera shakes.

  • Particles: sparks carry the energy outward and leave an aftermath. One GPUParticles2D built as a one-shot ring burst (one_shot=True, explosiveness=1.0, emitting=False until the first hit) fires again on every hit through restart().

The flash and the sparks run on the scaled dt, so both all but stop during the hitstop: the ingredients compose without knowing about each other.

The toggles are action buttons: Button(action="shake", text="Shake") shows the action’s key and presses it, so a click, a tap and the key all reach the same Input.just_pressed("shake"). A button’s checked is whether its ingredient is on; nothing else keeps that state.

Source

  1# /// simvx
  2# tags = ["2d", "juice", "camera", "coroutines", "particles"]
  3# web = { width = 960, height = 540, responsive = true }
  4# ///
  5"""Game feel: composing shake, hitstop, punch, flash and particles
  6
  7A target sits mid-screen. Space, a click or a tap hits it, and each hit stacks
  8five "juice" ingredients. The buttons down the left switch each one on or off
  9(keys 1 to 5 do the same), so its own contribution can be felt in isolation.
 10
 11- **Camera shake**, `Camera2D.shake(intensity, duration)`: jolting the whole
 12  view sells the impact's energy without moving anything in the scene.
 13- **Hitstop**, `App.time_scale`: dropping time to a crawl for about a tenth of
 14  a second makes the frame of contact linger, which reads as weight. Every
 15  `dt` the tree hands out is scaled, so the freeze is timed on the wall clock
 16  (`time.perf_counter`); on the scaled clock it would last twenty times longer.
 17- **Punch**, the coroutines `punch_position` and `punch_rotation`: a damped-sine
 18  recoil on the target says the hit landed on this object, not on the camera.
 19  Stopping a punch coroutine snaps the target back to where it started, so a
 20  new hit stops the old punch first and never accumulates drift.
 21- **Flash**: a white overlay that fades out marks the instant of contact. It
 22  hangs off a `CanvasLayer`, so it covers the window however hard the camera
 23  shakes.
 24- **Particles**: sparks carry the energy outward and leave an aftermath. One
 25  `GPUParticles2D` built as a one-shot ring burst (`one_shot=True`,
 26  `explosiveness=1.0`, `emitting=False` until the first hit) fires again on
 27  every hit through `restart()`.
 28
 29The flash and the sparks run on the scaled `dt`, so both all but stop during
 30the hitstop: the ingredients compose without knowing about each other.
 31
 32The toggles are action buttons: `Button(action="shake", text="Shake")` shows
 33the action's key and presses it, so a click, a tap and the key all reach the
 34same `Input.just_pressed("shake")`. A button's `checked` is whether its
 35ingredient is on; nothing else keeps that state.
 36"""
 37
 38import time
 39
 40from simvx.core import (
 41    Anchor,
 42    Button,
 43    Camera2D,
 44    CanvasLayer,
 45    GPUParticles2D,
 46    Input,
 47    Key,
 48    Label,
 49    Node,
 50    Node2D,
 51    Pointer,
 52    Property,
 53    VBoxContainer,
 54    Vec2,
 55    punch_position,
 56    punch_rotation,
 57)
 58from simvx.graphics import App
 59
 60INGREDIENTS = ("shake", "hitstop", "punch", "flash", "particles")
 61HITSTOP_SCALE = 0.05  # time scale during the freeze
 62HITSTOP_SECONDS = 0.12  # wall-clock length of the freeze
 63FLASH_DECAY = 6.0  # flash strength lost per scaled second
 64
 65
 66class Target(Node2D):
 67    """A square board with a ring target painted on it; the square lets the rotation punch show."""
 68
 69    def on_draw(self, renderer):
 70        renderer.rect((-54, -54), (108, 108), fill=(0.35, 0.26, 0.2, 1.0))
 71        for radius, colour in ((48, (0.82, 0.29, 0.29)), (36, (0.93, 0.91, 0.87)), (23, (0.82, 0.29, 0.29))):
 72            renderer.circle((0, 0), radius, fill=(*colour, 1.0))
 73        renderer.circle((0, 0), 11, fill=(0.93, 0.91, 0.87, 1.0))
 74
 75
 76class ScreenFlash(Node):
 77    """A white overlay that fires on impact and fades on the scaled clock."""
 78
 79    dynamic = True
 80    strength = Property(0.0, range=(0.0, 1.0), hint="Overlay alpha right after a hit, fading to 0")
 81
 82    def on_update(self, dt):
 83        self.strength -= FLASH_DECAY * dt
 84
 85    def on_draw(self, renderer):
 86        if self.strength > 0.0:
 87            renderer.rect((0, 0), self.viewport_size, fill=(1.0, 1.0, 1.0, 0.55 * self.strength))
 88
 89
 90class JuiceDemo(Node2D):
 91    dynamic = True  # the dot grid follows the window size
 92    input_context = {
 93        "hit": [Key.SPACE, Pointer.PRIMARY],
 94        "shake": [Key.KEY_1],
 95        "hitstop": [Key.KEY_2],
 96        "punch": [Key.KEY_3],
 97        "flash": [Key.KEY_4],
 98        "particles": [Key.KEY_5],
 99    }
100
101    def on_ready(self):
102        self.hits = 0
103        self._thaw_at = 0.0
104        self._punches = []
105
106        self.camera = self.add_child(Camera2D())  # the world origin sits at the screen centre
107        self.target = self.add_child(Target())
108        self.sparks = self.add_child(
109            GPUParticles2D(
110                amount=48,
111                lifetime=0.6,
112                one_shot=True,
113                explosiveness=1.0,
114                emitting=False,
115                speed=380.0,
116                speed_variance=140.0,
117                spread_pattern="ring",
118                gravity=(0.0, 640.0, 0.0),
119                damping=2.0,
120                start_scale=10.0,
121                end_scale=2.0,
122                start_colour=(1.0, 0.78, 0.32, 1.0),
123                end_colour=(1.0, 0.5, 0.2, 0.0),
124            )
125        )
126
127        overlay = self.add_child(CanvasLayer())
128        self.flash = overlay.add_child(ScreenFlash())
129        toggles = overlay.add_child(VBoxContainer(anchor=Anchor.TOP_LEFT, inset=16))
130        self.toggles = {
131            name: toggles.add_child(Button(action=name, text=name.capitalize(), checked=True)) for name in INGREDIENTS
132        }
133        self.status = overlay.add_child(Label(anchor=Anchor.BOTTOM_LEFT, inset=16))
134
135    def hit(self):
136        self.hits += 1
137        if self.toggles["shake"].checked:
138            self.camera.shake(10.0, 0.3)
139        if self.toggles["hitstop"].checked:
140            self.app.time_scale = HITSTOP_SCALE
141            self._thaw_at = time.perf_counter() + HITSTOP_SECONDS
142        if self.toggles["punch"].checked:
143            for punch in self._punches:
144                self.stop_coroutine(punch)
145            self._punches = [
146                self.start_coroutine(punch_position(self.target, Vec2(16, 10), 0.3)),
147                self.start_coroutine(punch_rotation(self.target, 0.18, 0.35)),
148            ]
149        if self.toggles["flash"].checked:
150            self.flash.strength = 1.0
151        if self.toggles["particles"].checked:
152            self.sparks.restart()
153
154    def on_update(self, dt):
155        if self.app.time_scale < 1.0 and time.perf_counter() >= self._thaw_at:
156            self.app.time_scale = 1.0
157        if Input.just_pressed("hit"):
158            self.hit()
159        for name, button in self.toggles.items():
160            if Input.just_pressed(name):
161                button.checked = not button.checked
162        self.status.text = (
163            f"Space, click or tap: hit the target\nhits {self.hits}   time scale {self.app.time_scale:.2f}"
164        )
165
166    def on_draw(self, renderer):
167        # A dot grid in world space gives the camera shake something to read against.
168        cols, rows = (int(n) for n in self.viewport_size // 160)
169        for x in range(-cols, cols + 1):
170            for y in range(-rows, rows + 1):
171                renderer.circle((x * 80, y * 80), 2, fill=(0.28, 0.28, 0.34, 1.0))
172
173
174if __name__ == "__main__":
175    App(title="Game Feel: Juice", width=960, height=540).run(JuiceDemo())