pwn 0.5.759 → 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?
@@ -187,6 +218,60 @@ module PWN
187
218
  end
188
219
  end
189
220
 
221
+ # Caller-owned simulation: yields on the UI thread, never reads input or
222
+ # accumulates a replay. The caller supplies the clock and normalized keys.
223
+ # demo: true supplies automatic controls without a full replay construction.
224
+ class MiniGame
225
+ def initialize(name:, width:, height:, seed: MINI_SEED, demo: false)
226
+ @name = name.to_s
227
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless (Banner.mini_names + [:pong]).map(&:to_s).include?(@name)
228
+
229
+ limit = %w[falling_blocks pacman galaga frogger].include?(@name) ? 64 : MINI_WIDTH
230
+ @width = width.clamp(5, limit)
231
+ @height = height.clamp(5, limit)
232
+ @keys = []
233
+ @simulation = Fiber.new do
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 }
236
+ Banner.send("mini_simulate_#{@name}", options)
237
+ end
238
+ end
239
+
240
+ def key(key)
241
+ @keys << key if @keys.length < 16
242
+ end
243
+
244
+ def controls
245
+ keys = @keys
246
+ @keys = []
247
+ keys
248
+ end
249
+
250
+ def step
251
+ @simulation.resume
252
+ end
253
+
254
+ def cells(frame)
255
+ rows = frame[:rows].drop(1)
256
+ rows = Banner.send(:mini_block_board, board: rows) if @name == 'falling_blocks'
257
+ rows = Banner.send(:mini_quarter_board, board: rows, name: @name, full: true) if @name == 'snake'
258
+ rows.map do |row|
259
+ row.chars.map do |pixel|
260
+ glyph, foreground, background = MINI_PIXELS.fetch(pixel)
261
+ { glyph: glyph.dup, foreground: foreground, background: background || :black }
262
+ end
263
+ end
264
+ end
265
+
266
+ def <<(frame)
267
+ Fiber.yield(frame)
268
+ end
269
+
270
+ def length
271
+ 0
272
+ end
273
+ end
274
+
190
275
  # Six immutable, compact replays bound memory even when callers resize/reseed.
191
276
  private_class_method def self.mini_replay(opts = {})
192
277
  key = [opts[:name].to_s.dup.freeze, opts[:width], opts[:height], opts[:seed]].freeze
@@ -261,14 +346,14 @@ module PWN
261
346
  width = source.first.length
262
347
  height = source.length
263
348
 
264
- board = Array.new(height) { ' ' * width }
349
+ board = Array.new(opts[:full] ? height / 2 : height) { ' ' * (opts[:full] ? width / 2 : width) }
265
350
  colors = { 'c' => 0, 'm' => 1, 'y' => 2, 'w' => 3, 'g' => 4, 'r' => 5, 'f' => 5, 'b' => 6 }
266
351
  points = source.each_with_index.flat_map do |row, y|
267
352
  row.chars.each_with_index.filter_map { |pixel, x| [x, y, pixel] unless pixel == ' ' }
268
353
  end
269
354
  points.sort_by! { |_, _, pixel| { 'f' => 2, 'y' => 1 }.fetch(pixel, 0) } if opts[:name] == 'snake'
270
355
  points.each do |x, y, pixel|
271
- 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)
272
357
  end
273
358
  board
274
359
  end
@@ -347,56 +432,99 @@ module PWN
347
432
  bag = (0...MINI_SHAPES.length).to_a.shuffle(random: rng) if bag.empty?
348
433
  type = bag.shift
349
434
  shape = MINI_SHAPES[type]
350
- landing = mini_landing(board: board, shape: shape, rng: rng)
435
+ landing = opts[:player] ? {} : mini_landing(board: board, shape: shape, rng: rng)
351
436
  x = (width - shape.map(&:first).max - 1) / 2
352
437
  unless landing && mini_fits?(board: board, shape: shape, x: x, y: 0)
353
438
  mini_reset_board(frames: frames, board: board)
