Skip to content

Commit da223e1

Browse files
Measure the new roster: the difficulty ladder is monotone again
24 seeds a tier, with the late sectors finally fielding something: CADET 18/24 entering the boss at 77% hull PILOT 12/24 at 75% ACE 3/24 at 73% Two things moved that matter more than the win rates. Hull on entering a boss fight went from 89% to 75%. That is the number that actually answers "you always reach the boss at full health" -- making repair bays rare was necessary and not sufficient, because the road to the boss still had to cost something, and it could not until sectors 4 and 5 had enough on screen to charge for. And the tiers separate properly for the first time. CADET and PILOT had been tying at 13 and 14: what CADET gives is mostly a deeper hull bar, and a pilot that is rarely hit never spends it. Denser fights give it something to spend. Per boss at PILOT, 2.2 -> 3.3 -> 3.5 -> 3.9 -> 6.1 hull: a clean ramp, which none of the earlier passes managed. Sprite sheet regenerated, and tools/shots_foes.py captures the three new kinds doing the thing they exist to do -- a sprite sheet shows what a lancer looks like, not that its beam is a column you have to leave. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 9dadc92 commit da223e1

6 files changed

Lines changed: 165 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ le jeu. `pc/build_exe.cmd` en fait un `.exe` autonome avec icône.
3434
Déplacement 8 directions, tir automatique, coop 2 joueurs (clavier et/ou
3535
manette), **cinq boss** aux mécaniques distinctes — murs à un seul trou,
3636
spirales, peignes de rayons, escortes, modules destructibles et canons sur les
37-
murs de l'arène. Les motifs sont denses mais jamais aléatoires : ils sont faits
37+
murs de l'arène. Le bestiaire grandit jusqu'au bout, un nouveau type par
38+
secteur, et les trois derniers reprennent chacun une mécanique de boss sur un
39+
vaisseau que tu peux abattre : un **rayon**, des anneaux, et dans le Vide un
40+
chasseur qui **esquive en dash** dès que tu te places sous lui. Les motifs sont denses mais jamais aléatoires : ils sont faits
3841
pour être lus, et chaque boss ménage une **fenêtre** où tout se tait, le temps
3942
de riposter. La carte, elle, est un vrai choix — un atelier de réparation n'est
4043
proposé que dans 54 % des secteurs, sur une seule branche, jamais avant le boss.

‎docs/img/pc-foes.png‎

13.3 KB
Loading

‎docs/img/pc-sprites.png‎

406 Bytes
Loading

‎pc/README.md‎

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,36 @@ hit-stop sur les dégâts, filtre CRT optionnel.
186186
| ![Marchand](../docs/img/pc-05-trader.png) | ![CRT](../docs/img/pc-03-fight-crt.png) |
187187
| Le marchand dit ce qu'il vend | Le filtre CRT, `F1` |
188188

189+
## Le bestiaire
190+
191+
Il grandit jusqu'au bout. Avant, c'était `min(2 + secteur, 5)` : tout était
192+
débloqué dès le secteur 3, donc les secteurs 4 et 5 n'introduisaient plus rien
193+
et le dernier tiers d'une partie n'était que le premier tiers avec des nombres
194+
plus gros.
195+
196+
```
197+
secteur 1 GRUNT, WEAVER secteur 4 + TANK
198+
secteur 2 + RUSHER secteur 5 + SPINNER
199+
secteur 3 + TURRET, LANCER le Vide + PHANTOM
200+
```
201+
202+
Les trois derniers prennent chacun une mécanique à un boss et la boulonnent sur
203+
quelque chose de mortel. C'est tout l'intérêt : un boss t'apprend une attaque
204+
une fois, puis l'attaque revient sur un vaisseau que tu peux abattre.
205+
206+
![Les ennemis tardifs](../docs/img/pc-foes.png)
207+
208+
| | |
209+
|---|---|
210+
| **LANCER** | Le **rayon** de LANCE. Il vise, charge 0,85 s, tire. Le rayon reste verrouillé là où il visait — et le tuer pendant la charge emporte le rayon avec lui. Une priorité de cible, pas une fatalité. |
211+
| **SPINNER** | Les anneaux de SENTINEL. Le seul ennemi du bestiaire qui ne se soucie pas d'où tu es, ce qui le rend gênant à côté de tous ceux qui te visent. |
212+
| **PHANTOM** | Le Vide seulement. **Dash arrière et latéral**, déclenché par le fait que tu sois aligné dessous — donc se garer en dessous et tenir la gâchette, ce que la cadence de tir du Vide t'a appris à faire, est exactement ce qui échoue. |
213+
214+
La densité aussi : le budget passe de `+10` à `+13` par secteur, plafond de 104
215+
à 150. Mais le vrai frein était ailleurs — un **plafond fixe de 14 ennemis
216+
simultanés**, inchangé depuis le secteur 1. Il monte maintenant avec le secteur,
217+
jusqu'à 20.
218+
189219
## Les boss
190220

