pwn 0.5.760 → 0.5.761

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.
data/lib/pwn/banner.rb CHANGED
@@ -2,25 +2,32 @@
2
2
 
3
3
  module PWN
4
4
  # Static banners and pure, PWN-branded retro ASCII/color loops: falling_blocks,
5
- # snake, pong, and asteroids. mini_frame returns fresh rows on a borderless canvas;
5
+ # snake, pacman, asteroids, galaga and frogger. mini_frame returns fresh rows on a borderless canvas;
6
6
  # use equal width and height (5..16) for complete square artwork. Dimensions
7
- # clamp to 0..16 (colored blocks: 0..64). Set branding: false for an unbranded
7
+ # clamp to 0..16 (colored blocks/Pac-Man/Galaga/Frogger: 0..64). Set branding: false for an unbranded
8
8
  # pane with the wordmark row reclaimed for play; tiny panes then stay blank. The caller
9
9
  # samples mini_names once per session and advances the explicit frame index
10
- # every mini_frame_seconds(name:) (0.05 for Pong/Asteroids, 0.1 otherwise).
10
+ # every mini_frame_seconds(name:) (0.05 for Asteroids/Galaga, 0.1 otherwise).
11
11
  # mini_cells replays 1800 seeded gameplay
12
12
  # frames (MINI_FRAME_COUNT); mini_frame retains its legacy 60-frame ASCII loop.
13
- # These are decorative game simulations, not interactive games or telemetry.
13
+ # Inactive panes use seeded demos; MiniGame supplies caller-owned playable state.
14
14
  # mini_cells uses local Random.new with seed 73 by default; callers may keep
15
15
  # a different integer seed per session. Six immutable replays are cached.
16
- # Tetrominoes stack and clear; snake pursues food and grows; Pong rallies
17
- # include imperfect paddle tracking, misses and score pips between serves.
16
+ # Tetrominoes stack and clear; snake pursues food and grows; Pac-Man eats pellets
17
+ # in connected mazes with chasing ghosts, energizers, recovery and new levels.
18
18
  # Blocks use horizontal half-cells with independent foreground/background
19
19
  # colors, filling the pane below PWN (up to 64 cells). Other games retain
20
- # quadrant pixels; snake stays centered with food/head color priority.
20
+ # quadrant pixels; decorative snake stays centered with food/head color priority.
21
21
  # White line-clear flashes and game-over/reset wipes are intentional.
22
- # Pong uses half-cell motion and one-cell-high paddles without speeding up play.
23
- # Asteroids uses five-pixel cyan arrowheads on a half-cell grid, sparse small
22
+ # Pac-Man and ghosts occupy one cell each so even eight-cell panes retain
23
+ # internal walls and corridors. Directional mouths and compact ghost glyphs
24
+ # trade eye detail for maze geometry; spanning-tree mazes vary each round.
25
+ # Galaga has formations, dives, hostile fire, explosions and advancing waves;
26
+ # arrows move and Space fires. Frogger arrows hop through moving traffic and
27
+ # onto carrying logs to fill goals, recover from deaths and advance levels.
28
+ # Both start on input, run automatic demos when inactive and retain the UI owner.
29
+ # Legacy name: :pong remains accepted by direct APIs but is not selectable.
30
+ # Asteroids uses filled cyan ships on a 2x4 Braille raster, sparse small
24
31
  # white rocks, yellow shots and red thrust; wrapped collisions split real rocks.
25
32
  # Static Banner modules autoload only when needed. For more information, see:
26
33
  # http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html
@@ -49,10 +56,14 @@ module PWN
49
56
  MINI_SEED = 73
50
57
  MINI_CACHE_LIMIT = 6
51
58
  MINI_ASCII_FRAME_COUNT = 60
52
- # Five quadrant pixels, not a scaled-down vector sprite. Eight stable headings
59
+ # Filled fine-dot silhouettes, with a solid spine and tapered nose. Eight headings
53
60
  # keep the nose legible even in the 5-column pane; physics still turns smoothly.
54
61
  MINI_SHIP = Array.new(8) do |heading|
55
- points = heading.even? ? [[-1, -1], [0, -1], [1, 0], [0, 1], [-1, 1]] : [[0, -1], [1, 0], [1, 1], [0, 1], [-1, 0]]
62
+ points = if heading.even?
63
+ [[-1, -2], [-1, -1], [0, -1], [-1, 0], [0, 0], [1, 0], [2, 0], [-1, 1], [0, 1], [-1, 2]]
64
+ else
65
+ [[-1, -2], [-1, -1], [0, -1], [-2, -1], [-1, 0], [0, 0], [1, 0], [0, 1], [1, 1], [2, 2]]
66
+ end
56
67
  (heading / 2).times { points = points.map { |x, y| [-y, x] } }
57
68
  points.map(&:freeze).freeze
58
69
  end.freeze
@@ -63,7 +74,14 @@ module PWN
63
74
  'c' => ['█', :cyan], 'y' => ['█', :yellow], 'm' => ['█', :magenta],
64
75
  'b' => ['█', :blue], 'r' => ['█', :red], 'g' => ['█', :green],
65
76
  'w' => ['█', :white], 'f' => ['▄', :red], 'o' => ['▀', :yellow],
66
- 'l' => ['▌', :cyan], 'q' => ['▐', :magenta], ':' => ['░', :blue]
77
+ 'l' => ['▌', :cyan], 'q' => ['▐', :magenta], ':' => ['░', :blue],
78
+ '#' => ['▒', :blue], '.' => ['·', :white], '*' => ['●', :yellow],
79
+ '>' => ['ᗧ', :yellow], '<' => ['ᗤ', :yellow], '^' => ['ᗢ', :yellow], 'v' => ['ᗣ', :yellow],
80
+ 'O' => ['●', :yellow],
81
+ 'A' => ['⣾', :cyan], 'E' => ['⣜', :red], 'B' => ['⣷', :magenta],
82
+ '!' => ['│', :yellow], 'i' => ['•', :red], '+' => ['✹', :yellow],
83
+ 'H' => ['⣏', :green], '=' => ['═', :yellow], '~' => ['≈', :blue], 'T' => ['▰', :red], 'U' => ['▱', :green],
84
+ 'R' => ['⣻', :red], 'M' => ['⣻', :magenta], 'C' => ['⣻', :cyan], 'F' => ['⣻', :blue]
67
85
  }.merge(%i[cyan magenta yellow white green red blue].each_with_index.flat_map do |color, index|
68
86
  ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'.chars.each_with_index.map do |glyph, mask|
69
87
  [(256 + (index * 16) + mask).chr(Encoding::UTF_8), [glyph, color]]
@@ -80,6 +98,14 @@ module PWN
80
98
  end
81
99
  [(1024 + (((top_index * 8) + bottom_index) * 16) + mask).chr(Encoding::UTF_8), cell]
82
100
  end
101
+ end.to_h).merge(%i[cyan magenta yellow white green red blue].each_with_index.flat_map do |color, index|
102
+ 256.times.map do |mask|
103
+ [(4096 + (index * 256) + mask).chr(Encoding::UTF_8), [(0x2800 + mask).chr(Encoding::UTF_8), color]]
104
+ end
105
+ end.to_h).merge(%i[cyan magenta yellow white green red blue].each_with_index.flat_map do |color, index|
106
+ [['•', :white, color], ['▙', color], ['▟', color], ['⠶', :white, color]].each_with_index.map do |cell, part|
107
+ [(8000 + (index * 4) + part).chr(Encoding::UTF_8), cell]
108
+ end
83
109
  end.to_h).transform_values(&:freeze).freeze
