Skip to content

Commit 9dadc92

Browse files
Three enemies out of the boss fights, and late sectors that keep growing
The roster was `min(2 + sector, 5)`: everything unlocked by sector 3, so sectors 4 and 5 introduced nothing at all and the last third of a run was the first third with bigger numbers. It grows the whole way now, one new kind a sector, and each of the three additions takes a mechanic off a boss and bolts it to something mortal -- a boss teaches an attack once, then the attack comes back on a ship you can shoot down. LANCER sector 3 LANCE's beam. Aims, charges for 0.85s, fires. The beam locks where it aimed, and killing the lancer mid-charge takes the beam with it: a target priority rather than a weather condition. SPINNER sector 5 SENTINEL's rings. The only thing in the roster that does not care where you are, which is what makes it awkward beside everything that does. PHANTOM the Void Dashes backwards and sideways. The trigger is you lining up underneath, so parking below one and holding the trigger -- what a maxed rate of fire teaches you to do by then -- is exactly what fails. Density: the budget slope goes from +10 to +13 a sector and its cap from 104 to 150, but the real brake was elsewhere -- a flat ceiling of 14 enemies in the air at once, unchanged since sector 1. It now rises with the sector, to 20. The test pilot sees lancer beams. Without that it would have rated the enemy by how invisible it is to a heuristic rather than by how dangerous it is, which is the exact mistake the old repulsion-field pilot made and the reason it had to be rewritten. Worst frame measured: 1.76 ms p99 against 16.7, with 305 entities live. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 92089ee commit 9dadc92

5 files changed

Lines changed: 248 additions & 15 deletions

File tree

‎pc/nova/combat.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,15 @@ def __init__(self, run, kind, art, audio=None):
6666

6767
sector = run.sector
6868
self.is_boss = kind == data.N_BOSS
69-
budget = (16 + sector * 10 + run.node * 3) * run.budget_mult
69+
budget = (16 + sector * 13 + run.node * 3) * run.budget_mult
7070
# Capped: past this an uncapped budget makes waves longer, not harder.
71-
self.budget = min(budget, 104)
71+
# The cap and the slope both went up for the late game, where sectors
72+
# 4 and 5 were running the same wave sizes as sector 3 and going past
73+
# in the same few minutes.
74+
self.budget = min(budget, 150)
7275
if kind == data.N_ELITE:
7376
self.budget *= 1.5
74-
self.pool = min(2 + sector, 5)
77+
self.pool = data.roster(sector)
7578

7679
if self.is_boss:
7780
self.budget = 0
@@ -116,12 +119,14 @@ def nearest_player_pos(self, x):
116119
return best.x, best.y
117120