354
439
  board = Array.new(height) { ' ' * width }
355
440
  next
356
441
  end
357
- rotations = mini_rotations(shape: shape)
358
- plan = [[shape, x, 0]]
359
- blocked = false
360
- (1..landing[:rotation]).each do |rotation|
361
- shape = rotations[rotation]
362
- if mini_fits?(board: board, shape: shape, x: x, y: 0)
363
- plan << [shape, x, 0]
364
- else
365
- blocked = true
442
+ if opts[:player]
443
+ shape, x, y = mini_play_piece(board: board, type: type, frames: frames, player: opts[:player])
444
+ landing = { x: x, y: y }
445
+ else
446
+ shape, x = mini_block_plan(board: board, shape: shape, x: x, landing: landing, frames: frames, type: type)
447
+ unless shape
448
+ mini_reset_board(frames: frames, board: board)
449
+ board = Array.new(height) { ' ' * width }
450
+ next
366
451
  end
367
452
  end
368
- until x == landing[:x] || blocked
369
- x += landing[:x] <=> x
370
- blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
371
- plan << [shape, x, 0] unless blocked
372
- end
373
- if blocked
374
- mini_reset_board(frames: frames, board: board)
375
- board = Array.new(height) { ' ' * width }
376
- next
377
- end
378
- (1..landing[:y]).each { |y| plan << [shape, x, y] }
379
453
  color = %w[c y m b r g g][type]
380
- plan.each do |piece, px, py|
381
- picture = board.map(&:dup)
382
- active = piece.map { |dx, dy| [px + dx, py + dy] }
383
- active.each { |ax, ay| picture[ay][ax] = color }
384
- mini_emit(frames: frames, board: picture,
385
- state: { active: active, stack: board.map(&:dup), type: type })
386
- end
387
454
  shape.each { |dx, dy| board[landing[:y] + dy][x + dx] = color }
388
455
  mini_emit(frames: frames, board: board, event: :lock, ticks: 2)
389
- full = board.each_index.reject { |y| board[y].include?(' ') }
456
+ full = board.each_index.reject { |row| board[row].include?(' ') }
390
457
  next if full.empty?
391
458
 
392
459
  flash = board.map(&:dup)
393
- full.each { |y| flash[y] = 'w' * width }
460
+ full.each { |row| flash[row] = 'w' * width }
394
461
  mini_emit(frames: frames, board: flash, event: :clear, state: { lines: full }, ticks: 3)
395
- board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, y| row unless full.include?(y) }
462
+ board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, index| row unless full.include?(index) }
396
463
  mini_emit(frames: frames, board: board, event: :collapse, ticks: 2)
397
464
  end
398
465
  end
399
466
 