84
110
  MINI_SHAPES = [
85
111
  [[0, 0], [1, 0], [2, 0], [3, 0]],
@@ -96,7 +122,7 @@ module PWN
96
122
  # Use equal width and height for a square; 5..16 retains the complete artwork.
97
123
  # Smaller panes degrade to a wordmark. Caller owns randomness, clock and IO.
98
124
  # PWN::Banner.mini_frame(
99
- # name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
125
+ # name: 'optional - falling_blocks, snake, pacman, asteroids, galaga or frogger; legacy pong accepted; defaults to falling_blocks',
100
126
  # frame: 'optional - integer frame index, defaults to zero and wraps at 60',
101
127
  # width: 'optional - output columns clamped to zero through 16, defaults to 16',
102
128
  # height: 'optional - output rows clamped to zero through 16, defaults to 16',
@@ -105,7 +131,7 @@ module PWN
105
131
 
106
132
  public_class_method def self.mini_frame(opts = {})
107
133
  name = (opts[:name] || :falling_blocks).to_s
108
- raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
134
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless (mini_names + [:pong]).map(&:to_s).include?(name)
109
135
 
110
136
  phase = (opts[:frame] || 0).to_i % MINI_ASCII_FRAME_COUNT
111
137
  width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, MINI_WIDTH)
@@ -120,7 +146,12 @@ module PWN
120
146
  rows[0] = 'PWN'[0, width].center(width)
121
147
  return rows if width < 5 || height < 5
122
148
 
123
- send("mini_#{name}", rows: rows, phase: phase, width: width, height: height)
149
+ if %w[galaga frogger].include?(name)
150
+ frame = mini_replay(name: name, width: width, height: height, seed: MINI_SEED)[phase + 6]
151
+ frame[:rows].drop(1).each_with_index { |row, y| rows[y + 1] = row.tr('AEB!i+H=~TU', 'AVV|*!F=~CH').gsub(/[\u1000-\u16ff]/, 'A') }
152
+ else
153
+ send("mini_#{name}", rows: rows, phase: phase, width: width, height: height)
154
+ end
124
155
  branding ? rows : rows.drop(1)
125
156
  end
126
157
 
@@ -129,7 +160,7 @@ module PWN
129
160
  # PWN::Banner.mini_names
130
161
 
131
162
  public_class_method def self.mini_names
132
- %i[falling_blocks snake pong asteroids]
163
+ %i[falling_blocks snake pacman asteroids galaga frogger]
133
164
  end
134
165
 
135
166
  # Supported Method Parameters::
@@ -140,9 +171,9 @@ module PWN
140
171
 
141
172
  public_class_method def self.mini_frame_seconds(opts = {})
142
173
  name = (opts[:name] || :falling_blocks).to_s
143
- raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
174
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless (mini_names + [:pong]).map(&:to_s).include?(name)
144
175
 
145
- %w[pong asteroids].include?(name) ? 0.05 : MINI_FRAME_SECONDS
176
+ %w[pong asteroids galaga].include?(name) ? 0.05 : MINI_FRAME_SECONDS
146
177
  end
147
178
 
148
179
  # Supported Method Parameters::
@@ -150,12 +181,12 @@ module PWN
150
181
  # Each fresh cell has glyph, foreground and background keys; no ANSI or IO.
151
182
  # Square means terminal-cell dimensions, not physical pixels. Caller owns borders.
152
183
  # Use mini_frame_seconds for cadence; 1800 frames span 90 or 180 seconds.
153
- # Colored blocks fill the canvas with half-cell pieces; other games cap at 16.
184
+ # Blocks, Pac-Man, Galaga and Frogger fill up to 64 cells; other games cap at 16.
154
185
  # PWN::Banner.mini_cells(
155
- # name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
186
+ # name: 'optional - falling_blocks, snake, pacman, asteroids, galaga or frogger; legacy pong accepted; defaults to falling_blocks',
156
187
  # frame: 'optional - integer index, defaults to zero and wraps at 1800',
157
- # width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
158
- # height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
188
+ # width: 'optional - columns clamped to 0..64 for blocks/Pac-Man/Galaga/Frogger, 0..16 otherwise; defaults to 16',
189
+ # height: 'optional - rows clamped to 0..64 for blocks/Pac-Man/Galaga/Frogger, 0..16 otherwise; defaults to 16',
159
190
  # seed: 'optional - integer local PRNG seed, defaults to 73; retain per session',
160
191
  # branding: 'optional - include the legacy wordmark, defaults to true; false reclaims its row'
161
192
  # )
@@ -163,9 +194,9 @@ module PWN
163
194
  public_class_method def self.mini_cells(opts = {})
164
195
  name = (opts[:name] || :falling_blocks).to_s
165
196
  phase = (opts[:frame] || 0).to_i % MINI_FRAME_COUNT
166
- raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
197
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless (mini_names + [:pong]).map(&:to_s).include?(name)
167
198
 
168
- limit = name == 'falling_blocks' ? 64 : MINI_WIDTH
199
+ limit = %w[falling_blocks pacman galaga frogger].include?(name) ? 64 : MINI_WIDTH
169
200
  width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, limit)
170
201
  height = (opts[:height] || MINI_HEIGHT).to_i.clamp(0, limit)
171
202
  return [] if width.zero? || height.zero?
@@ -189,18 +220,19 @@ module PWN
189
220
 
190
221
  # Caller-owned simulation: yields on the UI thread, never reads input or
191
222
  # accumulates a replay. The caller supplies the clock and normalized keys.
223
+ # demo: true supplies automatic controls without a full replay construction.
192
224
  class MiniGame
193
- def initialize(name:, width:, height:, seed: MINI_SEED)
225
+ def initialize(name:, width:, height:, seed: MINI_SEED, demo: false)
194
226
  @name = name.to_s
195
- raise ArgumentError, "Unknown mini-banner: #{name}" unless Banner.mini_names.map(&:to_s).include?(@name)
227
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless (Banner.mini_names + [:pong]).map(&:to_s).include?(@name)
196
228
 
