Skip to content

Commit f195bb7

Browse files
Escape opens a pause menu
Resume, sound, CRT, abandon the run, quit -- drawn over whatever is on screen, with the game dimmed but still visible underneath, because that is what you paused to look at. It is a flag rather than a state. A state would have thrown away the combat, the map or the shop underneath, and the point of pausing mid-fight is that the fight is still there afterwards. That also makes it the easiest possible place to leave an update path running by accident, so the test compares every bullet's position across 120 paused frames rather than just checking that the menu opened. On a pad, Start is no longer a second confirm button: it opens and closes the pause menu the way Start does everywhere else. The title screen has nothing to pause, so there it stays a confirm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 5f90fba commit f195bb7

5 files changed

Lines changed: 184 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ de riposter. La carte, elle, est un vrai choix — un atelier de réparation n'e
4343
proposé que dans 54 % des secteurs, sur une seule branche, jamais avant le boss.
4444
Zoom toujours entier, arène de taille fixe quelle que soit la résolution, et
4545
20 effets sonores synthétisés au démarrage (aucun fichier audio) —
46-
[à écouter](docs/nova-sound-demo.wav). Le tir étant automatique, `M` fait le
46+
[à écouter](docs/nova-sound-demo.wav). `Échap` met en pause. Le tir étant automatique, `M` fait le
4747
tour de trois réglages — tout, canon coupé, silence — parce que vouloir se
4848
passer du canon n'est pas vouloir se passer du son. Tout est dans
4949
[pc/README.md](pc/README.md).

‎pc/README.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ construit sur Windows.
4949
| `--no-crt` | Coupe les scanlines |
5050
| `--no-sound` | Démarre en muet |
5151

52-
En jeu : `F11` plein écran · `F1` filtre CRT · `M` son (3 crans) ·
53-
`+`/`-` taille · la fenêtre est redimensionnable à la souris.
52+
En jeu : `Échap` pause · `F11` plein écran · `F1` filtre CRT · `M` son
53+
(3 crans) · `+`/`-` taille · la fenêtre est redimensionnable à la souris.
5454

5555
## Résolutions
5656

@@ -177,6 +177,24 @@ plutôt que de planter.
177177
| Tirer | automatique | automatique |
178178
| Bombe | `Espace` ou `Maj` | partagée |
179179
| Menus | flèches + `Entrée` | |
180+
| Pause | `Échap`, ou `Start` à la manette | |
181+
182+
### La pause
183+
184+
`Échap` ouvre un menu par-dessus ce qui est à l'écran : reprendre, le son, le
185+
filtre CRT, abandonner la partie, quitter. Le combat reste **visible sous le
186+
voile**, parce qu'on met en pause justement pour le regarder.
187+
188+
C'est un drapeau, pas un état de la machine à états. Un état aurait jeté le
189+
combat, la carte ou la boutique en dessous, et tout l'intérêt de mettre en
190+
pause au milieu d'un combat est que le combat soit encore là au retour. En
191+
revanche c'est aussi la façon la plus facile de laisser tourner une boucle de
192+
mise à jour par mégarde, alors le test compare les positions de chaque
193+
projectile avant et après cent vingt images de pause.
194+
195+
À la manette, `Start` n'est plus un second bouton de validation : il ouvre et
196+
ferme la pause, comme partout ailleurs — sauf sur l'écran-titre, où il n'y a
197+
rien à mettre en pause et où il valide.
180198

181199
### Manette
182200

‎pc/nova/game.py‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def __init__(self, scale=3, fullscreen=False, crt=True, sound=True):
5454
self.difficulty = 1
5555
self.players = 1
5656
self.toast = None
57+
self.paused = False
58+
self.pause_menu = None
5759
self.enter_title()
5860
self.audio.music(0, 0)
5961

@@ -127,6 +129,75 @@ def enter_title(self):
127129
"Leave the void alone."],
128130
[data.CYAN, data.VIOLET, data.ORANGE, data.GREY])
129131

