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.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/documentation/pwn-ai-Agent.md +44 -10
- data/etc/default_skills/pwn/banner/SKILL.md +1 -1
- data/lib/pwn/ai/agent/loop.rb +73 -18
- data/lib/pwn/banner.rb +740 -81
- data/lib/pwn/plugins/repl/ai_console.rb +97 -10
- data/lib/pwn/plugins/repl/ai_swarm.rb +1 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
- data/spec/lib/pwn/banner_spec.rb +523 -19
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +267 -5
- data/third_party/pwn_rdoc.jsonl +36 -3
- metadata +3 -3
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?
|
|
@@ -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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
shape =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 { |
|
|
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 { |
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,|
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
)
|