197
- limit = @name == 'falling_blocks' ? 64 : MINI_WIDTH
229
+ limit = %w[falling_blocks pacman galaga frogger].include?(@name) ? 64 : MINI_WIDTH
198
230
  @width = width.clamp(5, limit)
199
231
  @height = height.clamp(5, limit)
200
232
  @keys = []
201
233
  @simulation = Fiber.new do
202
- options = { frames: self, width: @width, height: @name == 'falling_blocks' ? @height * 2 : @height,
203
- rng: Random.new(seed), player: self }
234
+ options = { frames: self, width: @name == 'snake' ? @width * 2 : @width, height: %w[falling_blocks snake].include?(@name) ? @height * 2 : @height,
235
+ rng: Random.new(seed), player: demo ? nil : self }
204
236
  Banner.send("mini_simulate_#{@name}", options)
205
237
  end
206
238
  end
@@ -222,7 +254,7 @@ module PWN
222
254
  def cells(frame)
223
255
  rows = frame[:rows].drop(1)
224
256
  rows = Banner.send(:mini_block_board, board: rows) if @name == 'falling_blocks'
225
- rows = Banner.send(:mini_quarter_board, board: rows, name: @name) if @name == 'snake'
257
+ rows = Banner.send(:mini_quarter_board, board: rows, name: @name, full: true) if @name == 'snake'
226
258
  rows.map do |row|
227
259
  row.chars.map do |pixel|
228
260
  glyph, foreground, background = MINI_PIXELS.fetch(pixel)
@@ -314,14 +346,14 @@ module PWN
314
346
  width = source.first.length
315
347
  height = source.length
316
348
 
317
- board = Array.new(height) { ' ' * width }
349
+ board = Array.new(opts[:full] ? height / 2 : height) { ' ' * (opts[:full] ? width / 2 : width) }
318
350
  colors = { 'c' => 0, 'm' => 1, 'y' => 2, 'w' => 3, 'g' => 4, 'r' => 5, 'f' => 5, 'b' => 6 }
319
351
  points = source.each_with_index.flat_map do |row, y|
320
352
  row.chars.each_with_index.filter_map { |pixel, x| [x, y, pixel] unless pixel == ' ' }
321
353
  end
322
354
  points.sort_by! { |_, _, pixel| { 'f' => 2, 'y' => 1 }.fetch(pixel, 0) } if opts[:name] == 'snake'
323
355
  points.each do |x, y, pixel|
324
- mini_pixel(board: board, x: x + (width / 2), y: y + (height / 2), color: colors.fetch(pixel), merge: true)
356
+ mini_pixel(board: board, x: x + (opts[:full] ? 0 : width / 2), y: y + (opts[:full] ? 0 : height / 2), color: colors.fetch(pixel), merge: true)
325
357
  end
326
358
  board
327
359
  end
@@ -540,20 +572,25 @@ module PWN
540
572
  spaces = (0...height).flat_map { |y| (0...width).map { |x| [x, y] } }
541
573
  player = opts[:player]
542
574
  while frames.length < MINI_FRAME_COUNT
543
- y = rng.rand(height)
575
+ y = player ? height / 2 : rng.rand(height)
544
576
  body = [[2, y], [1, y], [0, y]]
545
577
  direction = [1, 0]
546
578
  food = (spaces - body).sample(random: rng)
547
579
  steps = 0
580
+ started = !player
548
581
  loop do
549
582
  board = Array.new(height) { ' ' * width }
550
583
  body.each_with_index { |(x, row), index| board[row][x] = index.zero? ? 'y' : 'g' }
551
584
  board[food[1]][food[0]] = 'f' if food
552
- mini_emit(frames: frames, board: board, ticks: player ? 1 : 2, state: { body: body.map(&:dup), food: food&.dup })
585
+ mini_emit(frames: frames, board: board, ticks: player ? 1 : 2, event: started ? :play : :ready, state: { body: body.map(&:dup), food: food&.dup })
553
586
  break if frames.length >= MINI_FRAME_COUNT
554
587
 
555
588
  if player
