teek 0.1.3 → 0.1.4
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/README.md +21 -0
- data/Rakefile +120 -22
- data/ext/teek/extconf.rb +19 -1
- data/ext/teek/tcltkbridge.c +38 -2
- data/ext/teek/tcltkbridge.h +3 -0
- data/ext/teek/tkdrop.c +66 -0
- data/ext/teek/tkdrop.h +26 -0
- data/ext/teek/tkdrop_macos.m +141 -0
- data/ext/teek/tkdrop_win.c +232 -0
- data/ext/teek/tkdrop_x11.c +337 -0
- data/ext/teek/tkwin.c +42 -0
- data/lib/teek/platform.rb +29 -0
- data/lib/teek/version.rb +1 -1
- data/lib/teek.rb +49 -3
- data/teek.gemspec +3 -2
- metadata +7 -53
- data/sample/calculator.rb +0 -255
- data/sample/debug_demo.rb +0 -43
- data/sample/gamepad_viewer/assets/controller.png +0 -0
- data/sample/gamepad_viewer/gamepad_viewer.rb +0 -554
- data/sample/goldberg.rb +0 -1803
- data/sample/goldberg_helpers.rb +0 -170
- data/sample/optcarrot/thwaite.nes +0 -0
- data/sample/optcarrot/vendor/optcarrot/apu.rb +0 -856
- data/sample/optcarrot/vendor/optcarrot/config.rb +0 -257
- data/sample/optcarrot/vendor/optcarrot/cpu.rb +0 -1162
- data/sample/optcarrot/vendor/optcarrot/driver.rb +0 -144
- data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +0 -14
- data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +0 -105
- data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +0 -153
- data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +0 -14
- data/sample/optcarrot/vendor/optcarrot/nes.rb +0 -105
- data/sample/optcarrot/vendor/optcarrot/opt.rb +0 -168
- data/sample/optcarrot/vendor/optcarrot/pad.rb +0 -92
- data/sample/optcarrot/vendor/optcarrot/palette.rb +0 -65
- data/sample/optcarrot/vendor/optcarrot/ppu.rb +0 -1468
- data/sample/optcarrot/vendor/optcarrot/rom.rb +0 -143
- data/sample/optcarrot/vendor/optcarrot.rb +0 -14
- data/sample/optcarrot.rb +0 -354
- data/sample/paint/assets/bucket.png +0 -0
- data/sample/paint/assets/cursor.png +0 -0
- data/sample/paint/assets/eraser.png +0 -0
- data/sample/paint/assets/pencil.png +0 -0
- data/sample/paint/assets/spray.png +0 -0
- data/sample/paint/layer.rb +0 -255
- data/sample/paint/layer_manager.rb +0 -179
- data/sample/paint/paint_demo.rb +0 -837
- data/sample/paint/sparse_pixel_buffer.rb +0 -202
- data/sample/sdl2_demo.rb +0 -318
- data/sample/threading_demo.rb +0 -494
- data/sample/yam/assets/MINESWEEPER_0.png +0 -0
- data/sample/yam/assets/MINESWEEPER_1.png +0 -0
- data/sample/yam/assets/MINESWEEPER_2.png +0 -0
- data/sample/yam/assets/MINESWEEPER_3.png +0 -0
- data/sample/yam/assets/MINESWEEPER_4.png +0 -0
- data/sample/yam/assets/MINESWEEPER_5.png +0 -0
- data/sample/yam/assets/MINESWEEPER_6.png +0 -0
- data/sample/yam/assets/MINESWEEPER_7.png +0 -0
- data/sample/yam/assets/MINESWEEPER_8.png +0 -0
- data/sample/yam/assets/MINESWEEPER_F.png +0 -0
- data/sample/yam/assets/MINESWEEPER_M.png +0 -0
- data/sample/yam/assets/MINESWEEPER_X.png +0 -0
- data/sample/yam/assets/click.wav +0 -0
- data/sample/yam/assets/explosion.wav +0 -0
- data/sample/yam/assets/flag.wav +0 -0
- data/sample/yam/assets/music.mp3 +0 -0
- data/sample/yam/assets/sweep.wav +0 -0
- data/sample/yam/yam.rb +0 -587
data/sample/yam/yam.rb
DELETED
|
@@ -1,587 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# teek-record: title=Yet Another Minesweeper, audio=1
|
|
3
|
-
#
|
|
4
|
-
# Minesweeper clone built with Teek.
|
|
5
|
-
#
|
|
6
|
-
# Demonstrates:
|
|
7
|
-
# - Loading and resizing PNG images with Tk's photo system
|
|
8
|
-
# - Canvas-based game grid using image items
|
|
9
|
-
# - Click handling via canvas coordinate math (not per-item bindings)
|
|
10
|
-
# - Tcl variables for live-updating labels (-textvariable)
|
|
11
|
-
# - Menus with radiobuttons and keyboard accelerators
|
|
12
|
-
# - Scheduling repeated work with app.after
|
|
13
|
-
# - register_callback to bridge Ruby procs into Tcl event handlers
|
|
14
|
-
#
|
|
15
|
-
# Tile artwork: "Minesweeper Tile Set" by eugeneloza (CC0)
|
|
16
|
-
# https://opengameart.org/content/minesweeper-tile-set
|
|
17
|
-
#
|
|
18
|
-
# Sound effects: generated with jsfxr (https://sfxr.me), public domain
|
|
19
|
-
# Music: "Vaporware" by The Cynic Project (CC0)
|
|
20
|
-
# https://opengameart.org/content/calm-piano-1-vaporware
|
|
21
|
-
# cynicmusic.com / pixelsphere.org
|
|
22
|
-
|
|
23
|
-
require_relative '../../lib/teek'
|
|
24
|
-
require_relative '../../teek-sdl2/lib/teek/sdl2'
|
|
25
|
-
|
|
26
|
-
class Minesweeper
|
|
27
|
-
# The source PNGs are 216x216. Tk can shrink them with "copy -subsample N N"
|
|
28
|
-
# which keeps every Nth pixel. 216 / 6 = 36, giving us nice 36px tiles.
|
|
29
|
-
TILE_SIZE = 36
|
|
30
|
-
SUBSAMPLE = 6
|
|
31
|
-
|
|
32
|
-
LEVELS = {
|
|
33
|
-
beginner: { cols: 9, rows: 9, mines: 10 },
|
|
34
|
-
intermediate: { cols: 16, rows: 16, mines: 40 },
|
|
35
|
-
expert: { cols: 30, rows: 16, mines: 99 }
|
|
36
|
-
}.freeze
|
|
37
|
-
|
|
38
|
-
attr_reader :app
|
|
39
|
-
|
|
40
|
-
def initialize(app, level: :beginner)
|
|
41
|
-
@app = app
|
|
42
|
-
@level = level
|
|
43
|
-
apply_level
|
|
44
|
-
load_images
|
|
45
|
-
load_sounds
|
|
46
|
-
build_ui
|
|
47
|
-
new_game
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Simulate press/release on a cell for demo/test automation.
|
|
51
|
-
def press_cell(r, c) = on_left_press(r, c)
|
|
52
|
-
def release_cell(r, c) = on_left_release(r, c)
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
# -- Setup ---------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
def apply_level
|
|
59
|
-
cfg = LEVELS[@level]
|
|
60
|
-
@cols = cfg[:cols]
|
|
61
|
-
@rows = cfg[:rows]
|
|
62
|
-
@num_mines = cfg[:mines]
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Tk's "image create photo" loads a PNG into a named in-memory image.
|
|
66
|
-
# The full 216x216 images are too large for game tiles, so we create a
|
|
67
|
-
# second (empty) photo and copy into it with -subsample to shrink.
|
|
68
|
-
# After copying, we delete the full-size image to free memory.
|
|
69
|
-
#
|
|
70
|
-
# The resulting @img hash maps game state keys (:hidden, :flag, 1..8, etc.)
|
|
71
|
-
# to Tk photo names we can assign to canvas image items later.
|
|
72
|
-
def load_images
|
|
73
|
-
dir = File.join(__dir__, 'assets')
|
|
74
|
-
@img = {}
|
|
75
|
-
|
|
76
|
-
# Map game state keys to the PNG filename suffixes in assets/
|
|
77
|
-
tiles = { hidden: 'X', empty: '0', flag: 'F', mine: 'M' }
|
|
78
|
-
(1..8).each { |n| tiles[n] = n.to_s }
|
|
79
|
-
|
|
80
|
-
tiles.each do |key, suffix|
|
|
81
|
-
full = "ms_full_#{suffix}"
|
|
82
|
-
small = "ms_#{suffix}"
|
|
83
|
-
path = File.join(dir, "MINESWEEPER_#{suffix}.png")
|
|
84
|
-
|
|
85
|
-
# Load full-size PNG into a temporary Tk photo
|
|
86
|
-
@app.command(:image, :create, :photo, full, file: path)
|
|
87
|
-
|
|
88
|
-
# Create the smaller photo and copy with subsampling.
|
|
89
|
-
# -subsample takes two separate int args (x y), which command() can't
|
|
90
|
-
# express as a single kwarg, so this line stays as tcl_eval.
|
|
91
|
-
@app.command(:image, :create, :photo, small)
|
|
92
|
-
@app.tcl_eval("#{small} copy #{full} -subsample #{SUBSAMPLE} #{SUBSAMPLE}")
|
|
93
|
-
|
|
94
|
-
# Free the full-size image -- we only need the 36x36 version
|
|
95
|
-
@app.command(:image, :delete, full)
|
|
96
|
-
@img[key] = small
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def load_sounds
|
|
101
|
-
dir = File.join(__dir__, 'assets')
|
|
102
|
-
@snd_click = Teek::SDL2::Sound.new(File.join(dir, 'click.wav'))
|
|
103
|
-
@snd_sweep = Teek::SDL2::Sound.new(File.join(dir, 'sweep.wav'))
|
|
104
|
-
@snd_flag = Teek::SDL2::Sound.new(File.join(dir, 'flag.wav'))
|
|
105
|
-
@snd_explosion = Teek::SDL2::Sound.new(File.join(dir, 'explosion.wav'))
|
|
106
|
-
@music = Teek::SDL2::Music.new(File.join(dir, 'music.mp3'))
|
|
107
|
-
@music.volume = 48
|
|
108
|
-
@music_on = true
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def build_ui
|
|
112
|
-
# "wm" commands control the window manager -- title, resizability, etc.
|
|
113
|
-
# "." is the root Tk window (every widget path starts from here).
|
|
114
|
-
@app.command(:wm, :title, '.', 'Yet Another Minesweeper')
|
|
115
|
-
@app.command(:wm, :resizable, '.', 0, 0)
|
|
116
|
-
|
|
117
|
-
build_menu
|
|
118
|
-
build_header
|
|
119
|
-
build_canvas
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Tk menus: create a menu widget, attach it to the window with "configure
|
|
123
|
-
# -menu", then add items. Each item that triggers Ruby code needs a
|
|
124
|
-
# registered callback -- register_callback returns an integer ID, and
|
|
125
|
-
# "ruby_callback <id>" in Tcl invokes the corresponding Ruby proc.
|
|
126
|
-
def build_menu
|
|
127
|
-
@app.command(:menu, '.menubar')
|
|
128
|
-
@app.command('.', :configure, menu: '.menubar')
|
|
129
|
-
@app.command(:menu, '.menubar.game', tearoff: 0)
|
|
130
|
-
@app.command('.menubar', :add, :cascade, label: 'Game', menu: '.menubar.game')
|
|
131
|
-
|
|
132
|
-
# "add command" creates a clickable menu item.
|
|
133
|
-
# -accelerator is cosmetic (shows "F2" in the menu) -- the actual
|
|
134
|
-
# keybinding is set separately with "bind".
|
|
135
|
-
# With command(), procs are auto-registered as callbacks -- no need
|
|
136
|
-
# to manually call register_callback + interpolate the ID.
|
|
137
|
-
new_game_proc = proc { |*| new_game }
|
|
138
|
-
@app.command('.menubar.game', :add, :command,
|
|
139
|
-
label: 'New Game', accelerator: 'F2', command: new_game_proc)
|
|
140
|
-
@app.command(:bind, '.', '<F2>', new_game_proc)
|
|
141
|
-
|
|
142
|
-
@app.command('.menubar.game', :add, :separator)
|
|
143
|
-
|
|
144
|
-
# "add radiobutton" items share a Tcl variable -- Tk automatically shows
|
|
145
|
-
# a bullet next to the selected one. The -variable points to a global
|
|
146
|
-
# Tcl variable (:: prefix), and -value is what gets stored when selected.
|
|
147
|
-
@level_var = '::ms_level'
|
|
148
|
-
@app.command(:set, @level_var, @level)
|
|
149
|
-
LEVELS.each_key do |lvl|
|
|
150
|
-
@app.command('.menubar.game', :add, :radiobutton,
|
|
151
|
-
label: lvl.capitalize, variable: @level_var, value: lvl,
|
|
152
|
-
command: proc { |*| change_level(lvl) })
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
@app.command('.menubar.game', :add, :separator)
|
|
156
|
-
|
|
157
|
-
@app.command('.menubar.game', :add, :command,
|
|
158
|
-
label: 'Exit', command: proc { |*| @app.command(:destroy, '.') })
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# The header bar uses "pack" geometry: mine counter on the left, face
|
|
162
|
-
# button expanding to fill the center, timer on the right.
|
|
163
|
-
#
|
|
164
|
-
# -textvariable connects a label to a Tcl variable. When we later do
|
|
165
|
-
# "set ::ms_mines 7", the label updates automatically -- no manual
|
|
166
|
-
# refresh needed. This is one of Tk's nicest features.
|
|
167
|
-
def build_header
|
|
168
|
-
@app.command(:frame, '.hdr', relief: :raised, bd: 2)
|
|
169
|
-
@app.command(:pack, '.hdr', fill: :x)
|
|
170
|
-
|
|
171
|
-
# Mine counter (left) -- red digits on black, like the classic LCD look
|
|
172
|
-
@mine_var = '::ms_mines'
|
|
173
|
-
@app.command(:set, @mine_var, @num_mines)
|
|
174
|
-
@app.command(:label, '.hdr.mines', textvariable: @mine_var, width: 4,
|
|
175
|
-
font: 'TkFixedFont 14 bold', fg: :red, bg: :black,
|
|
176
|
-
relief: :sunken, anchor: :center)
|
|
177
|
-
@app.command(:pack, '.hdr.mines', side: :left, padx: 5, pady: 3)
|
|
178
|
-
|
|
179
|
-
# Face button (center) -- doubles as new-game button
|
|
180
|
-
@face = '.hdr.face'
|
|
181
|
-
@app.command(:button, @face, text: ':)', width: 3,
|
|
182
|
-
font: 'TkFixedFont 12 bold',
|
|
183
|
-
command: proc { |*| new_game })
|
|
184
|
-
@app.command(:pack, @face, side: :left, expand: 1, padx: 5, pady: 3)
|
|
185
|
-
|
|
186
|
-
# Timer (right)
|
|
187
|
-
@time_var = '::ms_time'
|
|
188
|
-
@app.command(:set, @time_var, 0)
|
|
189
|
-
@app.command(:label, '.hdr.time', textvariable: @time_var, width: 4,
|
|
190
|
-
font: 'TkFixedFont 14 bold', fg: :red, bg: :black,
|
|
191
|
-
relief: :sunken, anchor: :center)
|
|
192
|
-
@app.command(:pack, '.hdr.time', side: :right, padx: 5, pady: 3)
|
|
193
|
-
|
|
194
|
-
# Music toggle (right, next to timer)
|
|
195
|
-
@music_btn = '.hdr.music'
|
|
196
|
-
@app.command(:button, @music_btn, text: "\u266A", width: 2,
|
|
197
|
-
font: 'TkDefaultFont 10',
|
|
198
|
-
command: proc { |*| toggle_music })
|
|
199
|
-
@app.command(:pack, @music_btn, side: :right, padx: 2, pady: 3)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# The game grid is a single Tk canvas filled with image items.
|
|
203
|
-
#
|
|
204
|
-
# Instead of binding a click handler to each of the 81+ cells (which would
|
|
205
|
-
# mean hundreds of registered callbacks), we bind ONE handler to the whole
|
|
206
|
-
# canvas. The trick: Tk's bind substitution "%x %y" gives us the pointer
|
|
207
|
-
# coordinates. We stash them in Tcl variables, then in the Ruby callback
|
|
208
|
-
# we read those variables and divide by TILE_SIZE to get row/col.
|
|
209
|
-
#
|
|
210
|
-
# "canvasx %x" converts window coords to canvas coords (matters if the
|
|
211
|
-
# canvas is scrolled, though we don't scroll here).
|
|
212
|
-
def build_canvas
|
|
213
|
-
cw = @cols * TILE_SIZE
|
|
214
|
-
ch = @rows * TILE_SIZE
|
|
215
|
-
@canvas = '.c'
|
|
216
|
-
@app.command(:canvas, @canvas, width: cw, height: ch, highlightthickness: 0)
|
|
217
|
-
@app.command(:pack, @canvas)
|
|
218
|
-
|
|
219
|
-
# Left-click: press shows sunken tile + suspense face, release reveals.
|
|
220
|
-
# This mimics classic Windows Minesweeper's press-and-hold behavior.
|
|
221
|
-
@pressed_cell = nil
|
|
222
|
-
|
|
223
|
-
press_cb = @app.register_callback(proc { |*|
|
|
224
|
-
row, col = canvas_cell
|
|
225
|
-
on_left_press(row, col) if row
|
|
226
|
-
})
|
|
227
|
-
@app.tcl_eval("bind #{@canvas} <ButtonPress-1> " \
|
|
228
|
-
"{set ::_ms_x [#{@canvas} canvasx %x]; " \
|
|
229
|
-
"set ::_ms_y [#{@canvas} canvasy %y]; " \
|
|
230
|
-
"ruby_callback #{press_cb}}")
|
|
231
|
-
|
|
232
|
-
release_cb = @app.register_callback(proc { |*|
|
|
233
|
-
row, col = canvas_cell
|
|
234
|
-
on_left_release(row, col) if row
|
|
235
|
-
})
|
|
236
|
-
@app.tcl_eval("bind #{@canvas} <ButtonRelease-1> " \
|
|
237
|
-
"{set ::_ms_x [#{@canvas} canvasx %x]; " \
|
|
238
|
-
"set ::_ms_y [#{@canvas} canvasy %y]; " \
|
|
239
|
-
"ruby_callback #{release_cb}}")
|
|
240
|
-
|
|
241
|
-
# Right-click: toggle flag. Binding all three events covers:
|
|
242
|
-
# Button-2 -- right-click on macOS
|
|
243
|
-
# Button-3 -- right-click on Linux/Windows
|
|
244
|
-
# Ctrl+click -- fallback for single-button trackpads
|
|
245
|
-
rcb = @app.register_callback(proc { |*|
|
|
246
|
-
row, col = canvas_cell
|
|
247
|
-
on_right_click(row, col) if row
|
|
248
|
-
})
|
|
249
|
-
%w[Button-2 Button-3 Control-Button-1].each do |ev|
|
|
250
|
-
@app.tcl_eval("bind #{@canvas} <#{ev}> " \
|
|
251
|
-
"{set ::_ms_x [#{@canvas} canvasx %x]; " \
|
|
252
|
-
"set ::_ms_y [#{@canvas} canvasy %y]; " \
|
|
253
|
-
"ruby_callback #{rcb}}")
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# Read the stashed click coordinates and convert to grid position.
|
|
258
|
-
# Returns [row, col] or [nil, nil] if the click was outside the grid.
|
|
259
|
-
def canvas_cell
|
|
260
|
-
mx = @app.command(:set, '::_ms_x').to_f
|
|
261
|
-
my = @app.command(:set, '::_ms_y').to_f
|
|
262
|
-
col = (mx / TILE_SIZE).to_i
|
|
263
|
-
row = (my / TILE_SIZE).to_i
|
|
264
|
-
in_bounds?(row, col) ? [row, col] : [nil, nil]
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# -- Game state ----------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
def new_game
|
|
270
|
-
stop_timer
|
|
271
|
-
@game_over = false
|
|
272
|
-
@first_click = true
|
|
273
|
-
@flags_placed = 0
|
|
274
|
-
@elapsed = 0
|
|
275
|
-
|
|
276
|
-
@mine = Array.new(@rows) { Array.new(@cols, false) }
|
|
277
|
-
@revealed = Array.new(@rows) { Array.new(@cols, false) }
|
|
278
|
-
@flagged = Array.new(@rows) { Array.new(@cols, false) }
|
|
279
|
-
@adjacent = Array.new(@rows) { Array.new(@cols, 0) }
|
|
280
|
-
|
|
281
|
-
# Update the header displays via their Tcl variables
|
|
282
|
-
@app.command(:set, @mine_var, @num_mines)
|
|
283
|
-
@app.command(:set, @time_var, 0)
|
|
284
|
-
@app.command(@face, :configure, text: ':)')
|
|
285
|
-
|
|
286
|
-
draw_board
|
|
287
|
-
@music.play if @music_on && !@music.playing?
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Changing difficulty resizes the canvas and resets. The window auto-shrinks
|
|
291
|
-
# because we set "wm resizable . 0 0" -- Tk recomputes the geometry.
|
|
292
|
-
def change_level(level)
|
|
293
|
-
return if level == @level
|
|
294
|
-
|
|
295
|
-
@level = level
|
|
296
|
-
apply_level
|
|
297
|
-
|
|
298
|
-
cw = @cols * TILE_SIZE
|
|
299
|
-
ch = @rows * TILE_SIZE
|
|
300
|
-
@app.command(@canvas, :configure, width: cw, height: ch)
|
|
301
|
-
|
|
302
|
-
new_game
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Populate the canvas with hidden-cell images. "canvas create image" places
|
|
306
|
-
# a Tk photo at (x, y) with -anchor nw (top-left corner). It returns a
|
|
307
|
-
# numeric item ID that we store in @cell_id so we can update each cell's
|
|
308
|
-
# image later with "itemconfigure <id> -image <photo>".
|
|
309
|
-
def draw_board
|
|
310
|
-
@app.command(@canvas, :delete, :all)
|
|
311
|
-
@cell_id = Array.new(@rows) { Array.new(@cols) }
|
|
312
|
-
|
|
313
|
-
@rows.times do |r|
|
|
314
|
-
@cols.times do |c|
|
|
315
|
-
x = c * TILE_SIZE
|
|
316
|
-
y = r * TILE_SIZE
|
|
317
|
-
@cell_id[r][c] = @app.command(@canvas, :create, :image, x, y,
|
|
318
|
-
image: @img[:hidden], anchor: :nw)
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# -- Mine placement ------------------------------------------------------
|
|
324
|
-
|
|
325
|
-
# Mines are placed on the first click, not at game start. This guarantees
|
|
326
|
-
# the player's first click is always safe (and so are its neighbors).
|
|
327
|
-
def place_mines(safe_r, safe_c)
|
|
328
|
-
safe = { [safe_r, safe_c] => true }
|
|
329
|
-
neighbors(safe_r, safe_c).each { |nr, nc| safe[[nr, nc]] = true }
|
|
330
|
-
|
|
331
|
-
candidates = []
|
|
332
|
-
@rows.times { |r| @cols.times { |c| candidates << [r, c] unless safe[[r, c]] } }
|
|
333
|
-
rng = ENV['SEED'] ? Random.new(ENV['SEED'].to_i) : Random.new
|
|
334
|
-
candidates.shuffle!(random: rng).first(@num_mines).each { |r, c| @mine[r][c] = true }
|
|
335
|
-
|
|
336
|
-
# Precompute how many mines neighbor each cell
|
|
337
|
-
@rows.times do |r|
|
|
338
|
-
@cols.times do |c|
|
|
339
|
-
next if @mine[r][c]
|
|
340
|
-
@adjacent[r][c] = neighbors(r, c).count { |nr, nc| @mine[nr][nc] }
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
# -- Click handlers ------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
def on_left_press(r, c)
|
|
348
|
-
return if @game_over || @flagged[r][c] || @revealed[r][c]
|
|
349
|
-
|
|
350
|
-
# Show sunken/pressed tile and suspense face
|
|
351
|
-
@pressed_cell = [r, c]
|
|
352
|
-
set_cell_image(r, c, :empty)
|
|
353
|
-
@app.command(@face, :configure, text: ':o')
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def on_left_release(r, c)
|
|
357
|
-
prev = @pressed_cell
|
|
358
|
-
@pressed_cell = nil
|
|
359
|
-
|
|
360
|
-
# Restore face
|
|
361
|
-
@app.command(@face, :configure, text: ':)') unless @game_over
|
|
362
|
-
|
|
363
|
-
# If released on a different cell than pressed, restore the pressed cell
|
|
364
|
-
if prev && prev != [r, c]
|
|
365
|
-
pr, pc = prev
|
|
366
|
-
set_cell_image(pr, pc, :hidden) unless @revealed[pr][pc]
|
|
367
|
-
return
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
return if @game_over || @flagged[r][c] || @revealed[r][c]
|
|
371
|
-
|
|
372
|
-
if @first_click
|
|
373
|
-
@first_click = false
|
|
374
|
-
place_mines(r, c)
|
|
375
|
-
start_timer
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
if @mine[r][c]
|
|
379
|
-
@snd_explosion.play
|
|
380
|
-
game_over_lose(r, c)
|
|
381
|
-
else
|
|
382
|
-
@cascading = false
|
|
383
|
-
reveal(r, c)
|
|
384
|
-
check_win
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def on_right_click(r, c)
|
|
389
|
-
return if @game_over || @revealed[r][c]
|
|
390
|
-
|
|
391
|
-
@snd_flag.play
|
|
392
|
-
if @flagged[r][c]
|
|
393
|
-
@flagged[r][c] = false
|
|
394
|
-
@flags_placed -= 1
|
|
395
|
-
set_cell_image(r, c, :hidden)
|
|
396
|
-
else
|
|
397
|
-
@flagged[r][c] = true
|
|
398
|
-
@flags_placed += 1
|
|
399
|
-
set_cell_image(r, c, :flag)
|
|
400
|
-
end
|
|
401
|
-
@app.command(:set, @mine_var, @num_mines - @flags_placed)
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# -- Reveal / win / lose -------------------------------------------------
|
|
405
|
-
|
|
406
|
-
# Classic minesweeper flood fill: reveal a cell, and if it has zero
|
|
407
|
-
# adjacent mines, recursively reveal all its neighbors. This produces
|
|
408
|
-
# the satisfying "clearing" effect when you click an open area.
|
|
409
|
-
def reveal(r, c)
|
|
410
|
-
return unless in_bounds?(r, c)
|
|
411
|
-
return if @revealed[r][c] || @flagged[r][c] || @mine[r][c]
|
|
412
|
-
|
|
413
|
-
@revealed[r][c] = true
|
|
414
|
-
count = @adjacent[r][c]
|
|
415
|
-
|
|
416
|
-
if count == 0
|
|
417
|
-
set_cell_image(r, c, :empty)
|
|
418
|
-
unless @cascading
|
|
419
|
-
@cascading = true
|
|
420
|
-
@snd_sweep.play
|
|
421
|
-
end
|
|
422
|
-
neighbors(r, c).each { |nr, nc| reveal(nr, nc) }
|
|
423
|
-
else
|
|
424
|
-
@snd_click.play unless @cascading
|
|
425
|
-
set_cell_image(r, c, count)
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
# Win when every non-mine cell is revealed.
|
|
430
|
-
def check_win
|
|
431
|
-
unrevealed = 0
|
|
432
|
-
@rows.times { |r| @cols.times { |c| unrevealed += 1 unless @revealed[r][c] } }
|
|
433
|
-
return unless unrevealed == @num_mines
|
|
434
|
-
|
|
435
|
-
@game_over = true
|
|
436
|
-
stop_timer
|
|
437
|
-
@app.command(@face, :configure, text: 'B)')
|
|
438
|
-
|
|
439
|
-
# Auto-flag remaining mines as a visual cue
|
|
440
|
-
@rows.times do |r|
|
|
441
|
-
@cols.times do |c|
|
|
442
|
-
next unless @mine[r][c] && !@flagged[r][c]
|
|
443
|
-
@flagged[r][c] = true
|
|
444
|
-
set_cell_image(r, c, :flag)
|
|
445
|
-
end
|
|
446
|
-
end
|
|
447
|
-
@app.command(:set, @mine_var, 0)
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
def game_over_lose(_hit_r, _hit_c)
|
|
451
|
-
@game_over = true
|
|
452
|
-
stop_timer
|
|
453
|
-
@app.command(@face, :configure, text: ':(')
|
|
454
|
-
Teek::SDL2.fade_out_music(1500) if @music_on
|
|
455
|
-
|
|
456
|
-
@rows.times do |r|
|
|
457
|
-
@cols.times do |c|
|
|
458
|
-
set_cell_image(r, c, :mine) if @mine[r][c]
|
|
459
|
-
end
|
|
460
|
-
end
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
# -- Timer ---------------------------------------------------------------
|
|
464
|
-
|
|
465
|
-
# app.after(ms) schedules a Ruby block to run after a delay. It's a
|
|
466
|
-
# one-shot timer, so for repeating work we schedule the next tick at the
|
|
467
|
-
# end of each callback. Checking @timer_running lets us stop the chain
|
|
468
|
-
# cleanly -- the next queued tick just returns without rescheduling.
|
|
469
|
-
def start_timer
|
|
470
|
-
@timer_running = true
|
|
471
|
-
@app.after(1000) { tick_timer }
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
def stop_timer
|
|
475
|
-
@timer_running = false
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
def tick_timer
|
|
479
|
-
return unless @timer_running
|
|
480
|
-
@elapsed += 1
|
|
481
|
-
@app.command(:set, @time_var, @elapsed)
|
|
482
|
-
@app.after(1000) { tick_timer }
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# -- Music ---------------------------------------------------------------
|
|
486
|
-
|
|
487
|
-
def toggle_music
|
|
488
|
-
if @music_on
|
|
489
|
-
@music.pause
|
|
490
|
-
@music_on = false
|
|
491
|
-
@app.command(@music_btn, :configure, text: '--')
|
|
492
|
-
else
|
|
493
|
-
if @music.paused?
|
|
494
|
-
@music.resume
|
|
495
|
-
else
|
|
496
|
-
@music.play
|
|
497
|
-
end
|
|
498
|
-
@music_on = true
|
|
499
|
-
@app.command(@music_btn, :configure, text: "\u266A")
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
# -- Helpers -------------------------------------------------------------
|
|
504
|
-
|
|
505
|
-
def in_bounds?(r, c)
|
|
506
|
-
r >= 0 && r < @rows && c >= 0 && c < @cols
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
# Return [row, col] pairs for all valid neighbors of a cell (up to 8).
|
|
510
|
-
def neighbors(r, c)
|
|
511
|
-
[[-1, -1], [-1, 0], [-1, 1],
|
|
512
|
-
[0, -1], [0, 1],
|
|
513
|
-
[1, -1], [1, 0], [1, 1]].filter_map do |dr, dc|
|
|
514
|
-
nr, nc = r + dr, c + dc
|
|
515
|
-
[nr, nc] if in_bounds?(nr, nc)
|
|
516
|
-
end
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
# Swap a cell's displayed image. "itemconfigure" changes properties of an
|
|
520
|
-
# existing canvas item by its numeric ID -- here we just swap -image.
|
|
521
|
-
def set_cell_image(r, c, key)
|
|
522
|
-
@app.command(@canvas, :itemconfigure, @cell_id[r][c], image: @img[key])
|
|
523
|
-
end
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
# -- Main ------------------------------------------------------------------
|
|
527
|
-
|
|
528
|
-
# track_widgets: false because we manage canvas items ourselves and don't
|
|
529
|
-
# need Teek's automatic widget tracking overhead.
|
|
530
|
-
app = Teek::App.new(track_widgets: false)
|
|
531
|
-
|
|
532
|
-
# The root window starts withdrawn by default in Teek -- show it.
|
|
533
|
-
app.show
|
|
534
|
-
|
|
535
|
-
game = Minesweeper.new(app)
|
|
536
|
-
|
|
537
|
-
# Automated demo support (for rake docker:test and recording)
|
|
538
|
-
require_relative '../../lib/teek/demo_support'
|
|
539
|
-
TeekDemo.app = app
|
|
540
|
-
|
|
541
|
-
if TeekDemo.active?
|
|
542
|
-
ENV['SEED'] = '42'
|
|
543
|
-
game.send(:new_game) # restart with deterministic layout
|
|
544
|
-
|
|
545
|
-
if TeekDemo.recording?
|
|
546
|
-
app.set_window_geometry('+0+0')
|
|
547
|
-
app.tcl_eval('. configure -cursor none')
|
|
548
|
-
TeekDemo.signal_recording_ready
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
# Capture all audio output to WAV when TEEK_RECORD_AUDIO is set.
|
|
552
|
-
# The WAV can be muxed with the screen recording via ffmpeg:
|
|
553
|
-
# ffmpeg -i screen.mp4 -i yam_audio.wav -c:v copy -c:a aac -shortest out.mp4
|
|
554
|
-
audio_capture_path = ENV['TEEK_RECORD_AUDIO']
|
|
555
|
-
audio_capture_path = nil if audio_capture_path&.empty?
|
|
556
|
-
Teek::SDL2.start_audio_capture(audio_capture_path) if audio_capture_path
|
|
557
|
-
|
|
558
|
-
TeekDemo.after_idle {
|
|
559
|
-
d = TeekDemo.method(:delay)
|
|
560
|
-
|
|
561
|
-
# Click (row=2, col=3) — safe reveal, then (row=0, col=4) — mine. Boom!
|
|
562
|
-
steps = [
|
|
563
|
-
-> { game.press_cell(2, 3) },
|
|
564
|
-
-> { game.release_cell(2, 3) },
|
|
565
|
-
nil,
|
|
566
|
-
-> { game.press_cell(0, 4) },
|
|
567
|
-
-> { game.release_cell(0, 4) },
|
|
568
|
-
nil, nil,
|
|
569
|
-
-> {
|
|
570
|
-
Teek::SDL2.stop_audio_capture if audio_capture_path
|
|
571
|
-
TeekDemo.finish
|
|
572
|
-
},
|
|
573
|
-
]
|
|
574
|
-
|
|
575
|
-
i = 0
|
|
576
|
-
run_step = proc {
|
|
577
|
-
steps[i]&.call
|
|
578
|
-
i += 1
|
|
579
|
-
if i < steps.length
|
|
580
|
-
app.after(d.call(test: 50, record: 1500)) { run_step.call }
|
|
581
|
-
end
|
|
582
|
-
}
|
|
583
|
-
run_step.call
|
|
584
|
-
}
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
app.mainloop
|