467
+ private_class_method def self.mini_block_plan(opts = {})
468
+ board, shape, x, landing, frames, type = opts.values_at(:board, :shape, :x, :landing, :frames, :type)
469
+ rotations = mini_rotations(shape: shape)
470
+ plan = [[shape, x, 0]]
471
+ blocked = false
472
+ (1..landing[:rotation]).each do |rotation|
473
+ shape = rotations[rotation]
474
+ if mini_fits?(board: board, shape: shape, x: x, y: 0)
475
+ plan << [shape, x, 0]
476
+ else
477
+ blocked = true
478
+ end
479
+ end
480
+ until x == landing[:x] || blocked
481
+ x += landing[:x] <=> x
482
+ blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
483
+ plan << [shape, x, 0] unless blocked
484
+ end
485
+ return if blocked
486
+
487
+ (1..landing[:y]).each { |y| plan << [shape, x, y] }
488
+ color = %w[c y m b r g g][type]
489
+ plan.each do |piece, px, py|
490
+ picture = board.map(&:dup)
491
+ active = piece.map { |dx, dy| [px + dx, py + dy] }
492
+ active.each { |ax, ay| picture[ay][ax] = color }
493
+ mini_emit(frames: frames, board: picture,
494
+ state: { active: active, stack: board.map(&:dup), type: type })
495
+ end
496
+ [shape, x]
497
+ end
498
+
499
+ private_class_method def self.mini_play_piece(opts = {})
500
+ board, type, frames, player = opts.values_at(:board, :type, :frames, :player)
501
+ shape = MINI_SHAPES[type]
502
+ x = (board.first.length - shape.map(&:first).max - 1) / 2
503
+ y = 0
504
+ tick = 0
505
+ loop do
506
+ active = shape.map { |dx, dy| [x + dx, y + dy] }
507
+ picture = board.map(&:dup)
508
+ active.each { |ax, ay| picture[ay][ax] = %w[c y m b r g g][type] }
509
+ mini_emit(frames: frames, board: picture, state: { active: active, stack: board.map(&:dup), type: type })
510
+ keys = player.controls
511
+ keys.each do |key|
512
+ candidate = key == ' ' ? mini_rotations(shape: shape).fetch(1, shape) : shape
513
+ nx = x + { left: -1, right: 1 }.fetch(key, 0)
514
+ next unless mini_fits?(board: board, shape: candidate, x: nx, y: y)
515
+
516
+ shape = candidate
517
+ x = nx
518
+ end
519
+ tick += 1
520
+ next unless keys.include?(:down) || (tick % 5).zero?
521
+ break unless mini_fits?(board: board, shape: shape, x: x, y: y + 1)
522
+
523
+ y += 1
524
+ end
525
+ [shape, x, y]
526
+ end
527
+
400
528
  private_class_method def self.mini_reset_board(opts = {})
401
529
  board = opts[:board].map { |row| row.gsub(/[^ ]/, 'w') }
402
530
  mini_emit(frames: opts[:frames], board: board, event: :game_over, ticks: 8)
@@ -442,21 +570,44 @@ module PWN
442
570
  height = opts[:height]
443
571
  rng = opts[:rng]
444
572
  spaces = (0...height).flat_map { |y| (0...width).map { |x| [x, y] } }
573
+ player = opts[:player]
445
574
  while frames.length < MINI_FRAME_COUNT
446
- y = rng.rand(height)
575
+ y = player ? height / 2 : rng.rand(height)
447
576
  body = [[2, y], [1, y], [0, y]]
577
+ direction = [1, 0]
448
578
  food = (spaces - body).sample(random: rng)
449
579
  steps = 0
580
+ started = !player
450
581
  loop do
451
582
  board = Array.new(height) { ' ' * width }
452
583
  body.each_with_index { |(x, row), index| board[row][x] = index.zero? ? 'y' : 'g' }
453
584
  board[food[1]][food[0]] = 'f' if food
454
- mini_emit(frames: frames, board: board, ticks: 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 })
455
586
  break if frames.length >= MINI_FRAME_COUNT
456
587
 
457
- target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng) if food
588
+ if player
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|
594
+ [dx, dy] != direction && [dx, dy] != direction.map(&:-@)
595
+ end
596
+ direction = turn if turn
597
+ target = [body.first[0] + direction[0], body.first[1] + direction[1]]
598
+ occupied = target == food ? body : body.take(body.length - 1)
599
+ target = nil unless spaces.include?(target) && !occupied.include?(target)
600
+ elsif food
601
+ target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng)
602
+ end
458
603
  if !food || !target || steps >= width * height * 4
459
- 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
460
611
  break
461
612
  end
462
613
  body.unshift(target)
@@ -471,12 +622,452 @@ module PWN
471
622
  end
472
623
  end
473
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
+
474
1064
  private_class_method def self.mini_simulate_pong(opts = {})
475
1065
  frames = opts[:frames]
476
1066
  width = opts[:width]
477
1067
  height = opts[:height]
478
1068
  rng = opts[:rng]
479
1069
  paddles = [(height - 1) / 2.0, (height - 1) / 2.0]
1070
+ player = opts[:player]
480
1071
  scores = [0, 0]
