pwn 0.5.749 → 0.5.751
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 +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
data/lib/pwn/banner.rb
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PWN
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Static banners and pure, PWN-branded retro ASCII/color loops: falling_blocks,
|
|
5
|
+
# snake, pong, and asteroids. mini_frame returns fresh rows on a borderless canvas;
|
|
6
|
+
# use equal width and height (5..16) for complete square artwork. Dimensions
|
|
7
|
+
# clamp to 0..16 (colored blocks: 0..64), with tiny panes showing a wordmark. The caller
|
|
8
|
+
# samples mini_names once per session and advances the explicit frame index
|
|
9
|
+
# every mini_frame_seconds(name:) (0.05 for Pong/Asteroids, 0.1 otherwise).
|
|
10
|
+
# mini_cells replays 1800 seeded gameplay
|
|
11
|
+
# frames (MINI_FRAME_COUNT); mini_frame retains its legacy 60-frame ASCII loop.
|
|
12
|
+
# These are decorative game simulations, not interactive games or telemetry.
|
|
13
|
+
# mini_cells uses local Random.new with seed 73 by default; callers may keep
|
|
14
|
+
# a different integer seed per session. Six immutable replays are cached.
|
|
15
|
+
# Tetrominoes stack and clear; snake pursues food and grows; Pong rallies
|
|
16
|
+
# include imperfect paddle tracking, misses and score pips between serves.
|
|
17
|
+
# Blocks use horizontal half-cells with independent foreground/background
|
|
18
|
+
# colors, filling the pane below PWN (up to 64 cells). Other games retain
|
|
19
|
+
# quadrant pixels; snake stays centered with food/head color priority.
|
|
20
|
+
# White line-clear flashes and game-over/reset wipes are intentional.
|
|
21
|
+
# Pong uses half-cell motion and one-cell-high paddles without speeding up play.
|
|
22
|
+
# Asteroids uses five-pixel cyan arrowheads on a half-cell grid, sparse small
|
|
23
|
+
# white rocks, yellow shots and red thrust; wrapped collisions split real rocks.
|
|
24
|
+
# Static Banner modules autoload only when needed. For more information, see:
|
|
6
25
|
# http://www.rubyinside.com/ruby-techniques-revealed-autoload-1652.html
|
|
7
26
|
module Banner
|
|
8
27
|
autoload :Anon, 'pwn/banner/anon'
|
|
@@ -21,6 +40,693 @@ module PWN
|
|
|
21
40
|
autoload :Radare2AI, 'pwn/banner/radare2_ai'
|
|
22
41
|
autoload :WhiteRabbit, 'pwn/banner/white_rabbit'
|
|
23
42
|
|
|
43
|
+
# Bounded, square character canvases. The caller owns the surrounding border.
|
|
44
|
+
MINI_WIDTH = 16
|
|
45
|
+
MINI_HEIGHT = 16
|
|
46
|
+
MINI_FRAME_SECONDS = 0.1
|
|
47
|
+
MINI_FRAME_COUNT = 1800
|
|
48
|
+
MINI_SEED = 73
|
|
49
|
+
MINI_CACHE_LIMIT = 6
|
|
50
|
+
MINI_ASCII_FRAME_COUNT = 60
|
|
51
|
+
# Five quadrant pixels, not a scaled-down vector sprite. Eight stable headings
|
|
52
|
+
# keep the nose legible even in the 5-column pane; physics still turns smoothly.
|
|
53
|
+
MINI_SHIP = Array.new(8) do |heading|
|
|
54
|
+
points = heading.even? ? [[-1, -1], [0, -1], [1, 0], [0, 1], [-1, 1]] : [[0, -1], [1, 0], [1, 1], [0, 1], [-1, 0]]
|
|
55
|
+
(heading / 2).times { points = points.map { |x, y| [-y, x] } }
|
|
56
|
+
points.map(&:freeze).freeze
|
|
57
|
+
end.freeze
|
|
58
|
+
|
|
59
|
+
# Compact replay pixels expand to fresh cells only at the API boundary.
|
|
60
|
+
MINI_PIXELS = {
|
|
61
|
+
' ' => [' ', :blue], 'P' => ['P', :blue], 'W' => ['W', :blue], 'N' => ['N', :blue],
|
|
62
|
+
'c' => ['█', :cyan], 'y' => ['█', :yellow], 'm' => ['█', :magenta],
|
|
63
|
+
'b' => ['█', :blue], 'r' => ['█', :red], 'g' => ['█', :green],
|
|
64
|
+
'w' => ['█', :white], 'f' => ['▄', :red], 'o' => ['▀', :yellow],
|
|
65
|
+
'l' => ['▌', :cyan], 'q' => ['▐', :magenta], ':' => ['░', :blue]
|
|
66
|
+
}.merge(%i[cyan magenta yellow white green red blue].each_with_index.flat_map do |color, index|
|
|
67
|
+
' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'.chars.each_with_index.map do |glyph, mask|
|
|
68
|
+
[(256 + (index * 16) + mask).chr(Encoding::UTF_8), [glyph, color]]
|
|
69
|
+
end
|
|
70
|
+
end.to_h).merge(%i[black cyan magenta yellow white green red blue].each_with_index.flat_map do |top, top_index|
|
|
71
|
+
%i[black cyan magenta yellow white green red blue].each_with_index.map do |bottom, bottom_index|
|
|
72
|
+
mask = (top == :black ? 0 : 3) | (bottom == :black ? 0 : 12)
|
|
73
|
+
cell = if top == bottom
|
|
74
|
+
[top == :black ? ' ' : '█', top, :black]
|
|
75
|
+
elsif top == :black
|
|
76
|
+
['▄', bottom, :black]
|
|
77
|
+
else
|
|
78
|
+
['▀', top, bottom]
|
|
79
|
+
end
|
|
80
|
+
[(1024 + (((top_index * 8) + bottom_index) * 16) + mask).chr(Encoding::UTF_8), cell]
|
|
81
|
+
end
|
|
82
|
+
end.to_h).transform_values(&:freeze).freeze
|
|
83
|
+
MINI_SHAPES = [
|
|
84
|
+
[[0, 0], [1, 0], [2, 0], [3, 0]],
|
|
85
|
+
[[0, 0], [1, 0], [0, 1], [1, 1]],
|
|
86
|
+
[[0, 0], [1, 0], [2, 0], [1, 1]],
|
|
87
|
+
[[0, 0], [0, 1], [1, 1], [2, 1]],
|
|
88
|
+
[[2, 0], [0, 1], [1, 1], [2, 1]],
|
|
89
|
+
[[1, 0], [2, 0], [0, 1], [1, 1]],
|
|
90
|
+
[[0, 0], [1, 0], [1, 1], [2, 1]]
|
|
91
|
+
].map { |shape| shape.map(&:freeze).freeze }.freeze
|
|
92
|
+
|
|
93
|
+
# Supported Method Parameters::
|
|
94
|
+
# Return fresh ASCII rows for decorative retro loops, not interactive games.
|
|
95
|
+
# Use equal width and height for a square; 5..16 retains the complete artwork.
|
|
96
|
+
# Smaller panes degrade to a wordmark. Caller owns randomness, clock and IO.
|
|
97
|
+
# PWN::Banner.mini_frame(
|
|
98
|
+
# name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
|
|
99
|
+
# frame: 'optional - integer frame index, defaults to zero and wraps at 60',
|
|
100
|
+
# width: 'optional - output columns clamped to zero through 16, defaults to 16',
|
|
101
|
+
# height: 'optional - output rows clamped to zero through 16, defaults to 16'
|
|
102
|
+
# )
|
|
103
|
+
|
|
104
|
+
public_class_method def self.mini_frame(opts = {})
|
|
105
|
+
name = (opts[:name] || :falling_blocks).to_s
|
|
106
|
+
raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
|
|
107
|
+
|
|
108
|
+
phase = (opts[:frame] || 0).to_i % MINI_ASCII_FRAME_COUNT
|
|
109
|
+
width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, MINI_WIDTH)
|
|
110
|
+
height = (opts[:height] || MINI_HEIGHT).to_i.clamp(0, MINI_HEIGHT)
|
|
111
|
+
return [] if width.zero? || height.zero?
|
|
112
|
+
|
|
113
|
+
rows = Array.new(height) { ' ' * width }
|
|
114
|
+
rows[0] = 'PWN'[0, width].center(width)
|
|
115
|
+
return rows if width < 5 || height < 5
|
|
116
|
+
|
|
117
|
+
send("mini_#{name}", rows: rows, phase: phase, width: width, height: height)
|
|
118
|
+
rows
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Supported Method Parameters::
|
|
122
|
+
# List animation names; callers may sample once and retain the session choice.
|
|
123
|
+
# PWN::Banner.mini_names
|
|
124
|
+
|
|
125
|
+
public_class_method def self.mini_names
|
|
126
|
+
%i[falling_blocks snake pong asteroids]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Supported Method Parameters::
|
|
130
|
+
# Return the presentation interval; simulations preserve their gameplay speed.
|
|
131
|
+
# PWN::Banner.mini_frame_seconds(
|
|
132
|
+
# name: 'optional - animation name, defaults to falling_blocks'
|
|
133
|
+
# )
|
|
134
|
+
|
|
135
|
+
public_class_method def self.mini_frame_seconds(opts = {})
|
|
136
|
+
name = (opts[:name] || :falling_blocks).to_s
|
|
137
|
+
raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
|
|
138
|
+
|
|
139
|
+
%w[pong asteroids].include?(name) ? 0.05 : MINI_FRAME_SECONDS
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Supported Method Parameters::
|
|
143
|
+
# Return rows of single-column Unicode block cells, with named terminal colors.
|
|
144
|
+
# Each fresh cell has glyph, foreground and background keys; no ANSI or IO.
|
|
145
|
+
# Square means terminal-cell dimensions, not physical pixels. Caller owns borders.
|
|
146
|
+
# Use mini_frame_seconds for cadence; 1800 frames span 90 or 180 seconds.
|
|
147
|
+
# Colored blocks fill the canvas with half-cell pieces; other games cap at 16.
|
|
148
|
+
# PWN::Banner.mini_cells(
|
|
149
|
+
# name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
|
|
150
|
+
# frame: 'optional - integer index, defaults to zero and wraps at 1800',
|
|
151
|
+
# width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
|
|
152
|
+
# height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
|
|
153
|
+
# seed: 'optional - integer local PRNG seed, defaults to 73; retain per session'
|
|
154
|
+
# )
|
|
155
|
+
|
|
156
|
+
public_class_method def self.mini_cells(opts = {})
|
|
157
|
+
name = (opts[:name] || :falling_blocks).to_s
|
|
158
|
+
phase = (opts[:frame] || 0).to_i % MINI_FRAME_COUNT
|
|
159
|
+
raise ArgumentError, "Unknown mini-banner: #{name}" unless mini_names.map(&:to_s).include?(name)
|
|
160
|
+
|
|
161
|
+
limit = name == 'falling_blocks' ? 64 : MINI_WIDTH
|
|
162
|
+
width = (opts[:width] || MINI_WIDTH).to_i.clamp(0, limit)
|
|
163
|
+
height = (opts[:height] || MINI_HEIGHT).to_i.clamp(0, limit)
|
|
164
|
+
return [] if width.zero? || height.zero?
|
|
165
|
+
|
|
166
|
+
rows = if width < 5 || height < 5
|
|
167
|
+
['PWN'[0, width].center(width)] + Array.new(height - 1) { ' ' * width }
|
|
168
|
+
else
|
|
169
|
+
mini_replay(name: name, width: width, height: height, seed: (opts[:seed] || MINI_SEED).to_i)[phase][:rows]
|
|
170
|
+
end
|
|
171
|
+
rows.map do |row|
|
|
172
|
+
row.chars.map do |pixel|
|
|
173
|
+
glyph, foreground, background = MINI_PIXELS.fetch(pixel)
|
|
174
|
+
{ glyph: glyph.dup, foreground: foreground, background: background || :black }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Six immutable, compact replays bound memory even when callers resize/reseed.
|
|
180
|
+
private_class_method def self.mini_replay(opts = {})
|
|
181
|
+
key = [opts[:name].to_s.dup.freeze, opts[:width], opts[:height], opts[:seed]].freeze
|
|
182
|
+
@mini_replays ||= {}
|
|
183
|
+
return @mini_replays[key] if @mini_replays.key?(key)
|
|
184
|
+
|
|
185
|
+
name, width, height, seed = key
|
|
186
|
+
# Two independently colored horizontal halves per terminal cell. Four
|
|
187
|
+
# independent quadrants can require more than the terminal's two colors.
|
|
188
|
+
# Keep full pane width and the bottom half-row, below the wordmark.
|
|
189
|
+
height = ((height - 1) * 2) + 1 if name == 'falling_blocks'
|
|
190
|
+
frames = []
|
|
191
|
+
blank = Array.new(height - 1) { ' ' * width }
|
|
192
|
+
mini_emit(frames: frames, board: blank, event: :ready, ticks: 6)
|
|
193
|
+
send("mini_simulate_#{name}", frames: frames, width: width, height: height - 1, rng: Random.new(seed))
|
|
194
|
+
# A white hold and top-to-bottom wipe explicitly mark the replay boundary.
|
|
195
|
+
frames = frames.take(MINI_FRAME_COUNT - height - 6)
|
|
196
|
+
board = frames.last[:rows].drop(1).map { |row| row.gsub(/[^ ]/, 'w') }
|
|
197
|
+
mini_emit(frames: frames, board: board, event: :reset, ticks: 6)
|
|
198
|
+
board.length.times do |y|
|
|
199
|
+
board[y] = ' ' * width
|
|
200
|
+
mini_emit(frames: frames, board: board, event: :reset)
|
|
201
|
+
end
|
|
202
|
+
mini_emit(frames: frames, board: blank, event: :ready)
|
|
203
|
+
if %w[falling_blocks snake].include?(name)
|
|
204
|
+
packed = {}.compare_by_identity
|
|
205
|
+
frames = frames.map do |frame|
|
|
206
|
+
packed[frame] ||= begin
|
|
207
|
+
board = if name == 'falling_blocks'
|
|
208
|
+
mini_block_board(board: frame[:rows].drop(1))
|
|
209
|
+
else
|
|
210
|
+
mini_quarter_board(board: frame[:rows].drop(1), name: name)
|
|
211
|
+
end
|
|
212
|
+
mini_freeze(value: frame.merge(rows: ['PWN'.center(board.first.length)] + board))
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
@mini_replays.shift while @mini_replays.length >= MINI_CACHE_LIMIT
|
|
217
|
+
@mini_replays[key] = frames.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private_class_method def self.mini_freeze(opts = {})
|
|
221
|
+
value = opts[:value]
|
|
222
|
+
case value
|
|
223
|
+
when Hash
|
|
224
|
+
value.each_value { |child| mini_freeze(value: child) }
|
|
225
|
+
when Array
|
|
226
|
+
value.each { |child| mini_freeze(value: child) }
|
|
227
|
+
end
|
|
228
|
+
value.freeze
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Each block occupies a horizontal half-cell for its entire lifetime. Unlike
|
|
232
|
+
# four independent quadrants, two halves always fit foreground/background,
|
|
233
|
+
# including differently colored neighbors, holes and white clear flashes.
|
|
234
|
+
private_class_method def self.mini_block_board(opts = {})
|
|
235
|
+
opts[:board].each_slice(2).map do |top, bottom|
|
|
236
|
+
top.chars.each_with_index.map do |pixel, x|
|
|
237
|
+
a = ' cmywgrb'.index(pixel)
|
|
238
|
+
b = ' cmywgrb'.index(bottom[x])
|
|
239
|
+
mask = (a.zero? ? 0 : 3) | (b.zero? ? 0 : 12)
|
|
240
|
+
mask.zero? ? ' ' : (1024 + (((a * 8) + b) * 16) + mask).chr(Encoding::UTF_8)
|
|
241
|
+
end.join
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Retain snake's centered quadrant presentation.
|
|
246
|
+
# A foreground-on-black terminal cell cannot show several colors plus black:
|
|
247
|
+
# merge occupancy rather than erasing neighboring pieces/body at color seams.
|
|
248
|
+
private_class_method def self.mini_quarter_board(opts = {})
|
|
249
|
+
source = opts[:board]
|
|
250
|
+
width = source.first.length
|
|
251
|
+
height = source.length
|
|
252
|
+
|
|
253
|
+
board = Array.new(height) { ' ' * width }
|
|
254
|
+
colors = { 'c' => 0, 'm' => 1, 'y' => 2, 'w' => 3, 'g' => 4, 'r' => 5, 'f' => 5, 'b' => 6 }
|
|
255
|
+
points = source.each_with_index.flat_map do |row, y|
|
|
256
|
+
row.chars.each_with_index.filter_map { |pixel, x| [x, y, pixel] unless pixel == ' ' }
|
|
257
|
+
end
|
|
258
|
+
points.sort_by! { |_, _, pixel| { 'f' => 2, 'y' => 1 }.fetch(pixel, 0) } if opts[:name] == 'snake'
|
|
259
|
+
points.each do |x, y, pixel|
|
|
260
|
+
mini_pixel(board: board, x: x + (width / 2), y: y + (height / 2), color: colors.fetch(pixel), merge: true)
|
|
261
|
+
end
|
|
262
|
+
board
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private_class_method def self.mini_emit(opts = {})
|
|
266
|
+
board = opts[:board]
|
|
267
|
+
rows = ['PWN'.center(board.first.length)] + board.map(&:dup)
|
|
268
|
+
record = { rows: rows, event: opts[:event] || :play, state: opts[:state] || {} }
|
|
269
|
+
mini_freeze(value: record)
|
|
270
|
+
(opts[:ticks] || 1).times { opts[:frames] << record }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private_class_method def self.mini_rotations(opts = {})
|
|
274
|
+
shape = opts[:shape]
|
|
275
|
+
Array.new(4) do
|
|
276
|
+
current = shape
|
|
277
|
+
rotated = shape.map { |x, y| [-y, x] }
|
|
278
|
+
min_x = rotated.map(&:first).min
|
|
279
|
+
min_y = rotated.map(&:last).min
|
|
280
|
+
shape = rotated.map { |x, y| [x - min_x, y - min_y] }.sort
|
|
281
|
+
current
|
|
282
|
+
end.uniq
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private_class_method def self.mini_fits?(opts = {})
|
|
286
|
+
board = opts[:board]
|
|
287
|
+
opts[:shape].all? do |dx, dy|
|
|
288
|
+
x = opts[:x] + dx
|
|
289
|
+
y = opts[:y] + dy
|
|
290
|
+
x >= 0 && x < board.first.length && y >= 0 && y < board.length && board[y][x] == ' '
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
private_class_method def self.mini_landing(opts = {})
|
|
295
|
+
board = opts[:board]
|
|
296
|
+
rng = opts[:rng]
|
|
297
|
+
candidates = []
|
|
298
|
+
rotations = mini_rotations(shape: opts[:shape])
|
|
299
|
+
spawn_x = (board.first.length - opts[:shape].map(&:first).max - 1) / 2
|
|
300
|
+
rotations.each_with_index do |shape, rotation|
|
|
301
|
+
# Only plan maneuvers the displayed spawn/rotate/steer sequence can make.
|
|
302
|
+
# An obstructed rotation is rejected, never mistaken for a top-out.
|
|
303
|
+
break unless mini_fits?(board: board, shape: shape, x: spawn_x, y: 0)
|
|
304
|
+
|
|
305
|
+
(board.first.length - shape.map(&:first).max).times do |x|
|
|
306
|
+
corridor = [spawn_x, x].min..[spawn_x, x].max
|
|
307
|
+
next unless corridor.all? { |px| mini_fits?(board: board, shape: shape, x: px, y: 0) }
|
|
308
|
+
|
|
309
|
+
y = 0
|
|
310
|
+
y += 1 while mini_fits?(board: board, shape: shape, x: x, y: y + 1)
|
|
311
|
+
test = board.map(&:dup)
|
|
312
|
+
shape.each { |dx, dy| test[y + dy][x + dx] = 'c' }
|
|
313
|
+
lines = test.count { |row| !row.include?(' ') }
|
|
314
|
+
heights = []
|
|
315
|
+
holes = 0
|
|
316
|
+
board.first.length.times do |column|
|
|
317
|
+
top = test.index { |row| row[column] != ' ' } || test.length
|
|
318
|
+
heights << (test.length - top)
|
|
319
|
+
holes += test.drop(top).count { |row| row[column] == ' ' }
|
|
320
|
+
end
|
|
321
|
+
score = (lines * 12) - (holes * 9) - (heights.sum * 0.65) - (heights.each_cons(2).sum { |a, b| (a - b).abs } * 0.5) + (rng.rand * 1.4)
|
|
322
|
+
candidates << { shape: shape, rotation: rotation, x: x, y: y, score: score }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
candidates.max_by { |candidate| candidate[:score] }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private_class_method def self.mini_simulate_falling_blocks(opts = {})
|
|
329
|
+
frames = opts[:frames]
|
|
330
|
+
width = opts[:width]
|
|
331
|
+
height = opts[:height]
|
|
332
|
+
rng = opts[:rng]
|
|
333
|
+
board = Array.new(height) { ' ' * width }
|
|
334
|
+
bag = []
|
|
335
|
+
while frames.length < MINI_FRAME_COUNT
|
|
336
|
+
bag = (0...MINI_SHAPES.length).to_a.shuffle(random: rng) if bag.empty?
|
|
337
|
+
type = bag.shift
|
|
338
|
+
shape = MINI_SHAPES[type]
|
|
339
|
+
landing = mini_landing(board: board, shape: shape, rng: rng)
|
|
340
|
+
x = (width - shape.map(&:first).max - 1) / 2
|
|
341
|
+
unless landing && mini_fits?(board: board, shape: shape, x: x, y: 0)
|
|
342
|
+
mini_reset_board(frames: frames, board: board)
|
|
343
|
+
board = Array.new(height) { ' ' * width }
|
|
344
|
+
next
|
|
345
|
+
end
|
|
346
|
+
rotations = mini_rotations(shape: shape)
|
|
347
|
+
plan = [[shape, x, 0]]
|
|
348
|
+
blocked = false
|
|
349
|
+
(1..landing[:rotation]).each do |rotation|
|
|
350
|
+
shape = rotations[rotation]
|
|
351
|
+
if mini_fits?(board: board, shape: shape, x: x, y: 0)
|
|
352
|
+
plan << [shape, x, 0]
|
|
353
|
+
else
|
|
354
|
+
blocked = true
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
until x == landing[:x] || blocked
|
|
358
|
+
x += landing[:x] <=> x
|
|
359
|
+
blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
|
|
360
|
+
plan << [shape, x, 0] unless blocked
|
|
361
|
+
end
|
|
362
|
+
if blocked
|
|
363
|
+
mini_reset_board(frames: frames, board: board)
|
|
364
|
+
board = Array.new(height) { ' ' * width }
|
|
365
|
+
next
|
|
366
|
+
end
|
|
367
|
+
(1..landing[:y]).each { |y| plan << [shape, x, y] }
|
|
368
|
+
color = %w[c y m b r g g][type]
|
|
369
|
+
plan.each do |piece, px, py|
|
|
370
|
+
picture = board.map(&:dup)
|
|
371
|
+
active = piece.map { |dx, dy| [px + dx, py + dy] }
|
|
372
|
+
active.each { |ax, ay| picture[ay][ax] = color }
|
|
373
|
+
mini_emit(frames: frames, board: picture,
|
|
374
|
+
state: { active: active, stack: board.map(&:dup), type: type })
|
|
375
|
+
end
|
|
376
|
+
shape.each { |dx, dy| board[landing[:y] + dy][x + dx] = color }
|
|
377
|
+
mini_emit(frames: frames, board: board, event: :lock, ticks: 2)
|
|
378
|
+
full = board.each_index.reject { |y| board[y].include?(' ') }
|
|
379
|
+
next if full.empty?
|
|
380
|
+
|
|
381
|
+
flash = board.map(&:dup)
|
|
382
|
+
full.each { |y| flash[y] = 'w' * width }
|
|
383
|
+
mini_emit(frames: frames, board: flash, event: :clear, state: { lines: full }, ticks: 3)
|
|
384
|
+
board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, y| row unless full.include?(y) }
|
|
385
|
+
mini_emit(frames: frames, board: board, event: :collapse, ticks: 2)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
private_class_method def self.mini_reset_board(opts = {})
|
|
390
|
+
board = opts[:board].map { |row| row.gsub(/[^ ]/, 'w') }
|
|
391
|
+
mini_emit(frames: opts[:frames], board: board, event: :game_over, ticks: 8)
|
|
392
|
+
board.length.times do |y|
|
|
393
|
+
board[y] = ' ' * board.first.length
|
|
394
|
+
mini_emit(frames: opts[:frames], board: board, event: :reset)
|
|
395
|
+
end
|
|
396
|
+
mini_emit(frames: opts[:frames], board: board, event: :ready, ticks: 4)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private_class_method def self.mini_snake_route(opts = {})
|
|
400
|
+
body = opts[:body]
|
|
401
|
+
food = opts[:food]
|
|
402
|
+
width = opts[:width]
|
|
403
|
+
height = opts[:height]
|
|
404
|
+
rng = opts[:rng]
|
|
405
|
+
queue = [[body.first, nil]]
|
|
406
|
+
seen = body.take(body.length - 1).to_h { |point| [point, true] }
|
|
407
|
+
directions = [[1, 0], [-1, 0], [0, 1], [0, -1]].shuffle(random: rng)
|
|
408
|
+
cursor = 0
|
|
409
|
+
while cursor < queue.length
|
|
410
|
+
point, first = queue[cursor]
|
|
411
|
+
cursor += 1
|
|
412
|
+
return first if point == food
|
|
413
|
+
|
|
414
|
+
directions.each do |dx, dy|
|
|
415
|
+
target = [point[0] + dx, point[1] + dy]
|
|
416
|
+
next unless target[0].between?(0, width - 1) && target[1].between?(0, height - 1)
|
|
417
|
+
next if seen[target]
|
|
418
|
+
|
|
419
|
+
seen[target] = true
|
|
420
|
+
queue << [target, first || target]
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
directions.map { |dx, dy| [body.first[0] + dx, body.first[1] + dy] }.find do |point|
|
|
424
|
+
point[0].between?(0, width - 1) && point[1].between?(0, height - 1) && !body.take(body.length - 1).include?(point)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
private_class_method def self.mini_simulate_snake(opts = {})
|
|
429
|
+
frames = opts[:frames]
|
|
430
|
+
width = opts[:width]
|
|
431
|
+
height = opts[:height]
|
|
432
|
+
rng = opts[:rng]
|
|
433
|
+
spaces = (0...height).flat_map { |y| (0...width).map { |x| [x, y] } }
|
|
434
|
+
while frames.length < MINI_FRAME_COUNT
|
|
435
|
+
y = rng.rand(height)
|
|
436
|
+
body = [[2, y], [1, y], [0, y]]
|
|
437
|
+
food = (spaces - body).sample(random: rng)
|
|
438
|
+
steps = 0
|
|
439
|
+
loop do
|
|
440
|
+
board = Array.new(height) { ' ' * width }
|
|
441
|
+
body.each_with_index { |(x, row), index| board[row][x] = index.zero? ? 'y' : 'g' }
|
|
442
|
+
board[food[1]][food[0]] = 'f' if food
|
|
443
|
+
mini_emit(frames: frames, board: board, ticks: 2, state: { body: body.map(&:dup), food: food&.dup })
|
|
444
|
+
break if frames.length >= MINI_FRAME_COUNT
|
|
445
|
+
|
|
446
|
+
target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng) if food
|
|
447
|
+
if !food || !target || steps >= width * height * 4
|
|
448
|
+
mini_reset_board(frames: frames, board: board)
|
|
449
|
+
break
|
|
450
|
+
end
|
|
451
|
+
body.unshift(target)
|
|
452
|
+
if target == food
|
|
453
|
+
food = (spaces - body).sample(random: rng)
|
|
454
|
+
steps = 0
|
|
455
|
+
else
|
|
456
|
+
body.pop
|
|
457
|
+
steps += 1
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
private_class_method def self.mini_simulate_pong(opts = {})
|
|
464
|
+
frames = opts[:frames]
|
|
465
|
+
width = opts[:width]
|
|
466
|
+
height = opts[:height]
|
|
467
|
+
rng = opts[:rng]
|
|
468
|
+
paddles = [(height - 1) / 2.0, (height - 1) / 2.0]
|
|
469
|
+
scores = [0, 0]
|
|
470
|
+
while frames.length < MINI_FRAME_COUNT
|
|
471
|
+
x = (width - 1) / 2.0
|
|
472
|
+
y = rng.rand(1...(height - 1)).to_f
|
|
473
|
+
vx = (rng.rand(2).zero? ? -1 : 1) * 0.325
|
|
474
|
+
vy = ((rng.rand * 0.4) + 0.1) * (rng.rand(2).zero? ? -1 : 1)
|
|
475
|
+
bias = Array.new(2) { rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5 }
|
|
476
|
+
rally = 0
|
|
477
|
+
mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: 16)
|
|
478
|
+
loop do
|
|
479
|
+
paddles.each_index do |side|
|
|
480
|
+
incoming = side.zero? ? vx.negative? : vx.positive?
|
|
481
|
+
target = incoming ? y - 0.25 + bias[side] : (height - 1) / 2.0
|
|
482
|
+
speed = incoming ? 0.275 : 0.11
|
|
483
|
+
paddles[side] = (paddles[side] + (target - paddles[side]).clamp(-speed, speed)).clamp(0, height - 1)
|
|
484
|
+
end
|
|
485
|
+
nx = x + vx
|
|
486
|
+
ny = y + vy
|
|
487
|
+
if ny.negative? || ny > height - 1
|
|
488
|
+
ny = ny.negative? ? -ny : (2 * (height - 1)) - ny
|
|
489
|
+
vy = -vy
|
|
490
|
+
end
|
|
491
|
+
side = if x >= 1 && nx < 1
|
|
492
|
+
0
|
|
493
|
+
elsif x <= width - 2 && nx > width - 2
|
|
494
|
+
1
|
|
495
|
+
end
|
|
496
|
+
event = :play
|
|
497
|
+
if side && (ny - paddles[side]).between?(-0.25, 0.75)
|
|
498
|
+
nx = side.zero? ? 2 - nx : (2 * (width - 2)) - nx
|
|
499
|
+
vx = -vx
|
|
500
|
+
offset = ny - (paddles[side] + 0.25)
|
|
501
|
+
vy = ((offset * 0.65) + (rng.rand * 0.15) - 0.075).clamp(-0.4, 0.4)
|
|
502
|
+
vy = (rng.rand(2).zero? ? -0.11 : 0.11) if vy.abs < 0.075
|
|
503
|
+
bias[1 - side] = rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5
|
|
504
|
+
rally += 1
|
|
505
|
+
event = :bounce
|
|
506
|
+
end
|
|
507
|
+
x = nx
|
|
508
|
+
y = ny
|
|
509
|
+
if x.negative? || x > width - 1
|
|
510
|
+
scores[x.negative? ? 1 : 0] += 1
|
|
511
|
+
mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: 20)
|
|
512
|
+
break
|
|
513
|
+
end
|
|
514
|
+
mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: event, rally: rally)
|
|
515
|
+
break if frames.length >= MINI_FRAME_COUNT
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
private_class_method def self.mini_pong_snapshot(opts = {})
|
|
521
|
+
width, height, paddles, ball, scores = opts.values_at(:width, :height, :paddles, :ball, :scores)
|
|
522
|
+
board = Array.new(height) { ' ' * width }
|
|
523
|
+
height.times { |y| board[y][width / 2] = ':' if y.even? }
|
|
524
|
+
paddles.each_with_index do |top, side|
|
|
525
|
+
2.times do |dy|
|
|
526
|
+
mini_pixel(board: board, x: side.zero? ? 0 : (width * 2) - 1, y: (top * 2).floor + dy, color: side)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
mini_pixel(board: board, x: (ball[0] * 2).round, y: (ball[1] * 2).round, color: 2) if ball
|
|
530
|
+
if opts[:event] == :score
|
|
531
|
+
[scores[0], (width - 1) / 2].min.times { |x| board[0][x] = 'c' }
|
|
532
|
+
[scores[1], (width - 1) / 2].min.times { |x| board[0][width - 1 - x] = 'm' }
|
|
533
|
+
end
|
|
534
|
+
mini_emit(frames: opts[:frames], board: board, event: opts[:event], ticks: opts[:ticks],
|
|
535
|
+
state: { ball: ball&.dup, paddles: paddles.dup, paddle_height: 1.0, scores: scores.dup, rally: opts[:rally] || 0 })
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Two-by-two quadrant pixels: same-color neighbors coalesce, no ANSI/IO.
|
|
539
|
+
private_class_method def self.mini_pixel(opts = {})
|
|
540
|
+
board = opts[:board]
|
|
541
|
+
x = opts[:x] % (board.first.length * 2)
|
|
542
|
+
y = opts[:y] % (board.length * 2)
|
|
543
|
+
color = opts[:color]
|
|
544
|
+
prior = board[y / 2][x / 2].ord - 256
|
|
545
|
+
mask = prior >= 0 && (opts[:merge] || prior / 16 == color) ? prior % 16 : 0
|
|
546
|
+
mask |= 1 << ((y % 2 * 2) + (x % 2))
|
|
547
|
+
board[y / 2][x / 2] = (256 + (color * 16) + mask).chr(Encoding::UTF_8)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
private_class_method def self.mini_simulate_asteroids(opts = {})
|
|
551
|
+
frames, width, height, rng = opts.values_at(:frames, :width, :height, :rng)
|
|
552
|
+
ship = [width / 2.0, height / 2.0, rng.rand * Math::PI * 2]
|
|
553
|
+
velocity = [0.0, 0.0]
|
|
554
|
+
rocks = []
|
|
555
|
+
shots = []
|
|
556
|
+
sparks = []
|
|
557
|
+
serial = 0
|
|
558
|
+
tick = 0
|
|
559
|
+
while frames.length < MINI_FRAME_COUNT
|
|
560
|
+
event = :play
|
|
561
|
+
# Reserve room for both fragments before spawning a large rock.
|
|
562
|
+
rock_budget = [3, width / 4].min + 1
|
|
563
|
+
occupied = rocks.sum { |rock| rock[4] > 0.25 ? 2 : 1 }
|
|
564
|
+
if rocks.empty? || ((tick % 120).zero? && occupied + 2 <= rock_budget)
|
|
565
|
+
serial += 1
|
|
566
|
+
x = rng.rand * width
|
|
567
|
+
y = rng.rand * height
|
|
568
|
+
dx = mini_delta(from: ship[0], to: x, span: width)
|
|
569
|
+
dy = mini_delta(from: ship[1], to: y, span: height)
|
|
570
|
+
if (dx * dx) + (dy * dy) < 1.5**2
|
|
571
|
+
x = (x + (width / 2.0)) % width
|
|
572
|
+
y = (y + (height / 2.0)) % height
|
|
573
|
+
end
|
|
574
|
+
rocks << [x, y, (rng.rand - 0.5) * 0.12, (rng.rand - 0.5) * 0.12, 0.5, serial]
|
|
575
|
+
event = :respawn
|
|
576
|
+
end
|
|
577
|
+
rocks.each do |rock|
|
|
578
|
+
rock[0] = (rock[0] + rock[2]) % width
|
|
579
|
+
rock[1] = (rock[1] + rock[3]) % height
|
|
580
|
+
end
|
|
581
|
+
target = rocks.min_by do |rock|
|
|
582
|
+
(mini_delta(from: ship[0], to: rock[0], span: width)**2) + (mini_delta(from: ship[1], to: rock[1], span: height)**2)
|
|
583
|
+
end
|
|
584
|
+
angle = Math.atan2(mini_delta(from: ship[1], to: target[1], span: height), mini_delta(from: ship[0], to: target[0], span: width))
|
|
585
|
+
turn = mini_delta(from: ship[2], to: angle, span: Math::PI * 2).clamp(-0.1, 0.1)
|
|
586
|
+
ship[2] = (ship[2] + turn) % (Math::PI * 2)
|
|
587
|
+
thrust = tick % 80 < 24
|
|
588
|
+
[Math.cos(ship[2]), Math.sin(ship[2])].each_with_index do |direction, axis|
|
|
589
|
+
velocity[axis] = ((velocity[axis] * 0.99) + (thrust ? direction * 0.006 : 0)).clamp(-0.15, 0.15)
|
|
590
|
+
ship[axis] = (ship[axis] + velocity[axis]) % (axis.zero? ? width : height)
|
|
591
|
+
end
|
|
592
|
+
shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if (tick % 12).zero?
|
|
593
|
+
shots.each do |shot|
|
|
594
|
+
shot[0] = (shot[0] + shot[2]) % width
|
|
595
|
+
shot[1] = (shot[1] + shot[3]) % height
|
|
596
|
+
shot[4] -= 1
|
|
597
|
+
hit = rocks.find do |rock|
|
|
598
|
+
dx = mini_delta(from: shot[0], to: rock[0], span: width)
|
|
599
|
+
dy = mini_delta(from: shot[1], to: rock[1], span: height)
|
|
600
|
+
(dx * dx) + (dy * dy) <= rock[4]**2
|
|
601
|
+
end
|
|
602
|
+
next unless hit
|
|
603
|
+
|
|
604
|
+
rocks.delete(hit)
|
|
605
|
+
shot[4] = 0
|
|
606
|
+
sparks << [hit[0], hit[1], 8]
|
|
607
|
+
if hit[4] > 0.25
|
|
608
|
+
[-1, 1].each do |sign|
|
|
609
|
+
serial += 1
|
|
610
|
+
rocks << [hit[0], hit[1], hit[2] + (sign * 0.07), hit[3] - (sign * 0.05), 0.2, serial]
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
event = :hit
|
|
614
|
+
end
|
|
615
|
+
shots.reject! { |shot| shot[4] <= 0 }
|
|
616
|
+
sparks.each { |spark| spark[2] -= 1 }
|
|
617
|
+
sparks.reject! { |spark| spark[2] <= 0 }
|
|
618
|
+
mini_asteroids_snapshot(frames: frames, width: width, height: height, ship: ship, thrust: thrust,
|
|
619
|
+
rocks: rocks, shots: shots, sparks: sparks, event: event)
|
|
620
|
+
tick += 1
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
private_class_method def self.mini_delta(opts = {})
|
|
625
|
+
span = opts[:span]
|
|
626
|
+
((opts[:to] - opts[:from] + (span / 2.0)) % span) - (span / 2.0)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
private_class_method def self.mini_asteroids_snapshot(opts = {})
|
|
630
|
+
width, height, ship, rocks, shots, sparks = opts.values_at(:width, :height, :ship, :rocks, :shots, :sparks)
|
|
631
|
+
board = Array.new(height) { ' ' * width }
|
|
632
|
+
rocks.each do |rock|
|
|
633
|
+
x, y, radius = rock.values_at(0, 1, 4)
|
|
634
|
+
count = radius > 0.25 ? 4 : 1
|
|
635
|
+
count.times do |index|
|
|
636
|
+
angle = index * Math::PI * 2 / count
|
|
637
|
+
dx = count == 1 ? 0 : Math.cos(angle) * radius
|
|
638
|
+
dy = count == 1 ? 0 : Math.sin(angle) * radius
|
|
639
|
+
mini_pixel(board: board, x: ((x + dx) * 2).round, y: ((y + dy) * 2).round, color: 3)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
sparks.each do |x, y, life|
|
|
643
|
+
2.times do |index|
|
|
644
|
+
angle = index * Math::PI
|
|
645
|
+
radius = (8 - life) * 0.12
|
|
646
|
+
mini_pixel(board: board, x: ((x + (Math.cos(angle) * radius)) * 2).round, y: ((y + (Math.sin(angle) * radius)) * 2).round, color: 3)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
shots.each { |x, y,| mini_pixel(board: board, x: (x * 2).round, y: (y * 2).round, color: 2) }
|
|
650
|
+
if opts[:thrust]
|
|
651
|
+
# A black subpixel gap separates exhaust from hull: a terminal cell has
|
|
652
|
+
# only two colors, so cyan/red/black in one quadrant glyph loses the red.
|
|
653
|
+
mini_pixel(board: board, x: ((ship[0] * 2) - (Math.cos(ship[2]) * 3)).round,
|
|
654
|
+
y: ((ship[1] * 2) - (Math.sin(ship[2]) * 3)).round, color: 5)
|
|
655
|
+
end
|
|
656
|
+
MINI_SHIP[(ship[2] * 4 / Math::PI).round % 8].each do |dx, dy|
|
|
657
|
+
mini_pixel(board: board, x: (ship[0] * 2).round + dx, y: (ship[1] * 2).round + dy, color: 0)
|
|
658
|
+
end
|
|
659
|
+
mini_emit(frames: opts[:frames], board: board, event: opts[:event],
|
|
660
|
+
state: { ship: ship.dup, thrust: opts[:thrust], rocks: rocks.map(&:dup), shots: shots.map(&:dup) })
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
private_class_method def self.mini_asteroids(opts = {})
|
|
664
|
+
rows, phase, width, height = opts.values_at(:rows, :phase, :width, :height)
|
|
665
|
+
3.times { |index| rows[1 + (((phase / 3) + index) % (height - 1))][((phase / 2) + (index * 3)) % width] = 'O' }
|
|
666
|
+
rows[height / 2][width / 2] = %w[> v < ^][phase / 15]
|
|
667
|
+
rows[1 + ((phase / 2) % (height - 1))][((width / 2) + phase) % width] = '.'
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
private_class_method def self.mini_falling_blocks(opts = {})
|
|
671
|
+
rows = opts[:rows]
|
|
672
|
+
phase = opts[:phase]
|
|
673
|
+
width = opts[:width]
|
|
674
|
+
height = opts[:height]
|
|
675
|
+
# Alternating T and O tetrominoes complete two prepared rows, then clear.
|
|
676
|
+
shape = phase < 30 ? ['###', ' # '] : ['##', '##']
|
|
677
|
+
tick = phase % 30
|
|
678
|
+
left = (width - shape.first.length) / 2
|
|
679
|
+
shape.each_with_index do |line, dy|
|
|
680
|
+
rows[height - 2 + dy] = '#' * width
|
|
681
|
+
line.chars.each_with_index do |cell, dx|
|
|
682
|
+
rows[height - 2 + dy][left + dx] = ' ' if cell == '#'
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
top = 1 + ([tick, 24].min * (height - 3) / 24)
|
|
686
|
+
shape.each_with_index do |line, dy|
|
|
687
|
+
line.chars.each_with_index { |cell, dx| rows[top + dy][left + dx] = cell if cell == '#' }
|
|
688
|
+
end
|
|
689
|
+
return unless tick >= 26
|
|
690
|
+
|
|
691
|
+
rows[-2] = rows[-1] = (tick.even? ? '=' : ' ') * width
|
|
692
|
+
# Do not alias caller-mutable strings across rows, including clear frames.
|
|
693
|
+
rows[-1] = rows[-1].dup
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
private_class_method def self.mini_snake(opts = {})
|
|
697
|
+
rows = opts[:rows]
|
|
698
|
+
width = opts[:width]
|
|
699
|
+
height = opts[:height]
|
|
700
|
+
# A closed perimeter path keeps both the body and the cycle seam contiguous.
|
|
701
|
+
path = (0...width).map { |x| [x, 1] }
|
|
702
|
+
path += (2...height).map { |y| [width - 1, y] }
|
|
703
|
+
path += (0...(width - 1)).to_a.reverse.map { |x| [x, height - 1] }
|
|
704
|
+
path += (2...(height - 1)).to_a.reverse.map { |y| [0, y] }
|
|
705
|
+
head = opts[:phase] * path.length / MINI_ASCII_FRAME_COUNT
|
|
706
|
+
rows[height / 2][width / 2] = '*'
|
|
707
|
+
[width, 7].min.times do |offset|
|
|
708
|
+
x, y = path[(head - offset) % path.length]
|
|
709
|
+
rows[y][x] = offset.zero? ? '@' : 'o'
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
private_class_method def self.mini_pong(opts = {})
|
|
714
|
+
rows = opts[:rows]
|
|
715
|
+
width = opts[:width]
|
|
716
|
+
height = opts[:height]
|
|
717
|
+
phase = opts[:phase]
|
|
718
|
+
# Triangle waves reflect the ball, with no teleport at the cycle boundary.
|
|
719
|
+
x = 1 + ((30 - (phase - 30).abs) * (width - 3) / 30)
|
|
720
|
+
y = 1 + ((15 - ((phase % 30) - 15).abs) * (height - 2) / 15)
|
|
721
|
+
(1...height).each { |row| rows[row][width / 2] = ':' if row.odd? }
|
|
722
|
+
paddle_top = y.clamp(1, height - 2)
|
|
723
|
+
(paddle_top..(paddle_top + 1)).each do |row|
|
|
724
|
+
rows[row][0] = '|'
|
|
725
|
+
rows[row][width - 1] = '|'
|
|
726
|
+
end
|
|
727
|
+
rows[y][x] = 'o'
|
|
728
|
+
end
|
|
729
|
+
|
|
24
730
|
# Supported Method Parameters::
|
|
25
731
|
# PWN::Banner.get(
|
|
26
732
|
# index: 'optional - defaults to random banner index'
|
|
@@ -100,6 +806,41 @@ module PWN
|
|
|
100
806
|
index: 'optional - defaults to random banner index'
|
|
101
807
|
)
|
|
102
808
|
|
|
809
|
+
# List compact animation names; sample once per session, not per redraw.
|
|
810
|
+
#{self}.mini_names
|
|
811
|
+
|
|
812
|
+
# Presentation cadence for colored games; ASCII keeps MINI_FRAME_SECONDS.
|
|
813
|
+
#{self}.mini_frame_seconds(
|
|
814
|
+
name: 'optional - animation name, defaults to falling_blocks'
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
# Return fresh ASCII rows; caller owns timing, theme, and terminal drawing.
|
|
818
|
+
# Decorative retro loops, not interactive games or progress/telemetry.
|
|
819
|
+
# Advance every MINI_FRAME_SECONDS (0.1 seconds); ASCII wraps at 60.
|
|
820
|
+
# Pass equal width and height for a square; complete artwork fits 5..16.
|
|
821
|
+
# No border is included. Smaller panes show only a clipped PWN wordmark.
|
|
822
|
+
#{self}.mini_frame(
|
|
823
|
+
name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
|
|
824
|
+
frame: 'optional - integer frame index, defaults to zero and wraps at 60',
|
|
825
|
+
width: 'optional - columns clamped to zero through 16; defaults to 16',
|
|
826
|
+
height: 'optional - rows clamped to zero through 16; defaults to 16'
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# Return fresh Unicode block cells: { glyph:, foreground:, background: }.
|
|
830
|
+
# Colors are standard terminal names; blocks may use colored backgrounds.
|
|
831
|
+
# No ANSI or IO. Other games retain black backgrounds.
|
|
832
|
+
# 1800 frames; mini_frame_seconds is 0.05 for Pong/Asteroids, 0.1 otherwise.
|
|
833
|
+
# Caller samples mini_names once per session. No timers or animation threads.
|
|
834
|
+
# Seeded gameplay, bounded six-replay cache; white hold/wipe marks resets.
|
|
835
|
+
# Square refers to terminal cells, not physical pixels. Caller owns borders.
|
|
836
|
+
#{self}.mini_cells(
|
|
837
|
+
name: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',
|
|
838
|
+
frame: 'optional - integer frame index, defaults to zero and wraps at 1800',
|
|
839
|
+
width: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
|
|
840
|
+
height: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',
|
|
841
|
+
seed: 'optional - integer local PRNG seed, defaults to 73; retain per session'
|
|
842
|
+
)
|
|
843
|
+
|
|
103
844
|
# Run welcome and return its result
|
|
104
845
|
#{self}.welcome
|
|
105
846
|
|