556
- turn = player.controls.filter_map { |key| { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[key] }.find do |dx, dy|
589
+ keys = player.controls
590
+ started ||= keys.intersect?(%i[up down left right])
591
+ next unless started
592
+
593
+ turn = keys.filter_map { |key| { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[key] }.find do |dx, dy|
557
594
  [dx, dy] != direction && [dx, dy] != direction.map(&:-@)
558
595
  end
559
596
  direction = turn if turn
@@ -564,7 +601,13 @@ module PWN
564
601
  target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng)
565
602
  end
566
603
  if !food || !target || steps >= width * height * 4
567
- mini_reset_board(frames: frames, board: board)
604
+ if player
605
+ # Do not spend seconds in the decorative wipe ignoring arrows.
606
+ # A brief collision flash is followed by another input-gated round.
607
+ mini_emit(frames: frames, board: board.map { |row| row.gsub(/[^ ]/, 'w') }, event: :game_over)
608
+ else
609
+ mini_reset_board(frames: frames, board: board)
610
+ end
568
611
  break
569
612
  end
570
613
  body.unshift(target)
@@ -579,6 +622,445 @@ module PWN
579
622
  end
580
623
  end
581
624
 
625
+ # A connected lattice with broad corridors and four corner energizers.
626
+ # Even dimensions keep the final interior row/column open, too.
627
+ private_class_method def self.mini_pacman_round(opts = {})
628
+ width, height = opts.values_at(:width, :height)
629
+ rng = opts[:rng] || Random.new(MINI_SEED)
630
+ maze = Array.new(height) { '#' * width }
631
+ maze[1][1] = ' '
632
+ stack = [[1, 1]]
633
+ # Randomized depth-first spanning tree, then braid extra loops. Every
634
+ # opened cell touches the connected tree; no rejection flood fills.
635
+ until stack.empty?
636
+ x, y = stack.last
637
+ choices = [[2, 0], [-2, 0], [0, 2], [0, -2]].select do |dx, dy|
638
+ (x + dx).between?(1, width - 2) && (y + dy).between?(1, height - 2) && maze[y + dy][x + dx] == '#'
639
+ end
640
+ if choices.empty?
641
+ stack.pop
642
+ else
643
+ dx, dy = choices.sample(random: rng)
644
+ maze[y + (dy / 2)][x + (dx / 2)] = ' '
645
+ maze[y + dy][x + dx] = ' '
646
+ stack << [x + dx, y + dy]
647
+ end
648
+ end
649
+ (1...(height - 1)).each do |y|
650
+ (1...(width - 1)).each do |x|
651
+ ring = x == 1 || y == 1 || x == width - 2 || y == height - 2
652
+ maze[y][x] = ' ' if ring || (rng.rand < 0.18 && !mini_pacman_neighbors(point: [x, y], maze: maze).empty?)
653
+ maze[y][x] = rng.rand < 0.5 ? '#' : ' ' if !ring && width <= 6 && height <= 6
654
+ end
655
+ end
656
+ spaces = (0...height).flat_map { |y| (0...width).filter_map { |x| [x, y] if maze[y][x] == ' ' } }
657
+ corners = [[1, 1], [width - 2, 1], [1, height - 2], [width - 2, height - 2]]
658
+ start = [1, height - 2]
659
+ homes = [[width - 2, 1], [width - 2, height - 2], [1, 1]].take(opts[:ghost_count] || (width < 8 ? 1 : 3))
660
+ { maze: maze, pacman: start, direction: :right, buffered: nil, pellets: spaces - corners - [start], power: corners,
661
+ ghosts: homes.map.with_index { |point, index| { position: point, home: point.dup, color: %w[R M C][index], delay: 3 + (index * 4) } },
662
+ frightened: 0, score: opts[:score] || 0, level: opts[:level] || 1, started: false, tick: 0 }
663
+ end
664
+
665
+ private_class_method def self.mini_pacman_snapshot(opts = {})
666
+ state = opts[:state]
667
+ board = state[:maze].map(&:dup)
668
+ state[:pellets].each { |x, y| board[y][x] = '.' }
669
+ state[:power].each { |x, y| board[y][x] = '*' }
670
+ state[:ghosts].each do |ghost|
671
+ x, y = ghost[:position]
672
+ board[y][x] = state[:frightened].positive? ? 'F' : ghost[:color]
673
+ end
674
+ x, y = state[:pacman]
675
+ board[y][x] = { right: '>', left: '<', up: '^', down: 'v' }.fetch(state[:direction])
676
+ board[y][x] = 'O' if state[:tick] % 3 == 2
677
+ board[y][x] = 'w' if opts[:event] == :game_over
678
+ board.map! { |row| row.tr('#', 'w') } if opts[:event] == :level_complete
679
+ # Coordinates and maze rows are replaced, never mutated. Share these
680
+ # immutable leaves instead of serializing the whole maze on every tick.
681
+ snapshot = state.merge(pellets: state[:pellets].dup, power: state[:power].dup, ghosts: state[:ghosts].map(&:dup))
682
+ mini_emit(frames: opts[:frames], board: board, state: snapshot, event: opts[:event] || :ready)
683
+ end
684
+
685
+ private_class_method def self.mini_pacman_neighbors(opts = {})
686
+ x, y = opts[:point]
687
+ maze = opts[:maze]
688
+ { right: [x + 1, y], left: [x - 1, y], up: [x, y - 1], down: [x, y + 1] }.select do |_, (nx, ny)|
689
+ ny.between?(0, maze.length - 1) && nx.between?(0, maze.first.length - 1) && maze[ny][nx] != '#'
690
+ end
691
+ end
692
+
693
+ private_class_method def self.mini_pacman_move(opts = {})
694
+ state = opts[:state]
695
+ keys = opts[:keys] || []
696
+ turn = keys.reverse.find { |key| %i[up down left right].include?(key) }
697
+ state[:buffered] = turn if turn
698
+ state[:started] ||= !turn.nil?
699
+ return :ready unless state[:started]
700
+
701
+ state[:tick] += 1
702
+ state[:frightened] -= 1 if state[:frightened].positive?
703
+ return :play unless ((state[:tick] - 1) % 3).zero?
704
+
705
+ neighbors = mini_pacman_neighbors(point: state[:pacman], maze: state[:maze])
706
+ state[:direction] = state[:buffered] if neighbors.key?(state[:buffered])
707
+ state[:pacman] = neighbors.fetch(state[:direction], state[:pacman])
708
+ state[:score] += 10 if state[:pellets].delete(state[:pacman])
709
+ event = :play
710
+ if state[:power].delete(state[:pacman])
711
+ state[:score] += 50
712
+ state[:frightened] = 70
713
+ event = :power
714
+ end
715
+ collision = mini_pacman_contact(state: state)
716
+ return collision if collision == :game_over
717
+
718
+ event = collision || event
719
+ mini_pacman_ghosts(state: state, rng: opts[:rng] || Random.new(MINI_SEED))
720
+ collision = mini_pacman_contact(state: state)
721
+ return collision if collision == :game_over
722
+ return :level_complete if state[:pellets].empty? && state[:power].empty?
723
+
724
+ collision || event
725
+ end
726
+
727
+ private_class_method def self.mini_pacman_contact(opts = {})
728
+ state = opts[:state]
729
+ event = nil
730
+ state[:ghosts].each do |ghost|
731
+ next if ghost[:returning]
732
+ next unless ghost[:position] == state[:pacman]
733
+ return :game_over if state[:frightened].zero?
734
+
735
+ state[:score] += 200
736
+ ghost[:position] = ghost[:home].dup
737
+ ghost[:delay] = 12
738
+ ghost[:returning] = true
739
+ event = :ghost_eaten
740
+ end
741
+ event
742
+ end
743
+
744
+ # Breadth-first routing cannot get trapped behind a wall or circle a pellet.
745
+ private_class_method def self.mini_pacman_route(opts = {})
746
+ queue = [[opts[:point], nil]]
747
+ seen = { opts[:point] => true }
748
+ cursor = 0
749
+ while cursor < queue.length
750
+ point, first = queue[cursor]
751
+ cursor += 1
752
+ return first if first && opts[:targets].include?(point)
753
+
754
+ mini_pacman_neighbors(point: point, maze: opts[:maze]).to_a.shuffle(random: opts[:rng]).each do |key, neighbor|
755
+ next if seen[neighbor]
756
+
757
+ seen[neighbor] = true
758
+ queue << [neighbor, first || key]
759
+ end
760
+ end
761
+ nil
762
+ end
763
+
764
+ private_class_method def self.mini_pacman_ghosts(opts = {})
765
+ state, rng = opts.values_at(:state, :rng)
766
+ state[:ghosts].each do |ghost|
767
+ if ghost[:delay].positive?
768
+ ghost[:delay] -= 1
769
+ next
770
+ end
771
+ ghost[:returning] = false
772
+ next unless ((state[:tick] - 1) % 6).zero?
773
+
774
+ neighbors = mini_pacman_neighbors(point: ghost[:position], maze: state[:maze])
775
+ key = if state[:frightened].positive?
776
+ neighbors.keys.shuffle(random: rng).max_by do |direction|
777
+ point = neighbors[direction]
778
+ (point[0] - state[:pacman][0]).abs + (point[1] - state[:pacman][1]).abs
779
+ end
780
+ else
781
+ mini_pacman_route(point: ghost[:position], targets: [state[:pacman]], maze: state[:maze], rng: rng)
782
+ end
783
+ ghost[:position] = neighbors.fetch(key, ghost[:position])
784
+ end
785
+ end
786
+
787
+ private_class_method def self.mini_simulate_pacman(opts = {})
788
+ # Maze topology takes priority over actor detail, including the normal
789
+ # eight-cell console pane. Enlarged tiles reduced it to a 5x5 ring.
790
+ round = { width: opts[:width], height: opts[:height],
791
+ ghost_count: opts[:width] < 12 ? 1 : 3, rng: opts[:rng] }
792
+ state = mini_pacman_round(round)
793
+ frames = opts[:frames]
794
+ event = :ready
795
+ while frames.length < MINI_FRAME_COUNT
796
+ mini_pacman_snapshot(frames: frames, state: state, event: event)
797
+ keys = if opts[:player]
798
+ opts[:player].controls
799
+ elsif (state[:tick] % 3).zero?
800
+ [mini_pacman_route(point: state[:pacman], targets: state[:power] + state[:pellets], maze: state[:maze], rng: opts[:rng])]
801
+ end
802
+ event = mini_pacman_move(state: state, keys: keys, rng: opts[:rng])
803
+ next unless %i[game_over level_complete].include?(event)
804
+
805
+ 5.times { mini_pacman_snapshot(frames: frames, state: state, event: event) }
806
+ opts[:player]&.controls
807
+ level = event == :level_complete ? state[:level] + 1 : 1
808
+ score = event == :level_complete ? state[:score] : 0
809
+ state = mini_pacman_round(round.merge(level: level, score: score))
810
+ event = :ready
811
+ end
812
+ end
813
+
814
+ private_class_method def self.mini_frogger_round(opts = {})
815
+ width, height = opts.values_at(:width, :height)
816
+ rng = opts[:rng]
817
+ level = opts[:level] || 1
818
+ bank = height >= 5 ? height / 2 : -1
819
+ lanes = (1...(height - 1)).filter_map do |y|
820
+ next if y == bank
821
+
822
+ water = y < height / 2
823
+ { y: y, water: water, offset: rng.rand * width, period: width.to_f,
824
+ length: water ? width * 0.65 : [width * 0.22, 1.0].max,
825
+ speed: (y.even? ? -1 : 1) * [0.06 + (rng.rand * 0.04) + (level * 0.015), 0.2].min }
826
+ end
827
+ { frog: [width / 2.0, height - 1], lanes: lanes, goals: width < 8 ? [width / 2] : [1, width / 2, width - 2],
828
+ filled: [], level: level, score: opts[:score] || 0, tick: 0, started: false, explosion: 0 }
829
+ end
830
+
831
+ private_class_method def self.mini_frogger_contact(opts = {})
832
+ state = opts[:state]
833
+ x, y = state[:frog]
834
+ return :game_over unless x.between?(0, opts[:width] - 1)
835
+
836
+ if y.zero?
837
+ goal = state[:goals].find { |slot| (slot - x).abs <= 0.55 && !state[:filled].include?(slot) }
838
+ return :game_over unless goal
839
+
840
+ state[:filled] << goal
841
+ state[:score] += 100
842
+ state[:frog] = [opts[:width] / 2.0, opts[:height] - 1]
843
+ return state[:filled].length == state[:goals].length ? :level_complete : :goal
844
+ end
845
+ lane = state[:lanes].find { |row| row[:y] == y }
846
+ return :play unless lane
847
+
848
+ occupied = ((x - lane[:offset]) % lane[:period]) < lane[:length]
849
+ occupied == lane[:water] ? :play : :game_over
850
+ end
851
+
852
+ private_class_method def self.mini_frogger_move(opts = {})
853
+ state = opts[:state]
854
+ keys = (opts[:keys] || []).select { |key| %i[left right up down].include?(key) }
855
+ state[:started] ||= !keys.empty?
856
+ return :ready unless state[:started]
857
+
858
+ state[:tick] += 1
859
+ keys.each do |key|
860
+ dx, dy = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] }.fetch(key)
861
+ state[:frog] = [(state[:frog][0] + dx).clamp(0, opts[:width] - 1), (state[:frog][1] + dy).clamp(0, opts[:height] - 1)]
862
+ event = mini_frogger_contact(opts)
863
+ return event unless event == :play
864
+ end
865
+ # Check the landing before carrying: a hop into open water cannot be
866
+ # rescued by a log that arrives later in the same simulation tick.
867
+ event = mini_frogger_contact(opts)
868
+ return event unless event == :play
869
+
870
+ state[:lanes].each do |lane|
871
+ state[:frog][0] += lane[:speed] if lane[:water] && state[:frog][1] == lane[:y]
872
+ lane[:offset] = (lane[:offset] + lane[:speed]) % lane[:period]
873
+ end
874
+ mini_frogger_contact(opts)
875
+ end
876
+
877
+ private_class_method def self.mini_frogger_snapshot(opts = {})
878
+ state, width, height = opts.values_at(:state, :width, :height)
879
+ board = Array.new(height) { ' ' * width }
880
+ state[:goals].each { |x| board[0][x] = state[:filled].include?(x) ? 'H' : 'U' }
881
+ state[:lanes].each do |lane|
882
+ width.times do |x|
883
+ occupied = ((x - lane[:offset]) % lane[:period]) < lane[:length]
884
+ board[lane[:y]][x] = if lane[:water]
885
+ occupied ? '=' : '~'
886
+ else
887
+ occupied ? 'T' : '.'
888
+ end
889
+ end
890
+ end
891
+ x, y = state[:frog]
892
+ board[y][x.round.clamp(0, width - 1)] = state[:explosion].positive? ? '+' : 'H'
893
+ mini_emit(frames: opts[:frames], board: board, state: Marshal.load(Marshal.dump(state)), event: opts[:event])
894
+ end
895
+
896
+ private_class_method def self.mini_frogger_demo(opts = {})
897
+ state = opts[:state]
898
+ return [] unless (state[:tick] % 3).zero?
899
+
900
+ goal = (state[:goals] - state[:filled]).min_by { |x| (state[:frog][0] - x).abs }
901
+ preferred = state[:frog][0] < goal ? :right : :left
902
+ candidates = state[:frog][1] == 1 && (state[:frog][0] - goal).abs > 0.55 ? [preferred, :up] : [:up, preferred]
903
+ candidates.each do |key|
904
+ copy = Marshal.load(Marshal.dump(state))
905
+ result = mini_frogger_move(opts.merge(state: copy, keys: [key]))
906
+ return [key] unless result == :game_over
907
+ end
908
+ []
909
+ end
910
+
911
+ private_class_method def self.mini_simulate_frogger(opts = {})
912
+ state = mini_frogger_round(opts)
913
+ event = :ready
914
+ while opts[:frames].length < MINI_FRAME_COUNT
915
+ mini_frogger_snapshot(opts.merge(state: state, event: event))
916
+ keys = opts[:player]&.controls
917
+ if state[:explosion].positive?
918
+ state[:explosion] -= 1
919
+ if state[:explosion].zero?
920
+ state[:frog] = [opts[:width] / 2.0, opts[:height] - 1]
921
+ state[:started] = false
922
+ event = :ready
923
+ end
924
+ next
925
+ end
926
+ keys = mini_frogger_demo(opts.merge(state: state)) unless opts[:player]
927
+ event = mini_frogger_move(opts.merge(state: state, keys: keys))
928
+ state[:explosion] = 8 if event == :game_over
929
+ state = mini_frogger_round(opts.merge(level: state[:level] + 1, score: state[:score])) if event == :level_complete
930
+ end
931
+ end
932
+
933
+ private_class_method def self.mini_galaga_round(opts = {})
934
+ width, height = opts.values_at(:width, :height)
935
+ enemies = (1...(width - 1)).step(2).flat_map do |x|
936
+ [0, 1].map { |y| { x: x.to_f, y: y.to_f, home: [x, y], diving: false, phase: 0 } }
937
+ end
938
+ { ship: [width / 2.0, height - 1.0], enemies: enemies, shots: [], hostile: [],
939
+ tick: 0, started: false, explosion: 0, cooldown: 0, level: opts[:level] || 1, score: opts[:score] || 0 }
940
+ end
941
+
942
+ private_class_method def self.mini_galaga_move(opts = {})
943
+ state, width, height, rng = opts.values_at(:state, :width, :height, :rng)
944
+ keys = opts[:keys] || []
945
+ state[:started] ||= !keys.empty?
946
+ return :ready unless state[:started]
947
+
948
+ state[:tick] += 1
949
+ state[:cooldown] -= 1 if state[:cooldown].positive?
950
+ keys.each do |key|
951
+ dx, dy = { left: [-0.75, 0], right: [0.75, 0], up: [0, -0.6], down: [0, 0.6] }.fetch(key, [0, 0])
952
+ state[:ship] = [(state[:ship][0] + dx).clamp(0, width - 1), (state[:ship][1] + dy).clamp(height / 2.0, height - 1)]
953
+ next unless key == ' ' && state[:cooldown].zero?
954
+
955
+ state[:shots] << [state[:ship][0], state[:ship][1] - 0.5]
956
+ state[:cooldown] = 3
957
+ end
958
+ speed = [0.08 + (state[:level] * 0.02), 0.22].min
959
+ state[:enemies].each do |enemy|
960
+ if enemy[:diving]
961
+ enemy[:phase] += 0.12
962
+ enemy[:x] = (enemy[:x] + (Math.sin(enemy[:phase]) * 0.18)).clamp(0, width - 1)
963
+ enemy[:y] += speed
964
+ enemy[:diving] = false if enemy[:y] > height
965
+ else
966
+ enemy[:x] = (enemy[:home][0] + (Math.sin(state[:tick] * 0.035) * 0.6)).clamp(0, width - 1)
967
+ enemy[:y] = enemy[:home][1].to_f
968
+ end
969
+ end
970
+ if (state[:tick] % 24).zero? && !state[:enemies].empty?
971
+ enemy = state[:enemies].sample(random: rng)
972
+ enemy[:diving] = true
973
+ enemy[:phase] = 0
974
+ end
975
+ if (state[:tick] % 16).zero? && !state[:enemies].empty?
976
+ enemy = state[:enemies].sample(random: rng)
977
+ dx = (state[:ship][0] - enemy[:x]) * 0.025
978
+ state[:hostile] << [enemy[:x], enemy[:y], dx.clamp(-0.12, 0.12)]
979
+ end
980
+ event = :play
981
+ state[:shots].delete_if do |shot|
982
+ previous = shot[1]
983
+ shot[1] -= 0.65
984
+ enemy = state[:enemies].find { |target| (target[:x] - shot[0]).abs < 0.65 && target[:y].between?(shot[1] - 0.4, previous + 0.4) }
985
+ if enemy
986
+ state[:enemies].delete(enemy)
987
+ state[:score] += enemy[:diving] ? 100 : 50
988
+ event = :hit
989
+ end
990
+ enemy || shot[1].negative?
991
+ end
992
+ state[:hostile].each do |shot|
993
+ shot[0] += shot[2]
994
+ shot[1] += 0.18 + speed
995
+ end
996
+ hit = state[:hostile].any? { |x, y, _| (x - state[:ship][0]).abs < 0.55 && (y - state[:ship][1]).abs < 0.6 }
997
+ hit ||= state[:enemies].any? { |enemy| (enemy[:x] - state[:ship][0]).abs < 0.65 && (enemy[:y] - state[:ship][1]).abs < 0.65 }
998
+ state[:hostile].reject! { |x, y, _| y >= height || x.negative? || x >= width }
999
+ return :game_over if hit
1000
+ return :wave_complete if state[:enemies].empty?
1001
+
1002
+ event
1003
+ end
1004
+
1005
+ private_class_method def self.mini_galaga_snapshot(opts = {})
1006
+ state, width, height = opts.values_at(:state, :width, :height)
1007
+ board = Array.new(height) { ' ' * width }
1008
+ state[:enemies].each do |enemy|
1009
+ x, y = enemy.values_at(:x, :y).map(&:round)
1010
+ board[y][x] = enemy[:diving] ? 'B' : 'E' if x.between?(0, width - 1) && y.between?(0, height - 1)
1011
+ end
1012
+ (state[:shots].map { |x, y| [x, y, '!'] } + state[:hostile].map { |x, y, _| [x, y, 'i'] }).each do |x, y, pixel|
1013
+ board[y.round][x.round] = pixel if x.round.between?(0, width - 1) && y.round.between?(0, height - 1)
1014
+ end
1015
+ x, y = state[:ship].map(&:round)
1016
+ if state[:explosion].positive?
1017
+ board[y][x] = '+'
1018
+ else
1019
+ # A filled five-dot delta: narrow nose, solid spine and swept wings.
1020
+ # Painting alone uses the existing 2x4 raster; physics stays in cells.
1021
+ nose_x = (state[:ship][0] * 2).round.clamp(2, (width * 2) - 3)
1022
+ base_y = ((state[:ship][1] * 4).round + 3).clamp(4, (height * 4) - 1)
1023
+ [0, 1, 1, 2, 2].each_with_index do |radius, dy|
1024
+ (-radius..radius).each do |dx|
1025
+ mini_dot(board: board, x: nose_x + dx, y: base_y - 4 + dy, color: 0)
1026
+ end
1027
+ end
1028
+ end
1029
+ if state[:explosion].positive?
1030
+ radius = (8 - state[:explosion]) / 2
1031
+ [[radius, 0], [-radius, 0], [0, radius], [0, -radius]].each do |dx, dy|
1032
+ board[y + dy][x + dx] = 'i' if (x + dx).between?(0, width - 1) && (y + dy).between?(0, height - 1)
1033
+ end
1034
+ end
1035
+ mini_emit(frames: opts[:frames], board: board, state: Marshal.load(Marshal.dump(state)), event: opts[:event])
1036
+ end
1037
+
1038
+ private_class_method def self.mini_simulate_galaga(opts = {})
1039
+ state = mini_galaga_round(opts)
1040
+ event = :ready
1041
+ while opts[:frames].length < MINI_FRAME_COUNT
1042
+ mini_galaga_snapshot(opts.merge(state: state, event: event))
1043
+ keys = opts[:player]&.controls
1044
+ if state[:explosion].positive?
1045
+ state[:explosion] -= 1
1046
+ if state[:explosion].zero?
1047
+ state = mini_galaga_round(opts)
1048
+ event = :ready
1049
+ end
1050
+ next
1051
+ end
1052
+ unless opts[:player]
1053
+ target = state[:enemies].min_by { |enemy| (enemy[:x] - state[:ship][0]).abs }
1054
+ dx = target ? target[:x] - state[:ship][0] : 0
1055
+ keys = [' ']
1056
+ keys << (dx.positive? ? :right : :left) if dx.abs > 0.5
1057
+ end
1058
+ event = mini_galaga_move(opts.merge(state: state, keys: keys))
1059
+ state[:explosion] = 8 if event == :game_over
1060
+ state = mini_galaga_round(opts.merge(level: state[:level] + 1, score: state[:score])) if event == :wave_complete
1061
+ end
1062
+ end
1063
+
582
1064
  private_class_method def self.mini_simulate_pong(opts = {})
