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>
129 lines
5.3 KiB
GDScript
129 lines
5.3 KiB
GDScript
extends Node2D
|
|
## Draws the whole bullet field.
|
|
##
|
|
## A bullet hell routinely has hundreds of bullets on screen. One Node2D each
|
|
## would cost more than the simulation does, so the renderer reads the client's
|
|
## [BulletPool] arrays directly and pushes them into MultiMeshes.
|
|
##
|
|
## One MultiMesh per bullet kind rather than one overall: a MultiMeshInstance2D
|
|
## 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.
|
|
const _FALLBACK_COLOURS: Array[Color] = [
|
|
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
|
|
Color(1.0, 0.35, 0.75), # KIND_ORB
|
|
Color(1.0, 0.85, 0.3), # KIND_NEEDLE
|
|
Color(1.0, 0.45, 0.2), # KIND_HEAVY
|
|
]
|
|
|
|
var _layers: Array[MultiMeshInstance2D] = []
|
|
## 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().
|
|
var _fallback: bool = false
|
|
|
|
|
|
func _ready() -> void:
|
|
var sheet := Art.bullets_texture()
|
|
_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:
|
|
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()
|
|
# 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
|
|
mm.mesh = quad
|
|
mm.instance_count = SimConfig.MAX_BULLETS
|
|
mm.visible_instance_count = 0
|
|
layer.multimesh = mm
|
|
add_child(layer)
|
|
_layers.append(layer)
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
_anim_time += delta
|
|
|
|
|
|
## A soft-edged disc with a bright core, generated at runtime. This is what the
|
|
## game draws when the bullet sheet is not present, which is the case for any
|
|
## clone of the repository -- the sheet's licence forbids redistributing it.
|
|
func _make_dot_texture() -> ImageTexture:
|
|
var size := 32
|
|
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
|
|
var centre := Vector2(size, size) * 0.5
|
|
for y in size:
|
|
for x in size:
|
|
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (size * 0.5)
|
|
var alpha := pow(clampf(1.0 - d, 0.0, 1.0), 0.6)
|
|
var core := clampf(1.0 - d * 1.9, 0.0, 1.0)
|
|
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, alpha).lerp(Color.WHITE, core * 0.8))
|
|
return ImageTexture.create_from_image(img)
|
|
|
|
|
|
## 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. 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 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:
|
|
var counts := PackedInt32Array()
|
|
counts.resize(_layers.size())
|
|
counts.fill(0)
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 0:
|
|
continue
|
|
var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
|
|
var n := counts[kind]
|
|
if n >= SimConfig.MAX_BULLETS:
|
|
continue
|
|
# The art fills its 16px cell, while the radius is the hitbox. Draw a
|
|
# 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
|
|
# 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,
|
|
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE)
|
|
counts[kind] = n + 1
|
|
for kind in _layers.size():
|
|
_layers[kind].multimesh.visible_instance_count = counts[kind]
|