132+
# Pause is a flag rather than a state.
133+
#
134+
# Every other screen is a state, but pause is the one thing that has to
135+
# happen *on top of* whatever is already there: a state would throw away
136+
# the combat, the map or the shop underneath, and the whole point of
137+
# pausing mid-fight is that the fight is still there when you come back.
138+
def toggle_pause(self):
139+
if self.state in (TITLE, GAMEOVER):
140+
return # nothing to come back to
141+
self.paused = not self.paused
142+
if self.paused:
143+
self.build_pause_menu()
144+
self.audio.play("menu_ok", 0.6)
145+
else:
146+
self.audio.play("menu_move")
147+
148+
def build_pause_menu(self):
149+
crt = "ON" if self.crt_on else "OFF"
150+
self.pause_menu = ui.Menu(
151+
["RESUME", "SOUND " + self.audio.mode_name, "CRT " + crt,
152+
"ABANDON RUN", "QUIT"],
153+
["Back to it.",
154+
"M does this too, mid-fight.",
155+
"Scanlines. F1 does this too.",
156+
"Give up this run and go back to the title.",
157+
"Close the game."],
158+
[data.CYAN, data.WHITE, data.WHITE, data.ORANGE, data.GREY])
159+
160+
def pause_key(self, e):
161+
before = self.pause_menu.index
162+
choice = self.pause_menu.key(e)
163+
if self.pause_menu.index != before:
164+
self.audio.play("menu_move")
165+
if choice is None:
166+
return
167+
if choice == 0:
168+
self.toggle_pause()
169+
elif choice == 1:
170+
self.toast = [self.audio.cycle_mute(), 1.6]
171+
if not self.audio.muted:
172+
self.audio.music(self.run.sector if self.run else 0,
173+
self.run.seed if self.run else 0)
174+
self.build_pause_menu()
175+
elif choice == 2:
176+
self.crt_on = not self.crt_on
177+
self.build_pause_menu()
178+
elif choice == 3:
179+
self.paused = False
180+
self.run = None
181+
self.combat = None
182+
self.audio.play("menu_no")
183+
self.enter_title()
184+
self.audio.music(0, 0)
185+
elif choice == 4:
186+
self.running = False
187+
188+
def draw_pause(self, c):
189+
"""Dim what is underneath, then the panel. The game stays visible on
190+
purpose: you paused to look at it."""
191+
w, h = c.get_size()
192+
veil = pygame.Surface((w, h), pygame.SRCALPHA)
193+
veil.fill((0, 0, 0, 170))
194+
c.blit(veil, (0, 0))
195+
ui.text(c, self.art, "PAUSED", w // 2, data.TOP + 18, data.CYAN,
196+
centre=True, big=True)
197+
self.pause_menu.draw(c, self.art, data.TOP + 54, self.t)
198+
ui.text(c, self.art, "ESC or START to resume", w // 2, h - 16,
199+
data.DARK, centre=True)
200+
130201
def start_run(self, players):
131202
self.players = players
132203
self.run = Run(players, self.difficulty)
@@ -339,6 +410,14 @@ def handle_event(self, e):
339410
return
340411
if e.type != pygame.KEYDOWN:
341412
return
413+
if e.key == pygame.K_ESCAPE:
414+
self.toggle_pause()
415+
return
416+
if self.paused:
417+
# Nothing else gets a look in: F11 and the rest would otherwise
418+
# fire straight through the panel.
419+
self.pause_key(e)
420+
return
342421
if e.key == pygame.K_F11 or (e.key == pygame.K_RETURN and
343422
(e.mod & pygame.KMOD_ALT)):
344423
self.toggle_fullscreen()
@@ -440,12 +519,28 @@ def pad_menu(self):
440519
"""Turn pad presses into the key events the menus already understand,
441520
so there is exactly one place that knows what a menu does."""
442521
for edge in self.pads.menu_edges():
443-
key = {"up": pygame.K_UP, "down": pygame.K_DOWN,
444-
"confirm": pygame.K_RETURN, "back": pygame.K_BACKSPACE}[edge]
522+
if edge == "start":
523+
# Pause, except on the title where there is nothing to pause;
524+
# there it is the confirm button people expect it to be.
525+
key = (pygame.K_RETURN if self.state in (TITLE, GAMEOVER)
526+
else pygame.K_ESCAPE)
527+
else:
528+
key = {"up": pygame.K_UP, "down": pygame.K_DOWN,
529+
"confirm": pygame.K_RETURN,
530+
"back": pygame.K_BACKSPACE}[edge]
445531
self.handle_event(pygame.event.Event(pygame.KEYDOWN, key=key, mod=0))
446532