583
1065
  frames = opts[:frames]
584
1066
  width = opts[:width]
@@ -672,6 +1154,19 @@ module PWN
672
1154
  board[y / 2][x / 2] = (256 + (color * 16) + mask).chr(Encoding::UTF_8)
673
1155
  end
674
1156
 
1157
+ # Braille provides twice the vertical samples of quadrants and much finer
1158
+ # ink. Preserve the full canvas and physics coordinates, not a shrunken board.
1159
+ private_class_method def self.mini_dot(opts = {})
1160
+ board = opts[:board]
1161
+ x = opts[:x] % (board.first.length * 2)
1162
+ y = opts[:y] % (board.length * 4)
1163
+ color = opts[:color]
1164
+ prior = board[y / 4][x / 2].ord - 4096
1165
+ mask = prior >= 0 && prior / 256 == color ? prior % 256 : 0
1166
+ bit = [[0, 1, 2, 6], [3, 4, 5, 7]][x % 2][y % 4]
1167
+ board[y / 4][x / 2] = (4096 + (color * 256) + (mask | (1 << bit))).chr(Encoding::UTF_8)
1168
+ end
1169
+
675
1170
  private_class_method def self.mini_simulate_asteroids(opts = {})
676
1171
  frames, width, height, rng = opts.values_at(:frames, :width, :height, :rng)
