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.
- checksums.yaml +7 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/Rakefile +316 -0
- data/ext/teek/extconf.rb +79 -0
- data/ext/teek/stubs.h +33 -0
- data/ext/teek/tcl9compat.h +211 -0
- data/ext/teek/tcltkbridge.c +1597 -0
- data/ext/teek/tcltkbridge.h +42 -0
- data/ext/teek/tkfont.c +218 -0
- data/ext/teek/tkphoto.c +477 -0
- data/ext/teek/tkwin.c +144 -0
- data/lib/teek/background_none.rb +158 -0
- data/lib/teek/background_ractor4x.rb +410 -0
- data/lib/teek/background_thread.rb +272 -0
- data/lib/teek/debugger.rb +742 -0
- data/lib/teek/demo_support.rb +150 -0
- data/lib/teek/ractor_support.rb +246 -0
- data/lib/teek/version.rb +5 -0
- data/lib/teek.rb +540 -0
- data/sample/calculator.rb +260 -0
- data/sample/debug_demo.rb +45 -0
- data/sample/goldberg.rb +1803 -0
- data/sample/goldberg_helpers.rb +170 -0
- data/sample/minesweeper/assets/MINESWEEPER_0.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_1.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_2.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_3.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_4.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_5.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_6.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_7.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_8.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_F.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_M.png +0 -0
- data/sample/minesweeper/assets/MINESWEEPER_X.png +0 -0
- data/sample/minesweeper/minesweeper.rb +452 -0
- data/sample/threading_demo.rb +499 -0
- data/teek.gemspec +32 -0
- 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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|