447533
def update(self, dt):
448534
self.t += dt
535+
if self.paused:
536+
# The cursor still pulses and the toast still fades: a frozen menu
537+
# looks like a crash. Nothing else advances.
538+
if self.toast is not None:
539+
self.toast[1] -= dt
540+
if self.toast[1] <= 0:
541+
self.toast = None
542+
self.pad_menu()
543+
return
449544
self.flash.update(dt)
450545
if self.toast is not None:
451546
self.toast[1] -= dt
@@ -486,6 +581,9 @@ def draw(self):
486581
elif self.state == GAMEOVER:
487582
self.draw_gameover(c)
488583

584+
if self.paused:
585+
self.draw_pause(c)
586+
ox = oy = 0
489587
if self.toast is not None:
490588
self.draw_toast(c)
491589

‎pc/nova/gamepad.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,15 @@ def menu_edges(self):
120120
out.append("up")
121121
elif dy > 0:
122122
out.append("down")
123-
if (confirm and not prev[2]) or (start and not prev[4]):
123+
if confirm and not prev[2]:
124124
out.append("confirm")
125125
elif back and not prev[3]:
126126
out.append("back")
127+
if start and not prev[4]:
128+
# Start is its own edge now rather than another confirm: it opens
129+
# and closes the pause menu, which is what start does everywhere
130+
# else, and the game decides what that means on the title screen.
131+
out.append("start")
127132
self.prev[0] = (dx, dy, confirm, back, start)
128133
return out
129134

‎pc/tests/test_run.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,62 @@ def run_once(seed, players=1, difficulty=1):
163163
"fights": d.fights, "minutes": d.frames * DT / 60.0, "log": d.log}
164164

165165

166+
def test_pause():
167+
"""Escape freezes the fight and gives it back untouched.
168+
169+
The interesting half is not that the menu opens, it is that nothing moves
170+
behind it. Pause is a flag over the existing screen rather than a state of
171+
its own, which is what keeps the combat alive underneath -- and also what
172+
makes it easy to leave one update path running by accident.
173+
"""
174+
from nova.game import COMBAT, TITLE
175+
g = Game(scale=1, crt=False, sound=False)
176+
g.start_run(1)
177+
g.run.seed = 3
178+
g.run.rng = random.Random(3)
179+
g.map = None
180+
g.new_sector()
181+
while g.state != COMBAT: # walk into the first fight
182+
if g.state == MAP:
183+
press(g, pygame.K_RETURN)
184+
else:
185+
press(g, pygame.K_RETURN)
186+
for _ in range(240): # let the fight get going
187+
g.update(DT)
188+
189+
press(g, pygame.K_ESCAPE)
190+
assert g.paused, "escape did not pause"
191+
before = (g.combat.time, len(g.combat.enemies), len(g.combat.shots),
192+
g.run.hull, [(round(s.x, 3), round(s.y, 3))
193+
for s in g.combat.shots])
194+
for _ in range(120):
195+
g.update(DT)
196+
g.draw() # drawing must be safe too
197+
after = (g.combat.time, len(g.combat.enemies), len(g.combat.shots),
198+
g.run.hull, [(round(s.x, 3), round(s.y, 3))
199+
for s in g.combat.shots])
200+
assert before == after, "the fight moved while paused"
201+
202+
press(g, pygame.K_ESCAPE)
203+
assert not g.paused, "escape did not resume"
204+
for _ in range(60):
205+
g.update(DT)
206+
assert g.combat is None or g.combat.time > before[0], "the fight did not resume"
207+
208+
# abandoning goes back to the title, and the title cannot be paused
209+
if g.state == COMBAT:
210+
press(g, pygame.K_ESCAPE)
211+
for _ in range(3):
212+
press(g, pygame.K_DOWN)
213+
press(g, pygame.K_RETURN)
214+
assert g.state == TITLE, "abandon did not return to the title"
215+
assert not g.paused
216+
press(g, pygame.K_ESCAPE)
217+
assert not g.paused, "the title screen must not pause"
218+
print(" pause: freezes the fight, resumes it, abandons cleanly")
219+
return True
220+
221+
166222
def test_boss_entrance():
167223
"""A boss must not be damageable while it is holding fire.
168224
@@ -257,6 +313,7 @@ def main():
257313

258314
print()
259315
print("=== edge cases ===")
316+
test_pause()
260317
test_boss_entrance()
261318
test_maxed_trader()
262319
print(" fully-upgraded ship: trader and salvage both handled")

0 commit comments

Comments
 (0)