teek 0.1.0

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +139 -0
  5. data/Rakefile +316 -0
  6. data/ext/teek/extconf.rb +79 -0
  7. data/ext/teek/stubs.h +33 -0
  8. data/ext/teek/tcl9compat.h +211 -0
  9. data/ext/teek/tcltkbridge.c +1597 -0
  10. data/ext/teek/tcltkbridge.h +42 -0
  11. data/ext/teek/tkfont.c +218 -0
  12. data/ext/teek/tkphoto.c +477 -0
  13. data/ext/teek/tkwin.c +144 -0
  14. data/lib/teek/background_none.rb +158 -0
  15. data/lib/teek/background_ractor4x.rb +410 -0
  16. data/lib/teek/background_thread.rb +272 -0
  17. data/lib/teek/debugger.rb +742 -0
  18. data/lib/teek/demo_support.rb +150 -0
  19. data/lib/teek/ractor_support.rb +246 -0
  20. data/lib/teek/version.rb +5 -0
  21. data/lib/teek.rb +540 -0
  22. data/sample/calculator.rb +260 -0
  23. data/sample/debug_demo.rb +45 -0
  24. data/sample/goldberg.rb +1803 -0
  25. data/sample/goldberg_helpers.rb +170 -0
  26. data/sample/minesweeper/assets/MINESWEEPER_0.png +0 -0
  27. data/sample/minesweeper/assets/MINESWEEPER_1.png +0 -0
  28. data/sample/minesweeper/assets/MINESWEEPER_2.png +0 -0
  29. data/sample/minesweeper/assets/MINESWEEPER_3.png +0 -0
  30. data/sample/minesweeper/assets/MINESWEEPER_4.png +0 -0
  31. data/sample/minesweeper/assets/MINESWEEPER_5.png +0 -0
  32. data/sample/minesweeper/assets/MINESWEEPER_6.png +0 -0
  33. data/sample/minesweeper/assets/MINESWEEPER_7.png +0 -0
  34. data/sample/minesweeper/assets/MINESWEEPER_8.png +0 -0
  35. data/sample/minesweeper/assets/MINESWEEPER_F.png +0 -0
  36. data/sample/minesweeper/assets/MINESWEEPER_M.png +0 -0
  37. data/sample/minesweeper/assets/MINESWEEPER_X.png +0 -0
  38. data/sample/minesweeper/minesweeper.rb +452 -0
  39. data/sample/threading_demo.rb +499 -0
  40. data/teek.gemspec +32 -0
  41. metadata +179 -0
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Canvas and Tcl helpers for the Goldberg demo.
4
+ # Provides a thin wrapper around Tcl canvas commands so the draw/move
5
+ # methods read almost like the original tk-ng version.
6
+ #
7
+ # Including class must provide:
8
+ # @app - Teek::App instance
9
+ # @canvas - Tcl path of the canvas widget (String)
10
+
11
+ module GoldbergHelpers
12
+
13
+ # -- Tcl value formatting ------------------------------------------------
14
+
15
+ def tcl_val(v)
16
+ case v
17
+ when true then '1'
18
+ when false then '0'
19
+ when nil then '{}'
20
+ when Symbol then v.to_s
21
+ when Array
22
+ inner = v.map { |e|
23
+ s = e.is_a?(Symbol) ? e.to_s : e.to_s
24
+ s.include?(' ') ? "{#{s}}" : s
25
+ }.join(' ')
26
+ "{#{inner}}"
27
+ when String
28
+ v.empty? ? '{}' : "{#{v}}"
29
+ when Numeric
30
+ v.to_s
31
+ else
32
+ "{#{v}}"
33
+ end
34
+ end
35
+
36
+ def format_font(f)
37
+ return "{#{f}}" if f.is_a?(String)
38
+ parts = f.map { |p|
39
+ s = p.is_a?(Symbol) ? p.to_s : p.to_s
40
+ s.include?(' ') ? "{#{s}}" : s
41
+ }
42
+ "{#{parts.join(' ')}}"
43
+ end
44
+
45
+ def tcl_opts(opts)
46
+ opts.map { |k, v|
47
+ key = k == :tag ? :tags : k
48
+ val = key == :font ? format_font(v) : tcl_val(v)
49
+ "-#{key} #{val}"
50
+ }.join(' ')
51
+ end
52
+
53
+ # -- Canvas item creation ------------------------------------------------
54
+ # All return the Tcl item id (string).
55
+
56
+ def ccreate(type, *coords, **opts)
57
+ c = coords.flatten.join(' ')
58
+ o = opts.empty? ? '' : " #{tcl_opts(opts)}"
59
+ @app.tcl_eval("#{@canvas} create #{type} #{c}#{o}")
60
+ end
61
+
62
+ def cline(*coords, **opts) = ccreate(:line, *coords, **opts)
63
+ def cpoly(*coords, **opts) = ccreate(:polygon, *coords, **opts)
64
+ def coval(*coords, **opts) = ccreate(:oval, *coords, **opts)
65
+ def carc(*coords, **opts) = ccreate(:arc, *coords, **opts)
66
+ def crect(*coords, **opts) = ccreate(:rectangle, *coords, **opts)
67
+ def ctext(*coords, **opts) = ccreate(:text, *coords, **opts)
68
+ def cbitmap(*coords, **opts) = ccreate(:bitmap, *coords, **opts)
69
+
70
+ # -- Canvas operations ---------------------------------------------------
71
+
72
+ def cmove(tag, dx, dy)
73
+ @app.tcl_eval("#{@canvas} move #{tag} #{dx} #{dy}")
74
+ end
75
+
76
+ def ccoords(tag, new_coords = nil)
77
+ if new_coords
78
+ @app.tcl_eval("#{@canvas} coords #{tag} #{new_coords.flatten.join(' ')}")
79
+ else
80
+ @app.tcl_eval("#{@canvas} coords #{tag}").split.map(&:to_f)
81
+ end
82
+ end
83
+
84
+ def cdel(*tags)
85
+ tags.each { |t| @app.tcl_eval("#{@canvas} delete #{t}") }
86
+ end
87
+
88
+ def cbbox(tag)
89
+ r = @app.tcl_eval("#{@canvas} bbox #{tag}")
90
+ r.empty? ? nil : r.split.map(&:to_f)
91
+ end
92
+
93
+ def cscale(tag, ox, oy, sx, sy)
94
+ @app.tcl_eval("#{@canvas} scale #{tag} #{ox} #{oy} #{sx} #{sy}")
95
+ end
96
+
97
+ def citemconfig(tag, **opts)
98
+ @app.tcl_eval("#{@canvas} itemconfigure #{tag} #{tcl_opts(opts)}")
99
+ end
100
+
101
+ def citemcget(tag, opt)
102
+ @app.tcl_eval("#{@canvas} itemcget #{tag} -#{opt}")
103
+ end
104
+
105
+ def cfind(tag)
106
+ r = @app.tcl_eval("#{@canvas} find withtag #{tag}")
107
+ r.empty? ? [] : r.split
108
+ end
109
+
110
+ def craise(tag, above = nil)
111
+ cmd = "#{@canvas} raise #{tag}"
112
+ cmd += " #{above}" if above
113
+ @app.tcl_eval(cmd)
114
+ end
115
+
116
+ def clower(tag, below = nil)
117
+ cmd = "#{@canvas} lower #{tag}"
118
+ cmd += " #{below}" if below
119
+ @app.tcl_eval(cmd)
120
+ end
121
+
122
+ # Bind an event on a canvas item (tag).
123
+ def cbind_item(tag, event, &block)
124
+ id = @app.register_callback(proc { |*| block.call })
125
+ @app.tcl_eval("#{@canvas} bind #{tag} <#{event}> {ruby_callback #{id}}")
126
+ end
127
+
128
+ # Bind an event on the canvas widget itself.
129
+ def canvas_bind(event, &block)
130
+ @app.bind(@canvas, event, &block)
131
+ end
132
+
133
+ def canvas_bind_remove(event)
134
+ @app.unbind(@canvas, event)
135
+ end
136
+
137
+ # -- Misc helpers --------------------------------------------------------
138
+
139
+ def winfo_pixels(val)
140
+ @app.tcl_eval("winfo pixels #{@canvas} #{val}").to_i
141
+ end
142
+
143
+ def clock_ms
144
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
145
+ end
146
+
147
+ # Simple wrapper for a Tcl variable (for -textvariable / -variable binding).
148
+ class TclVar
149
+ attr_reader :name
150
+
151
+ def initialize(app, var_name, initial = '')
152
+ @app = app
153
+ @name = "::gb_#{var_name}"
154
+ set(initial)
155
+ end
156
+
157
+ def get
158
+ @app.get_variable(@name)
159
+ end
160
+
161
+ def set(v)
162
+ @app.set_variable(@name, v)
163
+ end
164
+
165
+ def to_s = get
166
+ def to_i = get.to_i
167
+ def to_f = get.to_f
168
+ def bool = (get != '0' && !get.empty?)
169
+ end
170
+ end
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Minesweeper clone built with Teek.
4
+ #
5
+ # Demonstrates:
6
+ # - Loading and resizing PNG images with Tk's photo system
7
+ # - Canvas-based game grid using image items
8
+ # - Click handling via canvas coordinate math (not per-item bindings)
9
+ # - Tcl variables for live-updating labels (-textvariable)
10
+ # - Menus with radiobuttons and keyboard accelerators
11
+ # - Scheduling repeated work with app.after
12
+ # - register_callback to bridge Ruby procs into Tcl event handlers
13
+ #
14
+ # Tile artwork: "Minesweeper Tile Set" by eugeneloza (CC0)
15
+ # https://opengameart.org/content/minesweeper-tile-set
16
+
17
+ require_relative '../../lib/teek'
18
+
19
+ class Minesweeper
20
+ # The source PNGs are 216x216. Tk can shrink them with "copy -subsample N N"
21
+ # which keeps every Nth pixel. 216 / 6 = 36, giving us nice 36px tiles.
22
+ TILE_SIZE = 36
23
+ SUBSAMPLE = 6
24
+
25
+ LEVELS = {
26
+ beginner: { cols: 9, rows: 9, mines: 10 },
27
+ intermediate: { cols: 16, rows: 16, mines: 40 },
28
+ expert: { cols: 30, rows: 16, mines: 99 }
29
+ }.freeze
30
+
31
+ def initialize(app, level: :beginner)
32
+ @app = app
33
+ @level = level
34
+ apply_level
35
+ load_images
36
+ build_ui
37
+ new_game
38
+ end
39
+
40
+ private
41
+
42
+ # -- Setup ---------------------------------------------------------------
43
+
44
+ def apply_level
45
+ cfg = LEVELS[@level]
46
+ @cols = cfg[:cols]
47
+ @rows = cfg[:rows]
48
+ @num_mines = cfg[:mines]
49
+ end
50
+
51
+ # Tk's "image create photo" loads a PNG into a named in-memory image.
52
+ # The full 216x216 images are too large for game tiles, so we create a
53
+ # second (empty) photo and copy into it with -subsample to shrink.
54
+ # After copying, we delete the full-size image to free memory.
55
+ #
56
+ # The resulting @img hash maps game state keys (:hidden, :flag, 1..8, etc.)
57
+ # to Tk photo names we can assign to canvas image items later.
58
+ def load_images
59
+ dir = File.join(__dir__, 'assets')
60
+ @img = {}
61
+
62
+ # Map game state keys to the PNG filename suffixes in assets/
63
+ tiles = { hidden: 'X', empty: '0', flag: 'F', mine: 'M' }
64
+ (1..8).each { |n| tiles[n] = n.to_s }
65
+
66
+ tiles.each do |key, suffix|
67
+ full = "ms_full_#{suffix}"
68
+ small = "ms_#{suffix}"
69
+ path = File.join(dir, "MINESWEEPER_#{suffix}.png")
70
+
71
+ # Load full-size PNG into a temporary Tk photo
72
+ @app.command(:image, :create, :photo, full, file: path)
73
+
74
+ # Create the smaller photo and copy with subsampling.
75
+ # -subsample takes two separate int args (x y), which command() can't
76
+ # express as a single kwarg, so this line stays as tcl_eval.
77
+ @app.command(:image, :create, :photo, small)
78
+ @app.tcl_eval("#{small} copy #{full} -subsample #{SUBSAMPLE} #{SUBSAMPLE}")
79
+
80
+ # Free the full-size image -- we only need the 36x36 version
81
+ @app.command(:image, :delete, full)
82
+ @img[key] = small
83
+ end
84
+ end
85
+
86
+ def build_ui
87
+ # "wm" commands control the window manager -- title, resizability, etc.
88
+ # "." is the root Tk window (every widget path starts from here).
89
+ @app.command(:wm, :title, '.', 'Minesweeper')
90
+ @app.command(:wm, :resizable, '.', 0, 0)
91
+
92
+ build_menu
93
+ build_header
94
+ build_canvas
95
+ end
96
+
97
+ # Tk menus: create a menu widget, attach it to the window with "configure
98
+ # -menu", then add items. Each item that triggers Ruby code needs a
99
+ # registered callback -- register_callback returns an integer ID, and
100
+ # "ruby_callback <id>" in Tcl invokes the corresponding Ruby proc.
101
+ def build_menu
102
+ @app.command(:menu, '.menubar')
103
+ @app.command('.', :configure, menu: '.menubar')
104
+ @app.command(:menu, '.menubar.game', tearoff: 0)
105
+ @app.command('.menubar', :add, :cascade, label: 'Game', menu: '.menubar.game')
106
+
107
+ # "add command" creates a clickable menu item.
108
+ # -accelerator is cosmetic (shows "F2" in the menu) -- the actual
109
+ # keybinding is set separately with "bind".
110
+ # With command(), procs are auto-registered as callbacks -- no need
111
+ # to manually call register_callback + interpolate the ID.
112
+ new_game_proc = proc { |*| new_game }
113
+ @app.command('.menubar.game', :add, :command,
114
+ label: 'New Game', accelerator: 'F2', command: new_game_proc)
115
+ @app.command(:bind, '.', '<F2>', new_game_proc)
116
+
117
+ @app.command('.menubar.game', :add, :separator)
118
+
119
+ # "add radiobutton" items share a Tcl variable -- Tk automatically shows
120
+ # a bullet next to the selected one. The -variable points to a global
121
+ # Tcl variable (:: prefix), and -value is what gets stored when selected.
122
+ @level_var = '::ms_level'
123
+ @app.command(:set, @level_var, @level)
124
+ LEVELS.each_key do |lvl|
125
+ @app.command('.menubar.game', :add, :radiobutton,
126
+ label: lvl.capitalize, variable: @level_var, value: lvl,
127
+ command: proc { |*| change_level(lvl) })
128
+ end
129
+
130
+ @app.command('.menubar.game', :add, :separator)
131
+
132
+ @app.command('.menubar.game', :add, :command,
133
+ label: 'Exit', command: proc { |*| @app.command(:destroy, '.') })
134
+ end
135
+
136
+ # The header bar uses "pack" geometry: mine counter on the left, face
137
+ # button expanding to fill the center, timer on the right.
138
+ #
139
+ # -textvariable connects a label to a Tcl variable. When we later do
140
+ # "set ::ms_mines 7", the label updates automatically -- no manual
141
+ # refresh needed. This is one of Tk's nicest features.
142
+ def build_header
143
+ @app.command(:frame, '.hdr', relief: :raised, bd: 2)
144
+ @app.command(:pack, '.hdr', fill: :x)
145
+
146
+ # Mine counter (left) -- red digits on black, like the classic LCD look
147
+ @mine_var = '::ms_mines'
148
+ @app.command(:set, @mine_var, @num_mines)
149
+ @app.command(:label, '.hdr.mines', textvariable: @mine_var, width: 4,
150
+ font: 'TkFixedFont 14 bold', fg: :red, bg: :black,
151
+ relief: :sunken, anchor: :center)
152
+ @app.command(:pack, '.hdr.mines', side: :left, padx: 5, pady: 3)
153
+
154
+ # Face button (center) -- doubles as new-game button
155
+ @face = '.hdr.face'
156
+ @app.command(:button, @face, text: ':)', width: 3,
157
+ font: 'TkFixedFont 12 bold',
158
+ command: proc { |*| new_game })
159
+ @app.command(:pack, @face, side: :left, expand: 1, padx: 5, pady: 3)
160
+
161
+ # Timer (right)
162
+ @time_var = '::ms_time'
163
+ @app.command(:set, @time_var, 0)
164
+ @app.command(:label, '.hdr.time', textvariable: @time_var, width: 4,
165
+ font: 'TkFixedFont 14 bold', fg: :red, bg: :black,
166
+ relief: :sunken, anchor: :center)
167
+ @app.command(:pack, '.hdr.time', side: :right, padx: 5, pady: 3)
168
+ end
169
+
170
+ # The game grid is a single Tk canvas filled with image items.
171
+ #
172
+ # Instead of binding a click handler to each of the 81+ cells (which would
173
+ # mean hundreds of registered callbacks), we bind ONE handler to the whole
174
+ # canvas. The trick: Tk's bind substitution "%x %y" gives us the pointer
175
+ # coordinates. We stash them in Tcl variables, then in the Ruby callback
176
+ # we read those variables and divide by TILE_SIZE to get row/col.
177
+ #
178
+ # "canvasx %x" converts window coords to canvas coords (matters if the
179
+ # canvas is scrolled, though we don't scroll here).
180
+ def build_canvas
181
+ cw = @cols * TILE_SIZE
182
+ ch = @rows * TILE_SIZE
183
+ @canvas = '.c'
184
+ @app.command(:canvas, @canvas, width: cw, height: ch, highlightthickness: 0)
185
+ @app.command(:pack, @canvas)
186
+
187
+ # Left-click: reveal a cell
188
+ lcb = @app.register_callback(proc { |*|
189
+ row, col = canvas_cell
190
+ on_left_click(row, col) if row
191
+ })
192
+ @app.tcl_eval("bind #{@canvas} <Button-1> " \
193
+ "{set ::_ms_x [#{@canvas} canvasx %x]; " \
194
+ "set ::_ms_y [#{@canvas} canvasy %y]; " \
195
+ "ruby_callback #{lcb}}")
196
+
197
+ # Right-click: toggle flag. Binding all three events covers:
198
+ # Button-2 -- right-click on macOS
199
+ # Button-3 -- right-click on Linux/Windows
200
+ # Ctrl+click -- fallback for single-button trackpads
201
+ rcb = @app.register_callback(proc { |*|
202
+ row, col = canvas_cell
203
+ on_right_click(row, col) if row
204
+ })
205
+ %w[Button-2 Button-3 Control-Button-1].each do |ev|
206
+ @app.tcl_eval("bind #{@canvas} <#{ev}> " \
207
+ "{set ::_ms_x [#{@canvas} canvasx %x]; " \
208
+ "set ::_ms_y [#{@canvas} canvasy %y]; " \
209
+ "ruby_callback #{rcb}}")
210
+ end
211
+ end
212
+
213
+ # Read the stashed click coordinates and convert to grid position.
214
+ # Returns [row, col] or [nil, nil] if the click was outside the grid.
215
+ def canvas_cell
216
+ mx = @app.command(:set, '::_ms_x').to_f
217
+ my = @app.command(:set, '::_ms_y').to_f
218
+ col = (mx / TILE_SIZE).to_i
219
+ row = (my / TILE_SIZE).to_i
220
+ in_bounds?(row, col) ? [row, col] : [nil, nil]
221
+ end
222
+
223
+ # -- Game state ----------------------------------------------------------
224
+
225
+ def new_game
226
+ stop_timer
227
+ @game_over = false
228
+ @first_click = true
229
+ @flags_placed = 0
230
+ @elapsed = 0
231
+
232
+ @mine = Array.new(@rows) { Array.new(@cols, false) }
233
+ @revealed = Array.new(@rows) { Array.new(@cols, false) }
234
+ @flagged = Array.new(@rows) { Array.new(@cols, false) }
235
+ @adjacent = Array.new(@rows) { Array.new(@cols, 0) }
236
+
237
+ # Update the header displays via their Tcl variables
238
+ @app.command(:set, @mine_var, @num_mines)
239
+ @app.command(:set, @time_var, 0)
240
+ @app.command(@face, :configure, text: ':)')
241
+
242
+ draw_board
243
+ end
244
+
245
+ # Changing difficulty resizes the canvas and resets. The window auto-shrinks
246
+ # because we set "wm resizable . 0 0" -- Tk recomputes the geometry.
247
+ def change_level(level)
248
+ return if level == @level
249
+
250
+ @level = level
251
+ apply_level
252
+
253
+ cw = @cols * TILE_SIZE
254
+ ch = @rows * TILE_SIZE
255
+ @app.command(@canvas, :configure, width: cw, height: ch)
256
+
257
+ new_game
258
+ end
259
+
260
+ # Populate the canvas with hidden-cell images. "canvas create image" places
261
+ # a Tk photo at (x, y) with -anchor nw (top-left corner). It returns a
262
+ # numeric item ID that we store in @cell_id so we can update each cell's
263
+ # image later with "itemconfigure <id> -image <photo>".
264
+ def draw_board
265
+ @app.command(@canvas, :delete, :all)
266
+ @cell_id = Array.new(@rows) { Array.new(@cols) }
267
+
268
+ @rows.times do |r|
269
+ @cols.times do |c|
270
+ x = c * TILE_SIZE
271
+ y = r * TILE_SIZE
272
+ @cell_id[r][c] = @app.command(@canvas, :create, :image, x, y,
273
+ image: @img[:hidden], anchor: :nw)
274
+ end
275
+ end
276
+ end
277
+
278
+ # -- Mine placement ------------------------------------------------------
279
+
280
+ # Mines are placed on the first click, not at game start. This guarantees
281
+ # the player's first click is always safe (and so are its neighbors).
282
+ def place_mines(safe_r, safe_c)
283
+ safe = { [safe_r, safe_c] => true }
284
+ neighbors(safe_r, safe_c).each { |nr, nc| safe[[nr, nc]] = true }
285
+
286
+ candidates = []
287
+ @rows.times { |r| @cols.times { |c| candidates << [r, c] unless safe[[r, c]] } }
288
+ candidates.shuffle!.first(@num_mines).each { |r, c| @mine[r][c] = true }
289
+
290
+ # Precompute how many mines neighbor each cell
291
+ @rows.times do |r|
292
+ @cols.times do |c|
293
+ next if @mine[r][c]
294
+ @adjacent[r][c] = neighbors(r, c).count { |nr, nc| @mine[nr][nc] }
295
+ end
296
+ end
297
+ end
298
+
299
+ # -- Click handlers ------------------------------------------------------
300
+
301
+ def on_left_click(r, c)
302
+ return if @game_over || @flagged[r][c] || @revealed[r][c]
303
+
304
+ if @first_click
305
+ @first_click = false
306
+ place_mines(r, c)
307
+ start_timer
308
+ end
309
+
310
+ if @mine[r][c]
311
+ game_over_lose(r, c)
312
+ else
313
+ reveal(r, c)
314
+ check_win
315
+ end
316
+ end
317
+
318
+ def on_right_click(r, c)
319
+ return if @game_over || @revealed[r][c]
320
+
321
+ if @flagged[r][c]
322
+ @flagged[r][c] = false
323
+ @flags_placed -= 1
324
+ set_cell_image(r, c, :hidden)
325
+ else
326
+ @flagged[r][c] = true
327
+ @flags_placed += 1
328
+ set_cell_image(r, c, :flag)
329
+ end
330
+ @app.command(:set, @mine_var, @num_mines - @flags_placed)
331
+ end
332
+
333
+ # -- Reveal / win / lose -------------------------------------------------
334
+
335
+ # Classic minesweeper flood fill: reveal a cell, and if it has zero
336
+ # adjacent mines, recursively reveal all its neighbors. This produces
337
+ # the satisfying "clearing" effect when you click an open area.
338
+ def reveal(r, c)
339
+ return unless in_bounds?(r, c)
340
+ return if @revealed[r][c] || @flagged[r][c] || @mine[r][c]
341
+
342
+ @revealed[r][c] = true
343
+ count = @adjacent[r][c]
344
+
345
+ if count == 0
346
+ set_cell_image(r, c, :empty)
347
+ neighbors(r, c).each { |nr, nc| reveal(nr, nc) }
348
+ else
349
+ set_cell_image(r, c, count)
350
+ end
351
+ end
352
+
353
+ # Win when every non-mine cell is revealed.
354
+ def check_win
355
+ unrevealed = 0
356
+ @rows.times { |r| @cols.times { |c| unrevealed += 1 unless @revealed[r][c] } }
357
+ return unless unrevealed == @num_mines
358
+
359
+ @game_over = true
360
+ stop_timer
361
+ @app.command(@face, :configure, text: 'B)')
362
+
363
+ # Auto-flag remaining mines as a visual cue
364
+ @rows.times do |r|
365
+ @cols.times do |c|
366
+ next unless @mine[r][c] && !@flagged[r][c]
367
+ @flagged[r][c] = true
368
+ set_cell_image(r, c, :flag)
369
+ end
370
+ end
371
+ @app.command(:set, @mine_var, 0)
372
+ end
373
+
374
+ def game_over_lose(_hit_r, _hit_c)
375
+ @game_over = true
376
+ stop_timer
377
+ @app.command(@face, :configure, text: ':(')
378
+
379
+ @rows.times do |r|
380
+ @cols.times do |c|
381
+ set_cell_image(r, c, :mine) if @mine[r][c]
382
+ end
383
+ end
384
+ end
385
+
386
+ # -- Timer ---------------------------------------------------------------
387
+
388
+ # app.after(ms) schedules a Ruby block to run after a delay. It's a
389
+ # one-shot timer, so for repeating work we schedule the next tick at the
390
+ # end of each callback. Checking @timer_running lets us stop the chain
391
+ # cleanly -- the next queued tick just returns without rescheduling.
392
+ def start_timer
393
+ @timer_running = true
394
+ @app.after(1000) { tick_timer }
395
+ end
396
+
397
+ def stop_timer
398
+ @timer_running = false
399
+ end
400
+
401
+ def tick_timer
402
+ return unless @timer_running
403
+ @elapsed += 1
404
+ @app.command(:set, @time_var, @elapsed)
405
+ @app.after(1000) { tick_timer }
406
+ end
407
+
408
+ # -- Helpers -------------------------------------------------------------
409
+
410
+ def in_bounds?(r, c)
411
+ r >= 0 && r < @rows && c >= 0 && c < @cols
412
+ end
413
+
414
+ # Return [row, col] pairs for all valid neighbors of a cell (up to 8).
415
+ def neighbors(r, c)
416
+ [[-1, -1], [-1, 0], [-1, 1],
417
+ [0, -1], [0, 1],
418
+ [1, -1], [1, 0], [1, 1]].filter_map do |dr, dc|
419
+ nr, nc = r + dr, c + dc
420
+ [nr, nc] if in_bounds?(nr, nc)
421
+ end
422
+ end
423
+
424
+ # Swap a cell's displayed image. "itemconfigure" changes properties of an
425
+ # existing canvas item by its numeric ID -- here we just swap -image.
426
+ def set_cell_image(r, c, key)
427
+ @app.command(@canvas, :itemconfigure, @cell_id[r][c], image: @img[key])
428
+ end
429
+ end
430
+
431
+ # -- Main ------------------------------------------------------------------
432
+
433
+ # track_widgets: false because we manage canvas items ourselves and don't
434
+ # need Teek's automatic widget tracking overhead.
435
+ app = Teek::App.new(track_widgets: false)
436
+
437
+ # The root window starts withdrawn by default in Teek -- show it.
438
+ app.show
439
+
440
+ Minesweeper.new(app)
441
+
442
+ # Automated demo support (for rake docker:test and recording)
443
+ require_relative '../../lib/teek/demo_support'
444
+ TeekDemo.app = app
445
+
446
+ if TeekDemo.testing?
447
+ TeekDemo.after_idle do
448
+ app.after(500) { TeekDemo.finish }
449
+ end
450
+ end
451
+
452
+ app.mainloop