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:
@@ -13,6 +13,10 @@ extends Resource
|
||||
## One line for the portal label, so a player standing in the hub can tell the
|
||||
## entrances apart without reading a wiki.
|
||||
@export var subtitle: String = ""
|
||||
## Which hand-authored boss arena this run ends in, and therefore which boss it
|
||||
## has -- see [method Rooms.boss_for_arena]. Empty means "whichever the seed
|
||||
## rolls", which only the test harness wants.
|
||||
@export var arena: StringName = &""
|
||||
@export var enemy_hp_mult: float = 1.0
|
||||
@export var boss_hp_mult: float = 1.0
|
||||
## Multiplies every loot chance, clamped at certain. Guaranteed drops stay
|
||||
|
||||
+25
-1
@@ -9,10 +9,12 @@ extends RefCounted
|
||||
## one entry plus one marker.
|
||||
|
||||
const STANDARD := &"warden_descent"
|
||||
const VAULT := &"choir_vault"
|
||||
const PROVING := &"proving_grounds"
|
||||
|
||||
const ORDER: Array[StringName] = [
|
||||
STANDARD,
|
||||
VAULT,
|
||||
PROVING,
|
||||
]
|
||||
|
||||
@@ -24,6 +26,7 @@ static func default_id() -> StringName:
|
||||
static func get_def(id: StringName) -> DungeonDef:
|
||||
match id:
|
||||
STANDARD: return standard()
|
||||
VAULT: return vault()
|
||||
PROVING: return proving_grounds()
|
||||
return null
|
||||
|
||||
@@ -53,11 +56,28 @@ static func standard() -> DungeonDef:
|
||||
var d := DungeonDef.new()
|
||||
d.id = STANDARD
|
||||
d.display_name = "Warden's Descent"
|
||||
d.subtitle = "the real run"
|
||||
d.subtitle = "the Warden of the Fold"
|
||||
d.arena = &"warden_hall"
|
||||
d.tint = Color(0.5, 0.9, 1.0)
|
||||
return d
|
||||
|
||||
|
||||
## The second boss's own run. Same generator and the same enemies as the
|
||||
## Descent; what differs is the arena it ends in, and therefore who is waiting.
|
||||
##
|
||||
## A dedicated entrance rather than a coin flip on the run's seed: 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.
|
||||
static func vault() -> DungeonDef:
|
||||
var d := DungeonDef.new()
|
||||
d.id = VAULT
|
||||
d.display_name = "The Choir Vault"
|
||||
d.subtitle = "the Cantor of the Vault"
|
||||
d.arena = &"choir_vault"
|
||||
d.tint = Color(0.75, 0.6, 1.0)
|
||||
return d
|
||||
|
||||
|
||||
## A test harness you can walk into.
|
||||
##
|
||||
## Same generator, same rooms, same enemies and the same Warden -- everything
|
||||
@@ -71,6 +91,10 @@ static func proving_grounds() -> DungeonDef:
|
||||
d.id = PROVING
|
||||
d.display_name = "Proving Grounds"
|
||||
d.subtitle = "for testing -- fragile, generous"
|
||||
# Deliberately left to the seed. The harness exists to reach a fight fast,
|
||||
# and either fight will do -- runs here last a couple of minutes, so the one
|
||||
# you want is one re-entry away.
|
||||
d.arena = &""
|
||||
d.enemy_hp_mult = 0.2
|
||||
d.boss_hp_mult = 0.08
|
||||
# 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag,
|
||||
|
||||
@@ -112,17 +112,17 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
|
||||
## P a dungeon portal S player spawn T practice target
|
||||
## U the upgrade NPC
|
||||
##
|
||||
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two
|
||||
## of them now: the real run and the Proving Grounds, side by side so they can
|
||||
## be compared without restarting anything. Adding a third dungeon means adding
|
||||
## a third marker here.
|
||||
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER: the
|
||||
## Warden's Descent, the Choir Vault, and the Proving Grounds. Adding a dungeon
|
||||
## means adding an entry there and a marker here, and they have to stay in the
|
||||
## same order.
|
||||
static func lobby() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"#########################################",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#..............P.........P..............#",
|
||||
"#.........P.........P.........P.........#",
|
||||
"#.......................................#",
|
||||
"#.....o...........................o.....#",
|
||||
"#.......................................#",
|
||||
|
||||
@@ -66,7 +66,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
|
||||
inst.depth = maxi(dungeon_depth, 1)
|
||||
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
|
||||
inst.world = SimWorld.new(dungeon_seed)
|
||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
|
||||
var flavour := Dungeons.get_or_default(inst.dungeon_id)
|
||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
|
||||
inst.depth, flavour.arena)
|
||||
inst.world.set_map(built["grid"])
|
||||
inst.rooms = built["rooms"]
|
||||
inst.boss_spawn = built["boss_pos"]
|
||||
|
||||
+16
-10
@@ -19,10 +19,11 @@ extends RefCounted
|
||||
## Generation is still deterministic from (kind, seed, depth), which is what
|
||||
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
|
||||
## pins that.
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int,
|
||||
arena: StringName = &"") -> Dictionary:
|
||||
if kind == Protocol.InstanceKind.LOBBY:
|
||||
return _build_lobby()
|
||||
return generate(seed_value, depth)
|
||||
return generate(seed_value, depth, arena)
|
||||
|
||||
|
||||
static func _build_lobby() -> Dictionary:
|
||||
@@ -71,7 +72,11 @@ static func _build_lobby() -> Dictionary:
|
||||
|
||||
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
|
||||
## (Vector2), boss_room (Rect2i).
|
||||
static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
## [param arena] names the boss arena to stamp, and therefore which boss the run
|
||||
## ends with. Empty leaves it to the seed, which is what the Proving Grounds
|
||||
## wants and nothing else does -- for a real dungeon the boss is the thing a
|
||||
## player chose at the portal.
|
||||
static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed_value
|
||||
var d := maxi(depth, 1)
|
||||
@@ -86,16 +91,17 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
# Which arena -- and therefore which boss -- comes from the SEED, not the
|
||||
# depth. Depth is a dev flag that nothing in play raises, so keying the
|
||||
# arena to it meant the second boss existed and no player could ever reach
|
||||
# it. Every run rolls a fresh seed, so this is a coin flip per dungeon.
|
||||
var arena := &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
|
||||
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
|
||||
# Normally the dungeon says which arena it is; only the test harness leaves
|
||||
# it open, and then the seed decides.
|
||||
var chosen := arena
|
||||
if chosen.is_empty():
|
||||
chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
|
||||
var stamp := Rooms.warden_hall() if chosen == &"warden_hall" else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
var boss_room := Rect2i(boss_origin, bs)
|
||||
var arena_id := chosen
|
||||
|
||||
var boss_pos := grid.tile_centre(
|
||||
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
|
||||
@@ -165,7 +171,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
||||
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||
# hall, where its patterns assume geometry that is not there.
|
||||
"boss_id": Rooms.boss_for_arena(arena),
|
||||
"boss_id": Rooms.boss_for_arena(arena_id),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-2
@@ -140,8 +140,19 @@ static func item_icon(item: StringName) -> Rect2:
|
||||
# still, which is exactly what it looked like on screen.
|
||||
const BULLET_CELL := 16.0
|
||||
const BULLET_FRAMES := 8
|
||||
## Seconds per bullet animation frame.
|
||||
const BULLET_FRAME_SECONDS := 0.08
|
||||
## Which of the sheet's frames is actually drawn.
|
||||
##
|
||||
## One, and always the same one. The sheet's eight frames are a COLOUR cycle
|
||||
## rather than a shape change, and cycling them put a few hundred bullets on
|
||||
## screen all strobing through a palette together -- which is exactly as hard to
|
||||
## look at as it sounds. The bullets turn slowly instead, which reads as motion
|
||||
## without flickering. The other seven frames stay in the atlas because that is
|
||||
## what the source art is; nothing draws them.
|
||||
const BULLET_STILL_FRAME := 0
|
||||
## Radians per second a bullet turns as it travels. Deliberately slow: at this
|
||||
## rate nothing completes a full turn inside its own lifetime, so the effect is
|
||||
## a drift rather than a spin.
|
||||
const BULLET_SPIN_RATE := 0.55
|
||||
|
||||
|
||||
## Region for a bullet kind at an animation frame.
|
||||
|
||||
+27
-30
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user