191221
Le premier jet PC avait un seul boss reskinné par secteur : le combat censé
@@ -360,7 +390,8 @@ pc/
360390
nova.spec recette PyInstaller
361391
tests/ suite headless
362392
tools/shots.py rend les captures d'écran
363-
tools/balance.py mesure la carte et les combats
393+
tools/balance.py mesure la carte, le bestiaire et les combats
394+
tools/shots_foes.py captures des ennemis tardifs
364395
tools/make_icon.py dessine l'icône
365396
```
366397

@@ -477,24 +508,26 @@ publiés mesuraient du bruit) :
477508

478509
| | |
479510
|---|---|
480-
| Taux de victoire (bot) | Cadet 13/24 · **Pilote 14/24** · As 6/24 |
481-
| Dégâts pris par boss | **5,4** points de coque (0,6 avant) |
482-
| Combats de boss sans une égratignure | **15 %** (57 % avant) |
483-
| Durée d'un combat de boss | **49 s** (30 s avant) |
484-
| Durée d'une partie gagnée | 12 à 13 min de combat |
485-
| Coût d'une image (p99) | **1,11 ms** sur un budget de 16,7 ms |
486-
| Pire cas mesuré | WARDEN phase 3 en coop, **103 projectiles** à l'écran |
511+
| Taux de victoire (bot) | Cadet 18/24 · **Pilote 12/24** · As 3/24 |
512+
| Coque à l'entrée du boss | **75 %** de la barre (89 % avant) |
513+
| Dégâts pris par boss | **3,6** points de coque (0,6 avant) |
514+
| Combats de boss sans une égratignure | **17 %** (57 % avant) |
515+
| Durée d'un combat de boss | **45 s** (30 s avant) |
516+
| Durée d'une partie gagnée | 13 à 16 min de combat |
517+
| Coût d'une image (p99) | **1,76 ms** sur un budget de 16,7 ms |
518+
| Pire cas mesuré | WARDEN phase 3 en coop, **305 entités** à l'écran |
487519
| Construction des sons | 24 échantillons en **0,03 s** au démarrage |
488520

489-
**Cadet et Pilote sont à égalité, et il ne faut pas le corriger.** 13 contre 14
490-
sur 24 graines, c'est un écart d'une partie, largement dans le bruit — mais
491-
c'est surtout la limite de l'instrument. Ce que Cadet donne, c'est 33 % de coque
492-
en plus et un tir adverse 35 % plus lent ; or ce pilote se fait très peu toucher,
493-
donc il ne dépense presque jamais cette coque supplémentaire. Un humain, qui
494-
encaisse bien davantage, sent la différence que le robot ne peut pas mesurer.
495-
Régler les niveaux jusqu'à ce que la courbe soit jolie **pour le robot**
496-
reviendrait à les régler contre le joueur. As, lui, se détache nettement (6/24),
497-
parce que la cadence de tir touche tout le monde de la même façon.
521+
L'échelle est monotone — 75 %, 50 %, 13 % — et elle ne l'a été qu'à partir du
522+
moment où les secteurs tardifs ont eu de quoi remplir l'écran. Avant, Cadet et
523+
Pilote finissaient à égalité (13 contre 14) : ce que Cadet donne, c'est surtout
524+
de la coque en plus, et un pilote qui se fait peu toucher ne la dépense jamais.
525+
Des combats plus denses lui donnent enfin quelque chose à en faire.
526+
527+
**La coque à l'entrée du boss est passée de 89 % à 75 %**, et c'est le chiffre
528+
qui répond vraiment à « on arrive toujours au boss à pleine vie ». Rendre les
529+
ateliers rares n'y suffisait pas : encore fallait-il que le chemin coûte quelque
530+
chose.
498531

499532
Et boss par boss, au niveau Pilote — c'est le tableau qui a servi à régler la
500533
difficulté, parce qu'une moyenne sur cinq boss cache exactement ce qu'on veut
@@ -503,10 +536,13 @@ savoir : est-ce que le *premier* boss rencontré est un combat ou une formalité
503536
| Boss | Dégâts pris | Durée | Sans une égratignure |
504537
|---|---:|---:|---:|
505538
| S1 SENTINEL | 2,2 | 31 s | 12 % |
506-
| S2 HIVE MOTHER | 7,6 | 67 s | 17 % |
507-
| S3 LANCE | 3,4 | 43 s | 19 % |
508-
| S4 BULWARK | 4,1 | 47 s | 27 % |
509-
| S5 WARDEN | 10,2 | 59 s | **0 %** |
539+
| S2 HIVE MOTHER | 3,3 | 56 s | 33 % |
540+
| S3 LANCE | 3,5 | 44 s | 5 % |
541+
| S4 BULWARK | 3,9 | 47 s | 24 % |
542+
| S5 WARDEN | 6,1 | 49 s | 7 % |
543+
544+
2,2 → 3,3 → 3,5 → 3,9 → 6,1 : une rampe propre, ce qu'aucune des passes
545+
précédentes n'avait obtenu.
510546

511547
HIVE MOTHER, le boss du secteur 2, mesurait **1,0 point de dégâts et la moitié
512548
de ses combats sans une égratignure** avant cette passe — de loin le plus faible

‎pc/tools/sheet.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
ZOOM = 5
2020
items = [("player 1", art.players[0]), ("player 2", art.players[1]),
2121
("crystal", art.crystal), ("repair", art.repair)]
22-
names = ("grunt", "weaver", "turret", "rusher", "tank")
23-
for i, n in enumerate(names):
22+
for i, n in ((data.GRUNT_ID, "grunt"), (data.WEAVER_ID, "weaver"),
23+
(data.TURRET_ID, "turret"), (data.RUSHER_ID, "rusher"),
24+
(data.TANK_ID, "tank"), (data.LANCER_ID, "lancer"),
25+
(data.SPINNER_ID, "spinner"), (data.PHANTOM_ID, "phantom")):
2426
items.append((n, art.enemy_surface(2, i)))
2527
items.append(("pod", art.pod))
2628
for i, n in enumerate(data.BOSS_NAME):

‎pc/tools/shots_foes.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""One screenshot per late-game enemy, caught doing the thing it exists to do.
2+
3+
A sprite sheet shows what they look like; it does not show that a lancer's beam
4+
is a column you have to leave, or that sector 5 fields twice what sector 3 does.
5+
"""
6+
7+
import os
8+
import sys
9+
10+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
11+
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
12+
HERE = os.path.dirname(os.path.abspath(__file__))
13+
PC = os.path.dirname(HERE)
14+
sys.path.insert(0, PC)
15+
sys.path.insert(0, os.path.join(PC, "tests"))
16+
17+
import pygame
18+
19+
from bot import Bot
20+
from nova import data, ui
21+
from nova.art import Art
22+
from nova.combat import Combat
23+
from nova.run import Run
24+
25+
OUT = os.path.normpath(os.path.join(PC, "..", "docs", "img"))
26+
DT = 1 / 60.0
27+
28+
# label, sector index, the kind that has to be on screen
29+
SCENES = (("SECTOR 3 a LANCER firing", 2, data.LANCER_ID),
30+
("SECTOR 5 SPINNER rings", 4, data.SPINNER_ID),
31+
("THE VOID PHANTOMs, which dash", 6, data.PHANTOM_ID))
32+
33+
34+
def capture(sector, want, seconds=80.0):
35+
art = Art()
36+
art.load_fonts()
37+
run = Run(1, 1, seed=11)
38+
run.sector = sector
39+
run.node = 5
40+
run.upgrades[data.U_SPREAD] = 2
41+
run.upgrades[data.U_DMG] = 1
42+
run.score = 4000 + sector * 600
43+
combat = Combat(run, data.N_FIGHT, art)
44+
bot = Bot(combat, 0)
45+
best = None
46+
for _ in range(int(seconds * 60)):
47+
mv, bomb = bot.inputs()
48+
combat.update(DT, {"move0": mv, "bomb": False})
49+
if combat.result is not None:
50+
break
51+
if not any(e.kind == want for e in combat.enemies):
52+
continue
53+
if combat.flash.amount >= 0.05:
54+
continue # a hull hit washes the whole arena red
55+
firing = any(e.beam is not None and e.beam.firing
56+
for e in combat.enemies)
57+
score = len(combat.enemies) + (70 if firing else 0)
58+
if best is None or score > best[0]:
59+
surf = pygame.Surface((data.W, data.H))
60+
combat.draw(surf)
61+
ui.draw_hud(surf, art, run, combat.tag)
62+
best = (score, surf.copy())
63+
return best
64+
65+
66+
def main():
67+
pygame.init()
68+
pygame.display.set_mode((data.W, data.H))
69+
data.set_viewport(480, 270)
70+
art = Art()
71+
art.load_fonts()
72+
73+
print("late-game enemies:")
74+
shots = []
75+
for label, sector, want in SCENES:
76+
got = capture(sector, want)
77+
if got is None:
78+
print(" %-34s NOT FOUND" % label)
79+
return 1
80+
shots.append((label, got[1]))
81+
print(" %-34s captured" % label)
82+
83+
pad = 16
84+
sheet = pygame.Surface((data.W + pad * 2,
85+
(data.H + pad + 14) * len(shots) + pad))
86+
sheet.fill(data.VOID)
87+
for i, (label, surf) in enumerate(shots):
88+
y = pad + i * (data.H + pad + 14)
89+
ui.text(sheet, art, label, pad, y - 12, data.CYAN)
90+
sheet.blit(surf, (pad, y))
91+
os.makedirs(OUT, exist_ok=True)
92+
path = os.path.join(OUT, "pc-foes.png")
93+
pygame.image.save(sheet, path)
94+
print("wrote %s %s" % (os.path.relpath(path, os.path.dirname(PC)),
95+
sheet.get_size()))
96+
return 0
97+
98+
99+
if __name__ == "__main__":
100+
sys.exit(main())

0 commit comments

Comments
 (0)