677
1172
  ship = [width / 2.0, height / 2.0, rng.rand * Math::PI * 2]
@@ -679,10 +1174,21 @@ module PWN
679
1174
  rocks = []
680
1175
  shots = []
681
1176
  sparks = []
1177
+ explosion = 0
1178
+ invulnerable = 0
682
1179
  serial = 0
683
1180
  tick = 0
684
1181
  while frames.length < MINI_FRAME_COUNT
685
1182
  event = :play
1183
+ if explosion.positive?
1184
+ explosion -= 1
1185
+ if explosion.zero?
1186
+ invulnerable = 30
1187
+ event = :recover
1188
+ end
1189
+ elsif invulnerable.positive?
1190
+ invulnerable -= 1
1191
+ end
686
1192
  # Reserve room for both fragments before spawning a large rock.
687
1193
  rock_budget = [3, width / 4].min + 1
688
1194
  occupied = rocks.sum { |rock| rock[4] > 0.25 ? 2 : 1 }
@@ -709,13 +1215,15 @@ module PWN
709
1215
  angle = Math.atan2(mini_delta(from: ship[1], to: target[1], span: height), mini_delta(from: ship[0], to: target[0], span: width))
710
1216
  turn = mini_delta(from: ship[2], to: angle, span: Math::PI * 2).clamp(-0.1, 0.1)
