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.
- checksums.yaml +4 -4
- data/documentation/pwn-ai-Agent.md +44 -10
- data/etc/default_skills/pwn/banner/SKILL.md +1 -1
- data/lib/pwn/banner.rb +593 -53
- data/lib/pwn/plugins/repl/ai_console.rb +46 -9
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/banner_spec.rb +428 -20
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +167 -6
- data/third_party/pwn_rdoc.jsonl +23 -2
- metadata +1 -1
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,
|
|
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
|
|
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
|
-
#
|
|
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;
|
|
17
|
-
#
|
|
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
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
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?
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,|
|
|
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
|
-
|
|
784
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
)
|