Game feel¶
composing shake, hitstop, punch, flash and particles
▶ Run in browserTags: 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. Everydtthe 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_positionandpunch_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
GPUParticles2Dbuilt as a one-shot ring burst (one_shot=True,explosiveness=1.0,emitting=Falseuntil the first hit) fires again on every hit throughrestart().
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())