711
1217
  keys = opts[:player]&.controls
712
- turn = keys.sum { |key| { left: -0.2, right: 0.2 }.fetch(key, 0) } if keys
1218
+ keys = [] if explosion.positive?
1219
+ turn = keys.sum { |key| { left: -Math::PI / 4, right: Math::PI / 4 }.fetch(key, 0) } if keys
713
1220
  ship[2] = (ship[2] + turn) % (Math::PI * 2)
714
1221
  power = tick % 80 < 24 ? 1 : 0
715
1222
  power = keys.sum { |key| { up: 1, down: -1 }.fetch(key, 0) }.clamp(-1, 1) if keys
716
1223
  thrust = !power.zero?
717
1224
  [Math.cos(ship[2]), Math.sin(ship[2])].each_with_index do |direction, axis|
718
- velocity[axis] = ((velocity[axis] * 0.99) + (direction * 0.006 * power)).clamp(-0.15, 0.15)
1225
+ acceleration = keys ? 0.08 : 0.006
1226
+ velocity[axis] = ((velocity[axis] * 0.99) + (direction * acceleration * power)).clamp(-0.15, 0.15)
719
1227
  ship[axis] = (ship[axis] + velocity[axis]) % (axis.zero? ? width : height)
720
1228
  end
721
1229
  fire = keys ? keys.include?(' ') : (tick % 12).zero?
@@ -745,8 +1253,20 @@ module PWN
745
1253
  shots.reject! { |shot| shot[4] <= 0 }
746
1254
  sparks.each { |spark| spark[2] -= 1 }
747
1255
  sparks.reject! { |spark| spark[2] <= 0 }
1256
+ # Decorative autopilot retains its existing physics. Player impacts use
1257
+ # the same wrapped world as shots, with a compact hull collision radius.
1258
+ if opts[:player] && explosion.zero? && invulnerable.zero? && rocks.any? do |rock|
1259
+ dx = mini_delta(from: ship[0], to: rock[0], span: width)
1260
+ dy = mini_delta(from: ship[1], to: rock[1], span: height)
1261
+ (dx * dx) + (dy * dy) <= (rock[4] + 0.55)**2
1262
+ end
1263
+ explosion = 12
1264
+ velocity = [0.0, 0.0]
1265
+ thrust = false
1266
+ event = :crash
1267
+ end
748
1268
  mini_asteroids_snapshot(frames: frames, width: width, height: height, ship: ship, thrust: thrust,
749
- rocks: rocks, shots: shots, sparks: sparks, event: event)
1269
+ rocks: rocks, shots: shots, sparks: sparks, event: event, explosion: explosion, invulnerable: invulnerable)
750
1270
  tick += 1
751
1271
  end
752
1272
  end
@@ -766,28 +1286,39 @@ module PWN
766
1286
  angle = index * Math::PI * 2 / count
767
1287
  dx = count == 1 ? 0 : Math.cos(angle) * radius