118121
def spawn_wave(self, dt):
119-
if self.budget <= 0 or len(self.enemies) > 14:
122+
# How many can be in the air at once, not just how many arrive: a flat
123+
# ceiling of 14 was what actually held the late sectors down.
124+
if self.budget <= 0 or len(self.enemies) >= min(20, 13 + self.run.sector):
120125
return
121126
self.spawn_t -= dt
122127
if self.spawn_t > 0:
123128
return
124-
kind = self.rng.randrange(self.pool)
129+
kind = self.rng.choice(self.pool)
125130
self.budget -= data.ENEMY_COST[kind]
126131
hp = data.ENEMY_HP[kind] + self.run.sector
127132
x = self.rng.uniform(data.PLAY_L + 24, data.PLAY_R - 24)
@@ -260,7 +265,11 @@ def update(self, dt, inputs):
260265
s.update(dt)
261266
for e in self.enemies:
262267
tx, ty = self.nearest_player_pos(e.x)
268+
had_beam = e.beam is not None
263269
e.update(dt, tx)
270+
if e.beam is not None and not had_beam:
271+
# the telegraph needs a sound as well as a line
272+
self.audio.play("boss_warn", 0.35, throttle=0.2)
264273
if e.y > data.TOP - 4 and e.wants_to_fire(
265274
dt, 1.0 + self.run.fire_bonus):
266275
self.shots.extend(e.volley(tx, ty, self.run.difficulty
@@ -432,6 +441,9 @@ def collide(self):
432441
s.y = 1e9
433442
break
434443
for e in self.enemies:
444+
if e.beam is not None and e.beam.firing and \
445+
ent.overlaps(pr, e.beam.rect()):
446+
self.hurt_player(p)
435447
if e.hp > 0 and ent.overlaps(pr, e.rect()):
436448
self.hurt_player(p)
437449
if e.kind != data.BOSS_ID:
@@ -468,6 +480,9 @@ def draw(self, surf):
468480
surf.blit(img, (int(pk.x) - img.get_width() // 2,
469481
int(pk.y) - img.get_height() // 2))
470482

483+
for e in self.enemies:
484+
if e.beam is not None:
485+
e.beam.draw(surf)
471486
for e in self.enemies:
472487
img = a.enemy_surface(sector, e.kind, e.flash > 0)
473488
surf.blit(img, (int(e.x) - img.get_width() // 2,

‎pc/nova/data.py‎

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -363,16 +363,83 @@ def SEC_ACCENT(sector):
363363
CRYSTAL_PAL = {"w": WHITE, "c": CYAN}
364364
REPAIR_PAL = {"w": WHITE, "g": GREEN}
365365

366+
# The three late arrivals. Each takes a mechanic off a boss and bolts it to
367+
# something mortal, which is the point: a boss teaches you an attack once, and
368+
# then the attack comes back on a ship you can actually shoot down.
369+
LANCER = """
370+
..aaaa
371+
.aaaaa
372+
aaaaaa
373+
aawwwa
374+
aaaaaa
375+
.aaaaa
376+
..aaaa
377+
...ddd
378+
....dd
379+
....dd
380+
""".strip("\n")
381+
382+
SPINNER = """
383+
.....a
384+
....aa
385+
...aaa
386+
..aaaa
387+
.aaaaa
388+
..awwa
389+
.aaaaa
390+
..aaaa
391+
...aaa
392+
....aa
393+
.....a
394+
""".strip("\n")
395+
396+
PHANTOM = """
397+
.....a
398+
....aa
399+
...aaa
400+
..awwa
401+
..awwa
402+
.aaaaa
403+
.aa.aa
404+
aa...a
405+
a....a
406+
""".strip("\n")
407+
366408
# Enemies are tinted per sector at load time: 'a' is the accent, 'd' its dark
367409
# shade, 'w' stays white.
368-
GRUNT_ID, WEAVER_ID, TURRET_ID, RUSHER_ID, TANK_ID, BOSS_ID = range(6)
369-
ENEMY_ART = (GRUNT, WEAVER, TURRET, RUSHER, TANK, BOSS_SENTINEL)
370-
371-
ENEMY_HP = (2, 2, 6, 2, 12, 1)
372-
ENEMY_SCORE = (10, 15, 25, 20, 40, 500)
373-
ENEMY_SPEED = (46, 42, 40, 118, 22, 0) # pixels per second
374-
ENEMY_FIRE = (2.6, 0.0, 1.4, 0.0, 1.9, 0.8) # seconds between shots, 0 = never
375-
ENEMY_COST = (1, 2, 3, 2, 5, 0) # threat budget
410+
(GRUNT_ID, WEAVER_ID, TURRET_ID, RUSHER_ID, TANK_ID, BOSS_ID,
411+
LANCER_ID, SPINNER_ID, PHANTOM_ID) = range(9)
412+
ENEMY_ART = (GRUNT, WEAVER, TURRET, RUSHER, TANK, BOSS_SENTINEL,
413+
LANCER, SPINNER, PHANTOM)
414+
415+
ENEMY_HP = (2, 2, 6, 2, 12, 1, 4, 5, 4)
416+
ENEMY_SCORE = (10, 15, 25, 20, 40, 500, 35, 40, 55)
417+
ENEMY_SPEED = (46, 42, 40, 118, 22, 0, 34, 30, 40) # pixels per second
418+
# seconds between shots, 0 = never (the lancer answers with a beam instead)
419+
ENEMY_FIRE = (2.6, 0.0, 1.4, 0.0, 1.9, 0.8, 0.0, 2.2, 1.5)
420+
ENEMY_COST = (1, 2, 3, 2, 5, 0, 4, 4, 4) # threat budget
421+
422+
# When each kind starts showing up, by sector index (0 = sector 1).
423+
#
424+
# The roster used to be `min(2 + sector, 5)`: everything was unlocked by
425+
# sector 3, so sectors 4 and 5 introduced nothing at all and the last third of
426+
# a run was the first third with bigger numbers. It grows the whole way now,
427+
# and the Void gets something of its own.
428+
ENEMY_UNLOCK = {
429+
GRUNT_ID: 0,
430+
WEAVER_ID: 0,
431+
RUSHER_ID: 1,
432+
TURRET_ID: 2,
433+
LANCER_ID: 2, # the beam, first met on LANCE at sector 3
434+
TANK_ID: 3,
435+
SPINNER_ID: 4, # SENTINEL's rings, on something that comes to you
436+
PHANTOM_ID: 5, # the Void only
437+
}
438+
439+
440+
def roster(sector):
441+
"""Which enemy kinds a sector can field."""
442+
return [k for k, first in sorted(ENEMY_UNLOCK.items()) if first <= sector]
376443

377444
# --- upgrades ------------------------------------------------------------
378445
# All twelve are back: the calculator build could only afford eight.

‎pc/nova/entities.py‎

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,64 @@ def alive(self):
5656
and data.PLAY_L - 20 < self.x < data.PLAY_R + 20)
5757

5858

59+
class EnemyBeam:
60+
"""A lancer's beam: LANCE's mechanic, bolted to something you can kill.
61+
62+
Same contract as the boss's -- it locks where it aimed when the charge
63+
began, so it is always dodgeable and never arbitrary -- but it hangs off a
64+
mortal ship. Kill the lancer mid-charge and the beam goes with it, which is
65+
the whole reason for putting a boss attack on a regular enemy: it turns
66+
into a target priority instead of a weather condition.
67+
"""
68+
69+
CHARGE = 0.85
70+
FIRE = 0.5
71+
FADE = 0.2
72+
WIDTH = 9
73+
74+
__slots__ = ("x", "y", "t")
75+
76+
def __init__(self, x, y):
77+
self.x = x
78+
self.y = y
79+
self.t = 0.0
80+
81+
def update(self, dt):
82+
self.t += dt
83+
84+
@property
85+
def firing(self):
86+
return self.CHARGE <= self.t < self.CHARGE + self.FIRE
87+
88+
@property
89+
def done(self):
90+
return self.t >= self.CHARGE + self.FIRE + self.FADE
91+
92+
def rect(self):
93+
half = self.WIDTH / 2
94+
return (self.x - half, self.y, self.WIDTH, data.BOT - self.y)
95+
96+
def draw(self, surf):
97+
top = int(self.y)
98+
h = data.BOT - top
99+
if h <= 0:
100+
return
101+
if self.t < self.CHARGE:
102+
frac = self.t / self.CHARGE
103+
if int(self.t * (7 + 20 * frac)) % 2:
104+
w = max(1, int(1 + frac * 3))
105+
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
106+
return
107+
if self.firing:
108+
w = self.WIDTH
109+
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
110+
surf.fill(data.WHITE, (int(self.x - 2), top, 4, h))
111+
return
112+
frac = 1.0 - (self.t - self.CHARGE - self.FIRE) / self.FADE
113+
w = max(1, int(self.WIDTH * frac * 0.5))
114+
surf.fill(data.ORANGE, (int(self.x - w / 2), top, w, h))
115+
116+
59117
class Pickup:
60118
"""Crystals and hull patches, back from the calculator's cutting-room floor."""
61119

@@ -184,7 +242,8 @@ def take_hit(self):
184242

185243
class Enemy:
186244
__slots__ = ("x", "y", "kind", "hp", "max_hp", "t", "fire_t", "anchor",
187-
"drift", "w", "h", "flash", "phase", "score", "rng")
245+
"drift", "w", "h", "flash", "phase", "score", "rng",
246+
"beam", "dash_t", "dash_vx", "dash_vy")
188247

189248
def __init__(self, kind, x, y, hp, sector, rng=None):
190249
self.kind = kind
@@ -202,6 +261,10 @@ def __init__(self, kind, x, y, hp, sector, rng=None):
202261
self.phase = 0
203262
self.score = data.ENEMY_SCORE[kind]
204263
self.w, self.h = ENEMY_SIZE[kind]
264+
self.beam = None # lancers only
265+
self.dash_t = 0.0 # phantoms only
266+
self.dash_vx = 0.0
267+
self.dash_vy = 0.0
205268

206269
def rect(self):
207270
return (self.x - self.w / 2, self.y - self.h / 2, self.w, self.h)
@@ -225,6 +288,57 @@ def update(self, dt, target_x):
225288
self.anchor += 11 * dt
226289
self.y += 11 * dt
227290
self.x += math.sin(self.t * 1.1) * 26 * dt
291+
elif k == data.LANCER_ID:
292+
# Slides to its station, then works a charge-fire cycle. It never
293+
# descends past the anchor: a beam is only fair if you can see
294+
# where it will be, and one falling on top of you is not that.
295+
if self.y < self.anchor:
296+
self.y += speed * dt
297+
else:
298+
self.x += math.sin(self.t * 0.9) * 22 * dt
299+
self.anchor += 6 * dt
300+
self.y += 6 * dt
301+
if self.beam is not None:
302+
# The beam does not follow the ship: it stays where it aimed
303+
# when the charge began, which is the whole telegraph.
304+
self.beam.update(dt)
305+
if self.beam.done:
306+
self.beam = None
307+
self.fire_t = self.rng.uniform(1.3, 2.1)
308+
else:
309+
self.fire_t -= dt
310+
if self.fire_t <= 0 and self.y > data.TOP + 8:
311+
self.beam = EnemyBeam(target_x, self.y + 6)
312+
elif k == data.SPINNER_ID:
313+
# Drifts in slowly and turns. Its rings are the one attack in the
314+
# roster that does not care where you are, which is what makes it
315+
# awkward next to everything that does.
316+
self.y += speed * dt
317+
self.x += math.sin(self.t * 1.6) * 34 * dt
318+
elif k == data.PHANTOM_ID:
319+
# The Void's answer to a maxed rate of fire: it will not stand
320+
# still to be hit. Lining up under it is the trigger, so parking
321+
# beneath one and holding the trigger -- which is what a stock
322+
# shmup rewards by then -- is exactly what does not work.
323+
if self.dash_t > 0:
324+
self.dash_t -= dt
325+
self.x += self.dash_vx * dt
326+
self.y += self.dash_vy * dt
327+
else:
328+
self.y += speed * dt
329+
lined_up = abs(self.x - target_x) < 26
330+
self.fire_t -= dt * (2.2 if lined_up else 1.0)
331+
if lined_up and self.t > 0.7:
332+
self.t = 0.0
333+
self.dash_t = 0.24
334+
if self.rng.random() < 0.45:
335+
# backwards, out of the firing lane entirely
336+
self.dash_vy = -260.0
337+
self.dash_vx = self.rng.choice((-1, 1)) * 90.0
338+
else:
339+
away = 1.0 if self.x > target_x else -1.0
340+
self.dash_vx = away * 300.0
341+
self.dash_vy = -40.0
228342
elif k == data.BOSS_ID:
229343
self.x += self.drift * dt
230344
if self.x < data.PLAY_L + 60 or self.x > data.PLAY_R - 60:
@@ -239,6 +353,10 @@ def update(self, dt, target_x):
239353
else:
240354
self.y += speed * dt
241355
self.x = max(data.PLAY_L + 6, min(data.PLAY_R - 6, self.x))
356+
if k == data.PHANTOM_ID:
357+
# A dash backwards must not park it off the top, where nothing can
358+
# reach it and it stops being a fight.
359+
self.y = max(data.TOP + 10, self.y)
242360

243361
def wants_to_fire(self, dt, haste=1.0):
244362
"""`haste` is the difficulty tier's contribution.
@@ -288,6 +406,19 @@ def volley(self, tx, ty, difficulty):
288406
out.append(EnemyBullet(self.x, self.y + 6,
289407
math.cos(a) * base * 0.8,
290408
math.sin(a) * base * 0.8, 2))
409+
elif k == data.SPINNER_ID:
410+
spin = self.t * 1.7
411+
for i in range(6):
412+
a = spin + i * (math.tau / 6)
413+
out.append(EnemyBullet(self.x, self.y,
414+
math.cos(a) * base * 0.66,
415+
math.sin(a) * base * 0.66, 2))
416+
elif k == data.PHANTOM_ID:
417+
for i in (-1, 1):
418+
a = ang + i * 0.1
419+
out.append(EnemyBullet(self.x, self.y + 4,
420+
math.cos(a) * base * 1.15,
421+
math.sin(a) * base * 1.15, 2))
291422
elif k == data.TANK_ID:
292423
for i in (-1, 1):
293424
a = ang + i * 0.16
@@ -306,7 +437,8 @@ def alive(self):
306437
return self.kind == data.BOSS_ID or self.y < data.BOT + 30
307438

308439

309-
ENEMY_SIZE = ((11, 11), (11, 11), (11, 12), (11, 13), (17, 13), (37, 17))
440+
ENEMY_SIZE = ((11, 11), (11, 11), (11, 12), (11, 13), (17, 13), (37, 17),
441+
(11, 10), (11, 11), (11, 9))
310442

311443

312444
def overlaps(a, b):

‎pc/tests/bot.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ def threats(self, me):
6464
# stationary threat sitting at the player's own height
6565
if beam.t > beam.CHARGE * 0.45:
6666
out.append((beam.x, me.y, 0.0, 0.0, beam.width * 0.5 + 8.0))
67+
# Lancers carry the same attack on a killable ship. A pilot that cannot
68+
# see them would rate the enemy by how invisible it is to a heuristic,
69+
# which is exactly the mistake the repulsion field made.
70+
for e in self.c.enemies:
71+
b = e.beam
72+
if b is not None and b.t > b.CHARGE * 0.45 and b.y < me.y:
73+
out.append((b.x, me.y, 0.0, 0.0, b.WIDTH * 0.5 + 8.0))
6774
return out
6875

6976
def cost(self, px, py, vx, vy, threats):

‎pc/tools/balance.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ def main(argv):
158158
print(" traders per sector : %.2f" % m["shop_per_map"])
159159
print(" every route crosses one : %.0f%%" % m["forced_shop"])
160160

161+
names = ("GRUNT", "WEAVER", "TURRET", "RUSHER", "TANK", "BOSS",
162+
"LANCER", "SPINNER", "PHANTOM")
163+
print("\n=== the roster, sector by sector ===")
164+
seen = set()
165+
for sec in range(7):
166+
kinds = set(data.roster(sec))
167+
fresh = sorted(kinds - seen)
168+
seen = kinds
169+
print(" %-8s %2d kinds new: %s"
170+
% ("VOID %d" % (sec - 4) if sec > 4 else "sector %d" % (sec + 1),
171+
len(kinds), ", ".join(names[k] for k in fresh) or "-"))
172+
161173
print("\n=== fights (%d seeds per tier) ===" % len(seeds))
162174
print("%-7s %6s %8s %9s %8s %10s %8s" %
163175
("tier", "won", "boss hp", "dmg taken", "no dmg", "boss secs",

0 commit comments

Comments
 (0)