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]