768
1288
  dy = count == 1 ? 0 : Math.sin(angle) * radius
769
- mini_pixel(board: board, x: ((x + dx) * 2).round, y: ((y + dy) * 2).round, color: 3)
1289
+ mini_dot(board: board, x: ((x + dx) * 2).round, y: ((y + dy) * 4).round, color: 3)
770
1290
  end
771
1291
  end
772
1292
  sparks.each do |x, y, life|
773
1293
  2.times do |index|
774
1294
  angle = index * Math::PI
775
1295
  radius = (8 - life) * 0.12
776
- mini_pixel(board: board, x: ((x + (Math.cos(angle) * radius)) * 2).round, y: ((y + (Math.sin(angle) * radius)) * 2).round, color: 3)
1296
+ mini_dot(board: board, x: ((x + (Math.cos(angle) * radius)) * 2).round, y: ((y + (Math.sin(angle) * radius)) * 4).round, color: 3)
777
1297
  end
778
1298
  end
779
- shots.each { |x, y,| mini_pixel(board: board, x: (x * 2).round, y: (y * 2).round, color: 2) }
780
- if opts[:thrust]
1299
+ shots.each { |x, y,| mini_dot(board: board, x: (x * 2).round, y: (y * 4).round, color: 2) }
1300
+ if opts[:thrust] && opts[:explosion].to_i.zero?
781
1301
  # A black subpixel gap separates exhaust from hull: a terminal cell has
782
1302
  # only two colors, so cyan/red/black in one quadrant glyph loses the red.
783
- mini_pixel(board: board, x: ((ship[0] * 2) - (Math.cos(ship[2]) * 3)).round,
784
- y: ((ship[1] * 2) - (Math.sin(ship[2]) * 3)).round, color: 5)
1303
+ mini_dot(board: board, x: ((ship[0] * 2) - (Math.cos(ship[2]) * 5)).round,
1304
+ y: ((ship[1] * 4) - (Math.sin(ship[2]) * 8)).round, color: 5)
785
1305
  end
786
- MINI_SHIP[(ship[2] * 4 / Math::PI).round % 8].each do |dx, dy|
787
- mini_pixel(board: board, x: (ship[0] * 2).round + dx, y: (ship[1] * 2).round + dy, color: 0)
1306
+ if opts[:explosion].to_i.positive?
1307
+ life = opts[:explosion]
1308
+ 8.times do |index|
1309
+ angle = index * Math::PI / 4
1310
+ radius = 0.3 + ((12 - life) * 0.13)
1311
+ mini_dot(board: board, x: ((ship[0] + (Math.cos(angle) * radius)) * 2).round,
1312
+ y: ((ship[1] + (Math.sin(angle) * radius)) * 4).round, color: life > 8 ? 2 : 5)
1313
+ end
1314
+ else
1315
+ MINI_SHIP[(ship[2] * 4 / Math::PI).round % 8].each do |dx, dy|
1316
+ mini_dot(board: board, x: (ship[0] * 2).round + dx, y: (ship[1] * 4).round + dy, color: 0)
1317
+ end
788
1318
  end
789
1319
  mini_emit(frames: opts[:frames], board: board, event: opts[:event],
790
- state: { ship: ship.dup, thrust: opts[:thrust], rocks: rocks.map(&:dup), shots: shots.map(&:dup) })
1320
+ state: { ship: ship.dup, thrust: opts[:thrust], rocks: rocks.map(&:dup), shots: shots.map(&:dup),
1321
+ explosion: opts[:explosion].to_i, invulnerable: opts[:invulnerable].to_i })
791
1322
  end
792
1323
 
793
1324
  private_class_method def self.mini_asteroids(opts = {})
@@ -840,6 +1371,15 @@ module PWN
840
1371
  end
841
1372
  end
842
1373
 
1374
+ private_class_method def self.mini_pacman(opts = {})
1375
+ rows = opts[:rows]
1376
+ replay = mini_replay(name: :pacman, width: opts[:width], height: opts[:height], seed: MINI_SEED)
1377
+ replay[opts[:phase] + 6][:rows].drop(1).each_with_index do |row, y|
1378
+ rows[y + 1] = row.chars.map { |pixel| pixel.ord > 127 ? '@' : pixel.tr('RMCF><^v*Ow', 'GGGGCCCCoC!') }.join
1379
+ end
1380
+ end
1381
+
1382
+ # Retained for existing direct API callers, never offered by mini_names.
843
1383
  private_class_method def self.mini_pong(opts = {})
844
1384
  rows = opts[:rows]
845
1385
  width = opts[:width]
@@ -950,7 +1490,7 @@ module PWN
950
1490
  # Pass equal width and height for a square; complete artwork fits 5..16.
951
1491
  # No border is included. Smaller panes show only a clipped PWN wordmark.
952
1492
  #{self}.mini_frame(
953
- name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
1493
+ name: 'optional - falling_blocks, snake, pacman, asteroids, galaga or frogger; legacy pong accepted; defaults to falling_blocks',
954
1494
  frame: 'optional - integer frame index, defaults to zero and wraps at 60',
955
1495
  width: 'optional - columns clamped to zero through 16; defaults to 16',
956
1496
  height: 'optional - rows clamped to zero through 16; defaults to 16',
@@ -960,15 +1500,15 @@ module PWN
960
1500
  # Return fresh Unicode block cells: { glyph:, foreground:, background: }.
961
1501
  # Colors are standard terminal names; blocks may use colored backgrounds.
962
1502
  # No ANSI or IO. Other games retain black backgrounds.
963
- # 1800 frames; mini_frame_seconds is 0.05 for Pong/Asteroids, 0.1 otherwise.
1503
+ # 1800 frames; mini_frame_seconds is 0.05 for Asteroids/legacy Pong, 0.1 otherwise.
964
1504
  # Caller samples mini_names once per session. No timers or animation threads.
965
1505
  # Seeded gameplay, bounded six-replay cache; white hold/wipe marks resets.
966
1506
  # Square refers to terminal cells, not physical pixels. Caller owns borders.
967
1507
  #{self}.mini_cells(
968
- name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
1508
+ name: 'optional - falling_blocks, snake, pacman, asteroids, galaga or frogger; legacy pong accepted; defaults to falling_blocks',
969
1509
  frame: 'optional - integer frame index, defaults to zero and wraps at 1800',
970
- width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
971
- height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
1510
+ width: 'optional - columns clamped to 0..64 for blocks/Pac-Man/Galaga/Frogger, 0..16 otherwise; defaults to 16',
1511
+ height: 'optional - rows clamped to 0..64 for blocks/Pac-Man/Galaga/Frogger, 0..16 otherwise; defaults to 16',
972
1512
  seed: 'optional - integer local PRNG seed, defaults to 73; retain per session',
973
1513
  branding: 'optional - include the legacy wordmark, defaults to true; false reclaims its row'
974
1514
  )