The Cantor gets its own portal; bullets stop strobing
ci / verify (push) Successful in 48s

A dungeon now names the boss arena it ends in, and the arena decides the boss.
Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds.

That replaces the seed coin-flip from the last commit, which was the wrong
call. Which boss you are about to fight is the single thing a player decides
before walking into a dungeon, and rolling it for them makes that decision
unavailable. Only the Proving Grounds still leaves its arena open, because a
harness you re-enter every couple of minutes wants whichever fight comes up
first -- and it is the one place where "either will do" is true.

test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here:
adding a boss and forgetting to give it a way in is now a failing test rather
than a boss nobody meets. The smoke test asserts all three portals open over a
real socket -- bots pick theirs by account id, so a run exercises each.

Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather
than a shape change, so running it had every bullet on screen strobing through
a palette in unison, which with a few hundred in the air is exactly as hard to
look at as it sounds. Each bullet holds one frame and turns slowly instead,
offset by its own id so a ring of twenty does not rotate as one rigid wheel.
The rate is slow enough that nothing completes a full turn inside its own
lifetime, so it reads as drift rather than spin, and a test pins that against
the longest-lived bullet in the game. The renderer slices four textures at
startup now instead of thirty-two.

check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 10:36:06 +02:00
1 parent 42568a40ad
commit 4a98cf4b0e
15 files changed
+251 -100

No files matched your search

+27 -30
View File
@@ -9,6 +9,11 @@ extends Node2D
## carries a single texture, and each kind needs a different region of the sprite
## sheet. Four kinds means four draw calls, which is nothing, and it avoids a
## custom shader passing UV offsets through per-instance custom data.
##
## Bullets do not animate. The sheet's frames are a colour cycle, and running it
## meant every bullet on screen strobed through a palette in unison -- unreadable
## with a few hundred of them in the air. Each bullet turns slowly as it flies
## instead, which is motion you can look at. See Art.BULLET_STILL_FRAME.
## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind.
@@ -20,9 +25,8 @@ const _FALLBACK_COLOURS: Array[Color] = [
]
var _layers: Array[MultiMeshInstance2D] = []
## [kind][frame] -> standalone texture. See _slice_frames().
var _frames: Array[Array] = []
## Advances the bullets' own animation, independent of the simulation.
## Elapsed seconds, driving the slow turn. Wall time rather than a frame count,
## so bullets drift at the same rate whatever the frame rate is doing.
var _anim_time: float = 0.0
## True when the licence-restricted bullet sheet is absent and we are drawing
## generated dots instead. See Art.bullets_texture().
@@ -34,16 +38,17 @@ func _ready() -> void:
_fallback = sheet == null
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
var kinds := SimConfig.KIND_HEAVY + 1
var per_kind: Array[Texture2D] = []
if not _fallback:
_slice_frames(sheet, kinds)
art = _frames[0][0]
per_kind = _slice_still_frames(sheet, kinds)
if _fallback:
GameLog.info("view", "bullet sheet absent, drawing generated dots")
var quad := QuadMesh.new()
quad.size = Vector2.ONE
for kind in kinds:
var layer := MultiMeshInstance2D.new()
layer.texture = art
# Assigned once and never touched again -- that swap was the animation.
layer.texture = per_kind[kind] if not _fallback else art
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
@@ -57,8 +62,6 @@ func _ready() -> void:
func _process(delta: float) -> void:
_anim_time += delta
if not _fallback:
_apply_frame()
## A soft-edged disc with a bright core, generated at runtime. This is what the
@@ -77,33 +80,23 @@ func _make_dot_texture() -> ImageTexture:
return ImageTexture.create_from_image(img)
## Slice the atlas into one standalone texture per (kind, frame).
## Cut one standalone texture per kind out of the atlas.
##
## A MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs run
## 0..1 across whatever texture it is given, and handing it an AtlasTexture does
## not help -- the draw resolves to the underlying atlas and the region is
## ignored, so every bullet rendered as the entire sheet. Pre-slicing costs 32
## tiny textures once at startup and makes each frame a single assignment.
func _slice_frames(sheet: Texture2D, kinds: int) -> void:
## ignored, so every bullet rendered as the entire sheet. Slicing is the way
## round that, and now that only one frame is ever drawn it costs four small
## textures at startup rather than thirty-two.
func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
var img := sheet.get_image()
var out: Array[Texture2D] = []
for kind in kinds:
var per_kind: Array[Texture2D] = []
for f in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, f)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
per_kind.append(ImageTexture.create_from_image(cell))
_frames.append(per_kind)
## Swap each layer to this frame's texture. One assignment per kind per frame;
## every bullet in a layer shares it, so there is no per-instance work.
func _apply_frame() -> void:
if _frames.is_empty():
return
var f := int(_anim_time / Art.BULLET_FRAME_SECONDS) % Art.BULLET_FRAMES
for kind in _layers.size():
_layers[kind].texture = _frames[kind][f]
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
out.append(ImageTexture.create_from_image(cell))
return out
func render_pool(pool: BulletPool) -> void:
@@ -121,7 +114,11 @@ func render_pool(pool: BulletPool) -> void:
# little larger so a bullet looks like the threat it is -- the same
# "prefer a visible near-miss" trade the ship sprite makes.
var d: float = pool.radius[i] * 3.0
var angle: float = (pool.vel[i] as Vector2).angle()
# Heading, plus a slow turn. The offset is taken from the bullet's own
# id so a ring of twenty does not rotate as one rigid wheel.
var angle: float = (pool.vel[i] as Vector2).angle() \
+ _anim_time * Art.BULLET_SPIN_RATE \
+ float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,