481
1072
  while frames.length < MINI_FRAME_COUNT
482
1073
  x = (width - 1) / 2.0
@@ -485,9 +1076,14 @@ module PWN
485
1076
  vy = ((rng.rand * 0.4) + 0.1) * (rng.rand(2).zero? ? -1 : 1)
486
1077
  bias = Array.new(2) { rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5 }
487
1078
  rally = 0
488
- mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: 16)
1079
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: player ? 1 : 16)
489
1080
  loop do
1081
+ player&.controls&.each do |key|
1082
+ paddles[0] = (paddles[0] + { up: -0.5, down: 0.5 }.fetch(key, 0)).clamp(0, height - 1)
1083
+ end
490
1084
  paddles.each_index do |side|
1085
+ next if player && side.zero?
1086
+
491
1087
  incoming = side.zero? ? vx.negative? : vx.positive?
492
1088
  target = incoming ? y - 0.25 + bias[side] : (height - 1) / 2.0
493
1089
  speed = incoming ? 0.275 : 0.11
@@ -519,7 +1115,7 @@ module PWN
519
1115
  y = ny
520
1116
  if x.negative? || x > width - 1
521
1117
  scores[x.negative? ? 1 : 0] += 1
522
- mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: 20)
1118
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: player ? 1 : 20)
523
1119
  break
524
1120
  end
525
1121
  mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: event, rally: rally)
@@ -558,6 +1154,19 @@ module PWN
558
1154
  board[y / 2][x / 2] = (256 + (color * 16) + mask).chr(Encoding::UTF_8)
559
1155
  end
560
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
+
561
1170
  private_class_method def self.mini_simulate_asteroids(opts = {})
562
1171
  frames, width, height, rng = opts.values_at(:frames, :width, :height, :rng)
563
1172
  ship = [width / 2.0, height / 2.0, rng.rand * Math::PI * 2]
@@ -565,10 +1174,21 @@ module PWN
565
1174
  rocks = []
566
1175
  shots = []
567
1176
  sparks = []
1177
+ explosion = 0
1178
+ invulnerable = 0
568
1179
  serial = 0
569
1180
  tick = 0
570
1181
  while frames.length < MINI_FRAME_COUNT
571
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
572
1192
  # Reserve room for both fragments before spawning a large rock.
573
1193
  rock_budget = [3, width / 4].min + 1
574
1194
  occupied = rocks.sum { |rock| rock[4] > 0.25 ? 2 : 1 }
@@ -594,13 +1214,20 @@ module PWN
594
1214
  end
595
1215
  angle = Math.atan2(mini_delta(from: ship[1], to: target[1], span: height), mini_delta(from: ship[0], to: target[0], span: width))
596
1216
  turn = mini_delta(from: ship[2], to: angle, span: Math::PI * 2).clamp(-0.1, 0.1)
1217
+ keys = opts[:player]&.controls
1218
+ keys = [] if explosion.positive?
1219
+ turn = keys.sum { |key| { left: -Math::PI / 4, right: Math::PI / 4 }.fetch(key, 0) } if keys
597
1220
  ship[2] = (ship[2] + turn) % (Math::PI * 2)
598
- thrust = tick % 80 < 24
1221
+ power = tick % 80 < 24 ? 1 : 0
1222
+ power = keys.sum { |key| { up: 1, down: -1 }.fetch(key, 0) }.clamp(-1, 1) if keys
1223
+ thrust = !power.zero?
599
1224
  [Math.cos(ship[2]), Math.sin(ship[2])].each_with_index do |direction, axis|
600
- velocity[axis] = ((velocity[axis] * 0.99) + (thrust ? direction * 0.006 : 0)).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)
601
1227
  ship[axis] = (ship[axis] + velocity[axis]) % (axis.zero? ? width : height)
602
1228
  end
603
- shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if (tick % 12).zero?
1229
+ fire = keys ? keys.include?(' ') : (tick % 12).zero?
1230
+ shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if fire
604
1231
  shots.each do |shot|
