zer0-image-generator 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require_relative "local_renderer" # render_local_svg + the shared seed_for/palette/composition scheme
6
+ require_relative "sanitizer" # sanitize_svg + SvgError — the REAL sanitizer, never a copy
7
+ require_relative "rasterizer" # which() for montage discovery; rasterize_svg for the full sweep
8
+
9
+ module Zer0ImageGenerator
10
+ module Svg
11
+ # Parametric adapter over the engine's OWN `local` banner template — the
12
+ # Ruby port of tools/svg/banner-seed.py, plus tools/svg/svg-sweep.sh's
13
+ # contact-sheet stitching.
14
+ #
15
+ # The adapter is deliberately thin: a sweep must judge the REAL `local`
16
+ # output, so it renders through LocalRenderer.render_local_svg and cleans
17
+ # through the real Sanitizer.sanitize_svg — the exact path LocalProvider
18
+ # ships (render_local_svg(slug, seed_for(slug)) -> sanitize_svg). It never
19
+ # reimplements either, so a change to the template is seen on true output
20
+ # instead of a lookalike.
21
+ #
22
+ # BYTE-IDENTITY (see test/ruby/test_banner_seed.rb):
23
+ # * The PRE-sanitize render is byte-identical to the Python adapter's —
24
+ # render_local_svg is a hard determinism contract (seeded LCG only).
25
+ # * The FINAL sanitized string is the SAME DOM but NOT byte-identical: the
26
+ # ported Sanitizer serializes via REXML (single quotes, alphabetized
27
+ # attributes, spaced self-closing tags) where Python uses ElementTree.
28
+ # That serializer difference is owned and documented by the sanitizer
29
+ # slice; this adapter inherits it. The test proves DOM byte-identity by
30
+ # canonicalizing both outputs through REXML.
31
+ module BannerSeed
32
+ # argparse defaults from banner-seed.py.
33
+ DEFAULT_SEED = 1
34
+ DEFAULT_OUT = ".sweep/banner.svg"
35
+
36
+ # OptionParser token for a base-10 integer flag (see main). A bare token
37
+ # object is all OptionParser#accept needs as the type key.
38
+ Base10Int = Object.new
39
+
40
+ # svg-sweep.sh's `montage` knobs, verbatim: a dark contact sheet, filename
41
+ # labels, fixed 400px cells with 8px gutters. The tile column count is
42
+ # per-run (ceil(sqrt(N))), so it is computed, not stored here.
43
+ MONTAGE_LABEL = "%f"
44
+ MONTAGE_BACKGROUND = "#111"
45
+ MONTAGE_FILL = "#eee"
46
+ MONTAGE_GEOMETRY = "400x400+8+8"
47
+
48
+ # Raised when a contact sheet is requested but ImageMagick's `montage` is
49
+ # not on PATH. svg-sweep.sh exits 127 for this; as a library we surface it
50
+ # as an explicit, actionable error rather than an exit code. ImageMagick
51
+ # is an OPTIONAL dependency — everything else here runs without it.
52
+ class MontageMissing < StandardError; end
53
+
54
+ module_function
55
+
56
+ # seed_for lives on LocalRenderer (the shared seed -> palette/composition
57
+ # contract also used by the claude provider). Re-exposed so the studio and
58
+ # CLI resolve slugs without reaching across modules. zlib.crc32 of the
59
+ # UTF-8 bytes; matches pg.seed_for exactly.
60
+ def seed_for(slug)
61
+ LocalRenderer.seed_for(slug)
62
+ end
63
+
64
+ # banner-seed.py's (title, seed) rule. A non-empty --slug wins and
65
+ # reproduces that post's banner (title = slug, seed derived as the engine
66
+ # derives it); otherwise a bare --seed drives the palette/composition
67
+ # scheme directly under a synthetic "banner-seed-N" title. Returns
68
+ # [title, seed]. Empty-string slug is falsy exactly as `if args.slug:` is.
69
+ def resolve(seed: DEFAULT_SEED, slug: "")
70
+ if slug.nil? || slug.empty?
71
+ ["banner-seed-#{seed}", seed]
72
+ else
73
+ [slug, seed_for(slug)]
74
+ end
75
+ end
76
+
77
+ # Core operation, == pg.sanitize_svg(pg.render_local_svg(title, seed)).
78
+ # Returns [svg_text, notes]; notes is the sanitizer's warnings list.
79
+ def generate(seed: DEFAULT_SEED, slug: "")
80
+ title, resolved = resolve(seed: seed, slug: slug)
81
+ Sanitizer.sanitize_svg(LocalRenderer.render_local_svg(title, resolved))
82
+ end
83
+
84
+ # Just the sanitized SVG string — the studio's common case. Notes are
85
+ # dropped; for the local template they are always empty (it renders SVG
86
+ # the sanitizer accepts untouched). Callers needing notes use #generate.
87
+ def svg(seed: DEFAULT_SEED, slug: "")
88
+ generate(seed: seed, slug: slug).first
89
+ end
90
+
91
+ # Rails "SVG studio" entry point: hand back one result per seed with no
92
+ # shelling out. Each entry is { seed:, title:, svg:, notes: }. Accepts any
93
+ # Enumerable of seeds (a form may request an arbitrary set, not a range).
94
+ def render_many(seeds)
95
+ seeds.map do |seed|
96
+ svg_text, notes = generate(seed: seed)
97
+ { seed: seed, title: "banner-seed-#{seed}", svg: svg_text, notes: notes }
98
+ end
99
+ end
100
+
101
+ # svg-sweep.sh's sweep shape: seeds `first .. first+count-1` (the bash
102
+ # sweeps 1..N). Returns the same { seed:, title:, svg:, notes: } records.
103
+ def sweep(count, first: 1)
104
+ raise ArgumentError, "count must be non-negative" if count.negative?
105
+
106
+ render_many(first...(first + count))
107
+ end
108
+
109
+ # File-writing half of banner-seed.py: make the parent dir, write UTF-8,
110
+ # emit sanitizer notes to `err`. Returns the out path (what the CLI prints
111
+ # to stdout). Separate from #generate so the studio never touches disk.
112
+ def write(out, seed: DEFAULT_SEED, slug: "", err: $stderr)
113
+ svg_text, notes = generate(seed: seed, slug: slug)
114
+ notes.each { |note| err.puts("sanitizer: #{note}") }
115
+ # os.makedirs(dirname(out) or ".", exist_ok=True): dirname("banner.svg")
116
+ # is "" -> ".", and mkdir_p(".") is a harmless no-op.
117
+ dir = File.dirname(out)
118
+ FileUtils.mkdir_p(dir.empty? ? "." : dir)
119
+ # The SVG string is UTF-8; write its bytes as-is (Python opens utf-8).
120
+ File.write(out, svg_text)
121
+ out
122
+ end
123
+
124
+ # CLI port of banner-seed.py's main(): parse --seed/--slug/--out, write the
125
+ # file, print the out path to `out`. Returns a process exit code (0 on
126
+ # success, 2 on a bad argument, mirroring argparse). Exposed so a bin/ shim
127
+ # or a test can drive the exact documented CLI surface without a subprocess.
128
+ def main(argv, out: $stdout, err: $stderr)
129
+ opts = { seed: DEFAULT_SEED, slug: "", out: DEFAULT_OUT }
130
+ parser = OptionParser.new do |p|
131
+ # Base-10, like Python argparse's `type=int` (int()). OptionParser's
132
+ # built-in Integer accepts 0x/0o/0b prefixes, so `--seed 010` would be
133
+ # octal 8 in Ruby but decimal 10 in Python — a different banner.
134
+ p.accept(Base10Int, /\A\s*[-+]?\d+\s*\z/) { |s| Integer(s.strip, 10) }
135
+ p.on("--seed N", Base10Int) { |v| opts[:seed] = v }
136
+ p.on("--slug SLUG", String, "derive the seed from a post slug, as the engine does") do |v|
137
+ opts[:slug] = v
138
+ end
139
+ p.on("--out PATH", String) { |v| opts[:out] = v }
140
+ end
141
+ begin
142
+ parser.parse(argv)
143
+ rescue OptionParser::ParseError => exc
144
+ # argparse writes usage + error to stderr and exits 2. Mirror the exit
145
+ # code with an explicit message; never swallow the parse failure.
146
+ err.puts(exc.message)
147
+ return 2
148
+ end
149
+ out.puts(write(opts[:out], seed: opts[:seed], slug: opts[:slug], err: err))
150
+ 0
151
+ end
152
+
153
+ # ---- svg-sweep.sh contact-sheet logic --------------------------------
154
+
155
+ # ceil(sqrt(N)) — svg-sweep.sh's column count. Own method so the sweep and
156
+ # any caller building a montage agree on layout. Math.sqrt(N).ceil matches
157
+ # Python's math.ceil(math.sqrt(N)) (both IEEE-754, exact on perfect squares).
158
+ def tile_columns(count)
159
+ Math.sqrt(count).ceil
160
+ end
161
+
162
+ # `command -v montage` equivalent, via the ported Rasterizer.which (itself
163
+ # shutil.which-equivalent and memoized). nil when ImageMagick is absent.
164
+ def montage_path
165
+ Rasterizer.which("montage")
166
+ end
167
+
168
+ def montage_available?
169
+ !montage_path.nil?
170
+ end
171
+
172
+ # The exact `montage` argv from svg-sweep.sh, as an array (no shell). Built
173
+ # separately from #contact_sheet so the flag set can be asserted without
174
+ # ImageMagick installed. `bin` defaults to the plain name for that offline
175
+ # inspection; #contact_sheet passes the resolved absolute path.
176
+ def montage_argv(png_paths, out:, columns: nil, bin: "montage")
177
+ cols = columns || tile_columns(png_paths.length)
178
+ [bin, "-label", MONTAGE_LABEL,
179
+ "-background", MONTAGE_BACKGROUND, "-fill", MONTAGE_FILL,
180
+ "-tile", "#{cols}x", "-geometry", MONTAGE_GEOMETRY,
181
+ *png_paths.map(&:to_s), out.to_s]
182
+ end
183
+
184
+ # Stitch PNGs into one labelled contact sheet via ImageMagick `montage`.
185
+ # ImageMagick is optional: when `montage` is absent this raises
186
+ # MontageMissing with an actionable message instead of failing obscurely.
187
+ # Returns the output path on success.
188
+ def contact_sheet(png_paths, out:, columns: nil)
189
+ montage = montage_path
190
+ unless montage
191
+ raise MontageMissing,
192
+ "ImageMagick 'montage' not found on PATH; install ImageMagick " \
193
+ "(e.g. `brew install imagemagick`) to stitch contact sheets"
194
+ end
195
+ raise ArgumentError, "no PNGs to montage" if png_paths.empty?
196
+
197
+ cmd = montage_argv(png_paths, out: out, columns: columns, bin: montage)
198
+ # system returns false on non-zero exit; surface it rather than pretend
199
+ # the sheet was written.
200
+ unless system(*cmd)
201
+ raise "montage exited #{$?.exitstatus} building #{out}"
202
+ end
203
+
204
+ out
205
+ end
206
+
207
+ # Full svg-sweep.sh pipeline in Ruby, opt-in: render seeds 1..count to SVG
208
+ # under `dir`, rasterize each to PNG, then montage them into one sheet.
209
+ # Returns the contact-sheet path.
210
+ #
211
+ # The SVG->PNG step delegates to the engine's Rasterizer (another slice);
212
+ # the dev-side svg-render.sh chain (400px, ImageMagick-free) is not
213
+ # separately ported, so this reuses the engine chain and lets `montage`'s
214
+ # -geometry downscale to the same 400px cells. Both external tools are
215
+ # optional: an empty render set or a missing montage raises explicitly.
216
+ # The studio uses #sweep (in-memory) instead of this disk+subprocess path.
217
+ def sweep_contact_sheet(count, dir: ".sweep", preview_dir: ".preview",
218
+ project_root: Dir.pwd)
219
+ FileUtils.rm_rf(dir)
220
+ FileUtils.mkdir_p(dir)
221
+ FileUtils.mkdir_p(preview_dir)
222
+
223
+ pngs = []
224
+ (1..count).each do |seed|
225
+ svg_path = File.join(dir, "seed-#{seed}.svg")
226
+ write(svg_path, seed: seed)
227
+ png_path = File.join(preview_dir, "seed-#{seed}.png")
228
+ # Only montage what actually rasterized; unlike the bash's stale
229
+ # `.preview/seed-*.png` glob, this feeds exactly this run's outputs.
230
+ pngs << png_path if Rasterizer.rasterize_svg(svg_path, png_path, project_root)
231
+ end
232
+ if pngs.empty?
233
+ raise "no PNGs rendered; install a rasterizer " \
234
+ "(resvg / rsvg-convert / inkscape / cairosvg) to sweep"
235
+ end
236
+
237
+ contact_sheet(pngs, out: File.join(dir, "contact-sheet.png"))
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+
5
+ module Zer0ImageGenerator
6
+ module Svg
7
+ module Generators
8
+ # Parametric flow-field — port of `tools/svg/examples/flowfield.py`.
9
+ #
10
+ # Seed a cloud of particles, then walk each one downstream through a
11
+ # value-noise field, emitting the trail as a polyline. The canonical
12
+ # "generator with knobs": lines/steps/scale/palette are the only dials,
13
+ # every coordinate falls out of the seeded stream.
14
+ #
15
+ # The committed artwork under `docs/samples/flowfield-*.svg` and
16
+ # `docs/showcase/variants/flowfield-*.svg` was produced by the Python
17
+ # original, so this port is pinned to byte-identical output: every draw
18
+ # comes off `Support::PyRandom` in the same order the Python drew it, and
19
+ # every interpolated number is formatted to the same width the f-strings
20
+ # used.
21
+ module Flowfield
22
+ PALETTES = {
23
+ "ember" => ["#1a1423", "#f24236", "#f5a623", "#f7e8d3"].freeze,
24
+ "cyanic" => ["#0b132b", "#1c7293", "#5bc0be", "#e8f1f2"].freeze,
25
+ "moss" => ["#16211b", "#3e6b48", "#a3c9a8", "#e9edc9"].freeze,
26
+ }.freeze
27
+
28
+ DEFAULT_PALETTE = "cyanic"
29
+
30
+ # Python's `math.tau` is the precomputed double 2*pi; `Math::PI * 2` is
31
+ # the identical bit pattern, so `angle` matches to the last ULP.
32
+ TAU = Math::PI * 2
33
+
34
+ HALF = Rational(1, 2)
35
+ private_constant :HALF
36
+
37
+ class << self
38
+ # Cheap hash-based value noise with bilinear interpolation. Returns a
39
+ # callable sampler; the hash is pure integer arithmetic so it ports
40
+ # directly — Ruby and CPython agree on `>>`, `&`, and `^` for the
41
+ # negative lattice coordinates `math.floor` can produce.
42
+ def value_noise(seed)
43
+ hash = lambda do |ix, iy|
44
+ n = (ix * 374_761_393) + (iy * 668_265_263) + (seed * 1_442_695_040_888_963_407)
45
+ n = (n ^ (n >> 13)) * 1_274_126_177
46
+ # `/ 0xFFFF` is true division in Python; fdiv keeps it a Float
47
+ # instead of Ruby's integer division.
48
+ ((n ^ (n >> 16)) & 0xFFFF).fdiv(0xFFFF)
49
+ end
50
+ smooth = ->(t) { t * t * (3 - (2 * t)) }
51
+ lambda do |x, y|
52
+ ix = x.floor
53
+ iy = y.floor
54
+ fx = smooth.call(x - ix)
55
+ fy = smooth.call(y - iy)
56
+ a = hash.call(ix, iy)
57
+ b = hash.call(ix + 1, iy)
58
+ c = hash.call(ix, iy + 1)
59
+ d = hash.call(ix + 1, iy + 1)
60
+ ((a * (1 - fx)) + (b * fx)) * (1 - fy) + (((c * (1 - fx)) + (d * fx)) * fy)
61
+ end
62
+ end
63
+
64
+ # Returns the finished SVG document, newline-terminated.
65
+ def build(seed, w, h, lines, steps, scale, palette)
66
+ rng = Support::PyRandom.new(seed)
67
+ noise = value_noise(seed)
68
+ colors = PALETTES.fetch(palette)
69
+ out = []
70
+
71
+ lines.times do
72
+ x = rng.uniform(0, w)
73
+ y = rng.uniform(0, h)
74
+ pts = [[x, y]]
75
+ steps.times do
76
+ angle = noise.call(x / scale, y / scale) * TAU * 2
77
+ x += Math.cos(angle) * 4
78
+ y += Math.sin(angle) * 4
79
+ break unless -20 < x && x < w + 20 && -20 < y && y < h + 20
80
+
81
+ pts << [x, y]
82
+ end
83
+ # Too-short trails are dropped BEFORE their stroke/width/opacity
84
+ # draws — mirroring Python's `continue`, so the RNG stays in sync.
85
+ next if pts.length < 8
86
+
87
+ d = "M" + pts.map { |px, py| "#{fmt(px, 0)},#{fmt(py, 0)}" }.join(" L")
88
+ stroke = rng.choice(colors[1..])
89
+ width = rng.uniform(0.4, 2.4)
90
+ op = rng.uniform(0.25, 0.8)
91
+ out << %(<path d="#{d}" fill="none" stroke="#{stroke}" ) +
92
+ %(stroke-width="#{fmt(width, 2)}" stroke-opacity="#{fmt(op, 2)}" ) +
93
+ %(stroke-linecap="round"/>)
94
+ end
95
+
96
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{w} #{h}" ) +
97
+ "role=\"img\">\n" \
98
+ " <title>Flow field, seed #{seed}, palette #{palette}</title>\n" +
99
+ %( <rect width="#{w}" height="#{h}" fill="#{colors[0]}"/>) + "\n " +
100
+ out.join("\n ") + "\n</svg>\n"
101
+ end
102
+
103
+ # Exact correctly-rounded fixed-point, matching Python's `f"{x:.Nf}"`
104
+ # (round-half-to-even on the exact binary value). Ruby's `%f`/`round`
105
+ # round the shortest decimal instead, which diverges on ties.
106
+ def fmt(value, places)
107
+ negative = value.negative? || (value.zero? && (1.0 / value).negative?)
108
+ scaled = value.abs.to_r * (10**places)
109
+ whole = scaled.floor
110
+ remainder = scaled - whole
111
+ whole += 1 if remainder > HALF || (remainder == HALF && whole.odd?)
112
+
113
+ digits = whole.to_s.rjust(places + 1, "0")
114
+ text = places.zero? ? digits : "#{digits[0...-places]}.#{digits[-places..]}"
115
+ negative ? "-#{text}" : text
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ if __FILE__ == $PROGRAM_NAME
124
+ require "fileutils"
125
+
126
+ opts = { seed: 1, out: "art/flowfield.svg", width: 800, height: 800,
127
+ lines: 240, steps: 70, scale: 180.0,
128
+ palette: Zer0ImageGenerator::Svg::Generators::Flowfield::DEFAULT_PALETTE }
129
+ argv = ARGV.dup
130
+ until argv.empty?
131
+ flag = argv.shift
132
+ val = argv.shift
133
+ case flag
134
+ when "--seed" then opts[:seed] = Integer(val)
135
+ when "--out" then opts[:out] = val
136
+ when "--width" then opts[:width] = Integer(val)
137
+ when "--height" then opts[:height] = Integer(val)
138
+ when "--lines" then opts[:lines] = Integer(val)
139
+ when "--steps" then opts[:steps] = Integer(val)
140
+ when "--scale" then opts[:scale] = Float(val)
141
+ when "--palette" then opts[:palette] = val
142
+ else raise ArgumentError, "unknown flag #{flag.inspect}"
143
+ end
144
+ end
145
+
146
+ dir = File.dirname(opts[:out])
147
+ FileUtils.mkdir_p(dir.empty? ? "." : dir)
148
+ svg = Zer0ImageGenerator::Svg::Generators::Flowfield.build(
149
+ opts[:seed], opts[:width], opts[:height], opts[:lines],
150
+ opts[:steps], opts[:scale], opts[:palette]
151
+ )
152
+ File.write(opts[:out], svg)
153
+ puts opts[:out]
154
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/py_random"
4
+ # The retro pixel toolkit is a sibling slice
5
+ # (lib/zer0_image_generator/svg/pixel_kit.rb). Load it unless a caller — a test
6
+ # harness pinning byte-identity — has already supplied the namespace; the
7
+ # `defined?` guard lets that stub stand in without a second load, and a genuine
8
+ # absence still surfaces as an explicit LoadError from require_relative.
9
+ require_relative "../pixel_kit" unless defined?(Zer0ImageGenerator::Svg::PixelKit)
10
+
11
+ module Zer0ImageGenerator
12
+ module Svg
13
+ module Generators
14
+ # Procedural fixed-shooter scene — port of `tools/svg/examples/invaders.py`.
15
+ #
16
+ # Each wave's alien species are *generated*: random cells in a half-grid,
17
+ # mirrored (hmirror) — bilateral symmetry is what makes random blobs read
18
+ # as creatures. One alien is mid-explosion; the symmetry break is the
19
+ # subject.
20
+ #
21
+ # Byte-identity contract: `docs/samples/invaders-*.svg` and
22
+ # `docs/showcase/variants/invaders-dusk-3.svg` were produced here, so every
23
+ # draw must come off `Support::PyRandom` in the Python order. `alien` does
24
+ # rejection sampling, so the number of draws is data-dependent — the
25
+ # density predicate has to match exactly or the whole stream desyncs.
26
+ module Invaders
27
+ SHIP = [
28
+ ".....#.....",
29
+ "....l#l....",
30
+ ".l.l###l.l.",
31
+ "###########",
32
+ "l#l#l#l#l#l",
33
+ ].freeze
34
+
35
+ BUNKER = [
36
+ ".########.",
37
+ "##########",
38
+ "##########",
39
+ "###....###",
40
+ "##......##",
41
+ ].freeze
42
+
43
+ MINI_SHIP = [
44
+ "..#..",
45
+ ".###.",
46
+ "#####",
47
+ ].freeze
48
+
49
+ BOOM = [
50
+ "#..#.#..#",
51
+ ".#..#..#.",
52
+ "..#####..",
53
+ ".#..#..#.",
54
+ "#..#.#..#",
55
+ ].freeze
56
+
57
+ DEFAULT_PALETTE = "cyanic"
58
+
59
+ class << self
60
+ # Random half-grid, mirrored. Reject sprites too sparse or too dense
61
+ # to read as a creature. The bounds are `0.35..0.75` of the half-grid
62
+ # cell count as floats, compared against the integer mass — mirror the
63
+ # comparison exactly, since a stricter/looser predicate changes how
64
+ # many times the loop redraws and shifts every later value.
65
+ def alien(rng, half_w = 6, height = 8)
66
+ loop do
67
+ rows = Array.new(height) do
68
+ Array.new(half_w) { rng.random < 0.55 ? "#" : "." }.join
69
+ end
70
+ mass = rows.sum { |row| row.count("#") }
71
+ lo = 0.35 * half_w * height
72
+ hi = 0.75 * half_w * height
73
+ return Svg::PixelKit.hmirror(rows) if mass >= lo && mass <= hi
74
+ end
75
+ end
76
+
77
+ def build(seed, palette = DEFAULT_PALETTE)
78
+ rng = Support::PyRandom.new(seed)
79
+ pal = Svg::PixelKit::PALETTES.fetch(palette)
80
+ canvas = Svg::Canvas.new(64, 48, px: 12, bg: pal[0])
81
+
82
+ # Starfield, sparse and uneven — heavier toward the top. The color
83
+ # draw happens only when the placement test passes; keep it inside
84
+ # the branch so the stream matches.
85
+ 70.times do
86
+ col = rng.randrange(64)
87
+ row = rng.randrange(44)
88
+ if rng.random < ((1.0 - (row / 44.0)) * 0.8)
89
+ canvas.set(col, row, rng.random < 0.6 ? pal[1] : pal[2])
90
+ end
91
+ end
92
+
93
+ # Three ranks of aliens, one species per rank, 5 across
94
+ species = Array.new(3) { alien(rng) }
95
+ rank_color = [pal[5], pal[4], pal[3]]
96
+ hit = [rng.randrange(3), rng.randrange(5)] # one alien mid-explosion
97
+ 3.times do |rank|
98
+ 5.times do |i|
99
+ col = 1 + (i * 13)
100
+ row = 5 + (rank * 10)
101
+ if [rank, i] == hit
102
+ canvas.blit(BOOM, { "#" => pal[4] }, col + 1, row + 1)
103
+ else
104
+ canvas.blit(species[rank], { "#" => rank_color[rank] }, col, row)
105
+ end
106
+ end
107
+ end
108
+
109
+ # A shot on its way up from the player
110
+ shot_col = 1 + (rng.randrange(5) * 13) + 5
111
+ canvas.fill(shot_col, 33, 1, 3, pal[5])
112
+
113
+ # Bunkers, ship, ground, lives
114
+ 3.times { |i| canvas.blit(BUNKER, { "#" => pal[2] }, 5 + (i * 22), 36) }
115
+ canvas.blit(SHIP, { "#" => pal[4], "l" => pal[3] }, 26, 41)
116
+ canvas.fill(0, 46, 64, 1, pal[2])
117
+ 3.times { |i| canvas.blit(MINI_SHIP, { "#" => pal[2] }, 2 + (i * 7), 1) }
118
+
119
+ canvas.to_svg("Invader wave, seed #{seed}, palette #{palette}", crt: true)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end