605
1232
  shot[0] = (shot[0] + shot[2]) % width
606
1233
  shot[1] = (shot[1] + shot[3]) % height
@@ -626,8 +1253,20 @@ module PWN
626
1253
  shots.reject! { |shot| shot[4] <= 0 }
627
1254
  sparks.each { |spark| spark[2] -= 1 }
628
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
629
1268
  mini_asteroids_snapshot(frames: frames, width: width, height: height, ship: ship, thrust: thrust,
630
- rocks: rocks, shots: shots, sparks: sparks, event: event)
1269
+ rocks: rocks, shots: shots, sparks: sparks, event: event, explosion: explosion, invulnerable: invulnerable)
631
1270
  tick += 1
632
1271
  end
633
1272
  end
@@ -647,28 +1286,39 @@ module PWN
647
1286
  angle = index * Math::PI * 2 / count
648
1287
  dx = count == 1 ? 0 : Math.cos(angle) * radius
649
1288
  dy = count == 1 ? 0 : Math.sin(angle) * radius
650
- 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)
651
1290
  end
652
1291
  end
653
1292
  sparks.each do |x, y, life|
654
1293
  2.times do |index|
655
1294
  angle = index * Math::PI
656
1295
  radius = (8 - life) * 0.12
657
- 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)
658
1297
  end
659
1298
  end
660
- shots.each { |x, y,| mini_pixel(board: board, x: (x * 2).round, y: (y * 2).round, color: 2) }
661
- 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?
662
1301
  # A black subpixel gap separates exhaust from hull: a terminal cell has
663
1302
  # only two colors, so cyan/red/black in one quadrant glyph loses the red.
664
- mini_pixel(board: board, x: ((ship[0] * 2) - (Math.cos(ship[2]) * 3)).round,
665
- 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)
666
1305
  end
667
- MINI_SHIP[(ship[2] * 4 / Math::PI).round % 8].each do |dx, dy|
668
- 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
669
1318
  end
670
1319
  mini_emit(frames: opts[:frames], board: board, event: opts[:event],
671
- 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 })
672
1322
  end
673
1323
 
674
1324
  private_class_method def self.mini_asteroids(opts = {})
@@ -721,6 +1371,15 @@ module PWN
721
1371
  end
722
1372
  end
723
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.
724
1383
  private_class_method def self.mini_pong(opts = {})
725
1384
  rows = opts[:rows]
726
1385
  width = opts[:width]
@@ -831,7 +1490,7 @@ module PWN
831
1490
  # Pass equal width and height for a square; complete artwork fits 5..16.
832
1491
  # No border is included. Smaller panes show only a clipped PWN wordmark.
833
1492
  #{self}.mini_frame(
834
- 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',
835
1494
  frame: 'optional - integer frame index, defaults to zero and wraps at 60',
836
1495
  width: 'optional - columns clamped to zero through 16; defaults to 16',
837
1496
  height: 'optional - rows clamped to zero through 16; defaults to 16',
@@ -841,15 +1500,15 @@ module PWN
841
1500
  # Return fresh Unicode block cells: { glyph:, foreground:, background: }.
842
1501
  # Colors are standard terminal names; blocks may use colored backgrounds.
843
1502
  # No ANSI or IO. Other games retain black backgrounds.
844
- # 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.
845
1504
  # Caller samples mini_names once per session. No timers or animation threads.
846
1505
  # Seeded gameplay, bounded six-replay cache; white hold/wipe marks resets.
847
1506
  # Square refers to terminal cells, not physical pixels. Caller owns borders.
848
1507
  #{self}.mini_cells(
849
- 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',
850
1509
  frame: 'optional - integer frame index, defaults to zero and wraps at 1800',
851
- width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
852
- 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',
853
1512
  seed: 'optional - integer local PRNG seed, defaults to 73; retain per session',
854
1513
  branding: 'optional - include the legacy wordmark, defaults to true; false reclaims its row'
855
1514
  )