teek 0.1.2 → 0.1.3
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/Rakefile +1 -0
- data/ext/teek/tcltkbridge.c +6 -0
- data/lib/teek/version.rb +1 -1
- data/lib/teek.rb +199 -4
- data/sample/gamepad_viewer/assets/controller.png +0 -0
- data/sample/gamepad_viewer/gamepad_viewer.rb +554 -0
- data/sample/optcarrot/thwaite.nes +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/{minesweeper/minesweeper.rb → yam/yam.rb} +147 -12
- metadata +22 -14
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_0.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_1.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_2.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_3.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_4.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_5.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_6.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_7.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_8.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_F.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_M.png +0 -0
- /data/sample/{minesweeper → yam}/assets/MINESWEEPER_X.png +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fe2f5c480c472d6479ac1b92088a748e1216cb1a15472e9e98fbd3c2e4fd347
|
|
4
|
+
data.tar.gz: c4d2da92180136ca756955258ea59402b27b9019e9d492f0969a7c346dcc053a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a10e7f92ca08b1385288829cf0188beb408c90cdf52aecb30a36652f2b299954318c4b3a64434ea0158950f05e105db5a289c115dab4506fa91b7ab6e70f26e
|
|
7
|
+
data.tar.gz: 7cd908cf513d6f92cad5c89d04cb49f7bf3fb3e9d7868af4d0d0fbb7db04e2a7a712c3e6e1e2fd107fd4df4b84140b49d720b2357cc9b9452067a8f5b7bfe2f6
|
data/Rakefile
CHANGED
data/ext/teek/tcltkbridge.c
CHANGED
|
@@ -336,6 +336,12 @@ interp_initialize(int argc, VALUE *argv, VALUE self)
|
|
|
336
336
|
rb_raise(eTclError, "Tk_Init failed: %s", err);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/* Hide the Tk console if it was auto-created during Tk_Init.
|
|
340
|
+
* On macOS/Windows, Tk may create a console window depending on
|
|
341
|
+
* how the process was launched. "catch" handles Linux where the
|
|
342
|
+
* console command does not exist. */
|
|
343
|
+
Tcl_Eval(tip->interp, "catch {console hide}");
|
|
344
|
+
|
|
339
345
|
/* 7. Initialize Tk stubs - after Tk_Init */
|
|
340
346
|
tk_version = Tk_InitStubs(tip->interp, TK_VERSION, 0);
|
|
341
347
|
if (tk_version == NULL) {
|
data/lib/teek/version.rb
CHANGED
data/lib/teek.rb
CHANGED
|
@@ -48,13 +48,15 @@ module Teek
|
|
|
48
48
|
|
|
49
49
|
class App
|
|
50
50
|
attr_reader :interp, :widgets, :debugger
|
|
51
|
+
attr_writer :_pending_exception # @api private
|
|
51
52
|
|
|
52
|
-
def initialize(track_widgets: true, debug: false, &block)
|
|
53
|
+
def initialize(title: nil, track_widgets: true, debug: false, &block)
|
|
53
54
|
@interp = Teek::Interp.new
|
|
54
55
|
@interp.tcl_eval('package require Tk')
|
|
55
56
|
hide
|
|
56
57
|
@widgets = {}
|
|
57
58
|
@widget_counters = Hash.new(0)
|
|
59
|
+
@_pending_exception = nil
|
|
58
60
|
debug ||= !!ENV['TEEK_DEBUG']
|
|
59
61
|
track_widgets = true if debug
|
|
60
62
|
setup_widget_tracking if track_widgets
|
|
@@ -62,6 +64,7 @@ module Teek
|
|
|
62
64
|
require_relative 'teek/debugger'
|
|
63
65
|
@debugger = Teek::Debugger.new(self)
|
|
64
66
|
end
|
|
67
|
+
set_window_title(title) if title
|
|
65
68
|
instance_eval(&block) if block
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -119,14 +122,24 @@ module Teek
|
|
|
119
122
|
|
|
120
123
|
# Schedule a one-shot timer. Calls the block after +ms+ milliseconds.
|
|
121
124
|
# @param ms [Integer] delay in milliseconds
|
|
125
|
+
# @param on_error [:raise, Proc, nil] error handling strategy:
|
|
126
|
+
# - +:raise+ (default) — exception propagates to Tcl background error handler.
|
|
127
|
+
# - +Proc+ — called with the exception; error is swallowed.
|
|
128
|
+
# - +nil+ — error is silently swallowed.
|
|
122
129
|
# @yield block to call when the timer fires
|
|
123
130
|
# @return [String] timer ID, pass to {#after_cancel} to cancel
|
|
124
131
|
# @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/after.htm#M5 after ms
|
|
125
|
-
def after(ms, &block)
|
|
132
|
+
def after(ms, on_error: :raise, &block)
|
|
126
133
|
cb_id = nil
|
|
127
134
|
cb_id = @interp.register_callback(proc { |*|
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
begin
|
|
136
|
+
block.call
|
|
137
|
+
rescue => e
|
|
138
|
+
raise if on_error == :raise
|
|
139
|
+
on_error.call(e) if on_error.is_a?(Proc)
|
|
140
|
+
ensure
|
|
141
|
+
@interp.unregister_callback(cb_id)
|
|
142
|
+
end
|
|
130
143
|
})
|
|
131
144
|
after_id = @interp.tcl_eval("after #{ms.to_i} {ruby_callback #{cb_id}}")
|
|
132
145
|
after_id.instance_variable_set(:@cb_id, cb_id)
|
|
@@ -148,6 +161,33 @@ module Teek
|
|
|
148
161
|
after_id
|
|
149
162
|
end
|
|
150
163
|
|
|
164
|
+
# Schedule a repeating timer. Calls the block every +ms+ milliseconds
|
|
165
|
+
# until cancelled. The block runs on the main thread in the event loop,
|
|
166
|
+
# so it must be fast (don't block the UI).
|
|
167
|
+
#
|
|
168
|
+
# @param ms [Integer] interval in milliseconds
|
|
169
|
+
# @param on_error [:raise, Proc, nil] error handling strategy:
|
|
170
|
+
# - +:raise+ (default) — cancels the timer and raises the exception
|
|
171
|
+
# from the next call to {#update}.
|
|
172
|
+
# - +Proc+ — called with the exception; timer keeps running.
|
|
173
|
+
# - +nil+ — cancels the timer silently; error stored in {RepeatingTimer#last_error}.
|
|
174
|
+
# @yield block to call on each tick
|
|
175
|
+
# @return [RepeatingTimer] cancel handle
|
|
176
|
+
#
|
|
177
|
+
# @example Basic polling loop
|
|
178
|
+
# timer = app.every(50) { update_display }
|
|
179
|
+
# timer.cancel # stop later
|
|
180
|
+
#
|
|
181
|
+
# @example With error handler (timer keeps running)
|
|
182
|
+
# timer = app.every(100, on_error: ->(e) { log(e) }) { risky_work }
|
|
183
|
+
#
|
|
184
|
+
# @example Silent cancel on error
|
|
185
|
+
# timer = app.every(50, on_error: nil) { maybe_fails }
|
|
186
|
+
# timer.last_error # => check later
|
|
187
|
+
def every(ms, on_error: :raise, &block)
|
|
188
|
+
RepeatingTimer.new(self, ms, on_error: on_error, &block)
|
|
189
|
+
end
|
|
190
|
+
|
|
151
191
|
# Cancel a pending {#after} or {#after_idle} timer.
|
|
152
192
|
# @param after_id [String] timer ID returned by {#after} or {#after_idle}
|
|
153
193
|
# @return [void]
|
|
@@ -390,6 +430,10 @@ module Teek
|
|
|
390
430
|
# @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/update.htm update
|
|
391
431
|
def update
|
|
392
432
|
@interp.tcl_eval('update')
|
|
433
|
+
if (e = @_pending_exception)
|
|
434
|
+
@_pending_exception = nil
|
|
435
|
+
raise e
|
|
436
|
+
end
|
|
393
437
|
end
|
|
394
438
|
|
|
395
439
|
# Process only pending idle callbacks (e.g. geometry redraws), then return.
|
|
@@ -415,6 +459,44 @@ module Teek
|
|
|
415
459
|
@interp.tcl_eval("wm withdraw #{window}")
|
|
416
460
|
end
|
|
417
461
|
|
|
462
|
+
# Enable the Tk debug console. The console starts hidden and can be
|
|
463
|
+
# toggled with the given keyboard shortcut (default: F12).
|
|
464
|
+
#
|
|
465
|
+
# The Tk console is a built-in interactive Tcl shell — useful for
|
|
466
|
+
# inspecting variables, running Tcl commands, and debugging widget
|
|
467
|
+
# layouts at runtime. It is available on macOS and Windows only;
|
|
468
|
+
# on Linux this method is a no-op (Linux has the real terminal).
|
|
469
|
+
#
|
|
470
|
+
# @param keybinding [String] Tk event to toggle the console
|
|
471
|
+
# (default: "<F12>")
|
|
472
|
+
# @return [Boolean] true if the console was created, false if
|
|
473
|
+
# unavailable on this platform
|
|
474
|
+
# @example
|
|
475
|
+
# app = Teek::App.new
|
|
476
|
+
# app.add_debug_console # F12 toggles console
|
|
477
|
+
# app.add_debug_console("<F11>") # custom key
|
|
478
|
+
# @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/console.htm console
|
|
479
|
+
def add_debug_console(keybinding = '<F12>')
|
|
480
|
+
@interp.create_console
|
|
481
|
+
@_console_visible = false
|
|
482
|
+
|
|
483
|
+
toggle = proc do |*|
|
|
484
|
+
if @_console_visible
|
|
485
|
+
tcl_eval('console hide')
|
|
486
|
+
@_console_visible = false
|
|
487
|
+
else
|
|
488
|
+
tcl_eval('console show')
|
|
489
|
+
@_console_visible = true
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
command(:bind, '.', keybinding, toggle)
|
|
494
|
+
true
|
|
495
|
+
rescue TclError => e
|
|
496
|
+
warn "Teek: debug console not available on this platform (#{e.message})"
|
|
497
|
+
false
|
|
498
|
+
end
|
|
499
|
+
|
|
418
500
|
# Set a window's title.
|
|
419
501
|
# @param title [String] new title
|
|
420
502
|
# @param window [String] Tk window path
|
|
@@ -675,9 +757,122 @@ module Teek
|
|
|
675
757
|
"{ruby_callback #{id}}"
|
|
676
758
|
when Symbol
|
|
677
759
|
value.to_s
|
|
760
|
+
when Array
|
|
761
|
+
"{#{value.map { |v| tcl_value(v) }.join(' ')}}"
|
|
678
762
|
else
|
|
679
763
|
"{#{value}}"
|
|
680
764
|
end
|
|
681
765
|
end
|
|
682
766
|
end
|
|
767
|
+
|
|
768
|
+
# A cancellable repeating timer that fires on the main thread.
|
|
769
|
+
#
|
|
770
|
+
# Created via {App#every}. Reschedules itself after each tick using
|
|
771
|
+
# Tcl's +after+ command. The block runs in the event loop, so it
|
|
772
|
+
# must complete quickly to avoid blocking the UI.
|
|
773
|
+
#
|
|
774
|
+
# Tracks timing drift: if a tick fires significantly late (more than
|
|
775
|
+
# 2x the interval), a warning is printed to stderr. This helps catch
|
|
776
|
+
# blocks that are too slow for the requested interval.
|
|
777
|
+
#
|
|
778
|
+
# @see App#every
|
|
779
|
+
class RepeatingTimer
|
|
780
|
+
# @return [Integer] interval in milliseconds
|
|
781
|
+
attr_reader :interval
|
|
782
|
+
|
|
783
|
+
# @return [Exception, nil] the last error if the timer stopped due to an
|
|
784
|
+
# unhandled exception, nil otherwise
|
|
785
|
+
attr_reader :last_error
|
|
786
|
+
|
|
787
|
+
# @return [Integer] number of ticks that fired late (> 2x interval)
|
|
788
|
+
attr_reader :late_ticks
|
|
789
|
+
|
|
790
|
+
# @api private
|
|
791
|
+
def initialize(app, ms, on_error: nil, &block)
|
|
792
|
+
raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
|
|
793
|
+
|
|
794
|
+
@app = app
|
|
795
|
+
@interval = ms
|
|
796
|
+
@block = block
|
|
797
|
+
@on_error = on_error
|
|
798
|
+
@cancelled = false
|
|
799
|
+
@after_id = nil
|
|
800
|
+
@last_error = nil
|
|
801
|
+
@late_ticks = 0
|
|
802
|
+
@next_expected = nil
|
|
803
|
+
schedule
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Stop the timer. Safe to call multiple times.
|
|
807
|
+
# @return [void]
|
|
808
|
+
def cancel
|
|
809
|
+
return if @cancelled
|
|
810
|
+
@cancelled = true
|
|
811
|
+
@app.after_cancel(@after_id) if @after_id
|
|
812
|
+
@after_id = nil
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# @return [Boolean] true if the timer has been cancelled
|
|
816
|
+
def cancelled?
|
|
817
|
+
@cancelled
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# Change the interval. Takes effect on the next tick.
|
|
821
|
+
# @param ms [Integer] new interval in milliseconds
|
|
822
|
+
def interval=(ms)
|
|
823
|
+
raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
|
|
824
|
+
@interval = ms
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
private
|
|
828
|
+
|
|
829
|
+
def schedule
|
|
830
|
+
return if @cancelled
|
|
831
|
+
@next_expected = now_ms + @interval
|
|
832
|
+
@after_id = @app.after(@interval) { tick }
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def tick
|
|
836
|
+
return if @cancelled
|
|
837
|
+
check_drift
|
|
838
|
+
@block.call
|
|
839
|
+
schedule
|
|
840
|
+
rescue => e
|
|
841
|
+
@last_error = e
|
|
842
|
+
case @on_error
|
|
843
|
+
when :raise
|
|
844
|
+
@cancelled = true
|
|
845
|
+
# Store on App so it raises from the next app.update call.
|
|
846
|
+
# Don't re-raise here — that would go through rb_protect → bgerror.
|
|
847
|
+
@app._pending_exception = e
|
|
848
|
+
when Proc
|
|
849
|
+
begin
|
|
850
|
+
@on_error.call(e)
|
|
851
|
+
rescue => handler_err
|
|
852
|
+
@last_error = handler_err
|
|
853
|
+
@cancelled = true
|
|
854
|
+
@app._pending_exception = handler_err
|
|
855
|
+
return
|
|
856
|
+
end
|
|
857
|
+
schedule
|
|
858
|
+
when nil
|
|
859
|
+
@cancelled = true
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def check_drift
|
|
864
|
+
return unless @next_expected
|
|
865
|
+
actual = now_ms
|
|
866
|
+
drift = actual - @next_expected
|
|
867
|
+
if drift > @interval
|
|
868
|
+
@late_ticks += 1
|
|
869
|
+
warn "Teek::RepeatingTimer: tick #{@late_ticks} fired #{drift.round}ms late " \
|
|
870
|
+
"(interval=#{@interval}ms)"
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def now_ms
|
|
875
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
876
|
+
end
|
|
877
|
+
end
|
|
683
878
|
end
|
|
Binary file
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Gamepad Viewer — SDL2 gamepad input visualized with pure Tk widgets.
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates:
|
|
6
|
+
# - Teek::SDL2::Gamepad for controller input (polling + events)
|
|
7
|
+
# - Virtual gamepad wired to keyboard for testing without hardware
|
|
8
|
+
# - Combobox for gamepad selection with hot-plug detection
|
|
9
|
+
# - Canvas overlay highlights on a controller image
|
|
10
|
+
# - Tk label indicators for button/axis state
|
|
11
|
+
# - Periodic polling via app.every (50ms game loop)
|
|
12
|
+
#
|
|
13
|
+
# Controller artwork: "Generic Gamepad Template" by Erratic (CC0)
|
|
14
|
+
# https://opengameart.org/content/generic-gamepad-template
|
|
15
|
+
|
|
16
|
+
require_relative '../../lib/teek'
|
|
17
|
+
require_relative '../../teek-sdl2/lib/teek/sdl2'
|
|
18
|
+
|
|
19
|
+
class GamepadViewer
|
|
20
|
+
GP = Teek::SDL2::Gamepad
|
|
21
|
+
|
|
22
|
+
# Approximate pixel coordinates for button highlights on controller.png (479x310)
|
|
23
|
+
# Format: [center_x, center_y, radius]
|
|
24
|
+
BUTTON_POS = {
|
|
25
|
+
a: [352, 140, 18],
|
|
26
|
+
b: [390, 107, 18],
|
|
27
|
+
x: [315, 107, 18],
|
|
28
|
+
y: [353, 71, 18],
|
|
29
|
+
dpad_up: [127, 74, 12],
|
|
30
|
+
dpad_down: [127, 143, 12],
|
|
31
|
+
dpad_left: [ 93, 111, 12],
|
|
32
|
+
dpad_right: [160, 110, 12],
|
|
33
|
+
left_shoulder: [ 97, 18, 20],
|
|
34
|
+
right_shoulder: [383, 21, 20],
|
|
35
|
+
back: [215, 93, 10],
|
|
36
|
+
start: [262, 95, 10],
|
|
37
|
+
left_stick: [165, 200, 16],
|
|
38
|
+
right_stick: [313, 200, 16],
|
|
39
|
+
guide: [242, 135, 12],
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Center of each analog stick for the position dot
|
|
43
|
+
STICK_CENTER = {
|
|
44
|
+
left: [165, 200],
|
|
45
|
+
right: [313, 200],
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Keyboard → virtual gamepad button mapping
|
|
49
|
+
KEY_MAP_BUTTONS = {
|
|
50
|
+
'space' => :a,
|
|
51
|
+
'shift_l' => :b,
|
|
52
|
+
'shift_r' => :b,
|
|
53
|
+
'z' => :x,
|
|
54
|
+
'c' => :y,
|
|
55
|
+
'return' => :start,
|
|
56
|
+
'tab' => :back,
|
|
57
|
+
'q' => :left_shoulder,
|
|
58
|
+
'e' => :right_shoulder,
|
|
59
|
+
'up' => :dpad_up,
|
|
60
|
+
'down' => :dpad_down,
|
|
61
|
+
'left' => :dpad_left,
|
|
62
|
+
'right' => :dpad_right,
|
|
63
|
+
'f' => :left_stick,
|
|
64
|
+
'g' => :right_stick,
|
|
65
|
+
'escape' => :guide,
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# Keyboard → virtual analog stick mapping
|
|
69
|
+
# WASD → left stick, IJKL → right stick
|
|
70
|
+
KEY_MAP_AXES = {
|
|
71
|
+
'w' => [:left_y, -GP::AXIS_MAX],
|
|
72
|
+
's' => [:left_y, GP::AXIS_MAX],
|
|
73
|
+
'a' => [:left_x, GP::AXIS_MIN],
|
|
74
|
+
'd' => [:left_x, GP::AXIS_MAX],
|
|
75
|
+
'i' => [:right_y, -GP::AXIS_MAX],
|
|
76
|
+
'k' => [:right_y, GP::AXIS_MAX],
|
|
77
|
+
'j' => [:right_x, GP::AXIS_MIN],
|
|
78
|
+
'l' => [:right_x, GP::AXIS_MAX],
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def initialize(calibrate: false)
|
|
82
|
+
@calibrate = calibrate
|
|
83
|
+
@app = Teek::App.new(title: calibrate ? 'Gamepad Viewer — CALIBRATE' : 'Gamepad Viewer')
|
|
84
|
+
@gamepads = [] # [[display_name, :virtual | device_index], ...]
|
|
85
|
+
@current_gp = nil # opened Gamepad instance
|
|
86
|
+
@current_mode = nil # :virtual or device index
|
|
87
|
+
@keys_held = {} # keysym → true (for virtual stick axes)
|
|
88
|
+
@var_counter = 0 # unique Tcl variable names
|
|
89
|
+
@prev_buttons = {} # btn → pressed (skip UI update when unchanged)
|
|
90
|
+
@prev_axes = {} # ax → value
|
|
91
|
+
|
|
92
|
+
GP.init_subsystem
|
|
93
|
+
|
|
94
|
+
build_ui
|
|
95
|
+
unless @calibrate
|
|
96
|
+
refresh_gamepad_list
|
|
97
|
+
start_poll_loop
|
|
98
|
+
end
|
|
99
|
+
@app.show
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run
|
|
103
|
+
@app.mainloop
|
|
104
|
+
ensure
|
|
105
|
+
@poll_timer&.cancel
|
|
106
|
+
@current_gp&.close unless @current_gp&.closed?
|
|
107
|
+
GP.detach_virtual
|
|
108
|
+
GP.shutdown_subsystem
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# ── UI construction ────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def build_ui
|
|
116
|
+
# Top bar: gamepad selector
|
|
117
|
+
top = @app.create_widget('ttk::frame')
|
|
118
|
+
top.pack(fill: :x, padx: 8, pady: 4)
|
|
119
|
+
|
|
120
|
+
@app.create_widget('ttk::label', parent: top, text: 'Gamepad:')
|
|
121
|
+
.pack(side: :left)
|
|
122
|
+
|
|
123
|
+
# Tcl variable must exist before combobox references it via -textvariable
|
|
124
|
+
@combo_var = next_var
|
|
125
|
+
set_var(@combo_var, '')
|
|
126
|
+
@combo = @app.create_widget('ttk::combobox', parent: top,
|
|
127
|
+
textvariable: @combo_var,
|
|
128
|
+
state: :readonly, width: 35)
|
|
129
|
+
@combo.pack(side: :left, padx: 4)
|
|
130
|
+
@combo.bind('<<ComboboxSelected>>') { on_gamepad_selected }
|
|
131
|
+
|
|
132
|
+
@app.create_widget('ttk::button', parent: top, text: 'Refresh',
|
|
133
|
+
command: proc { refresh_gamepad_list })
|
|
134
|
+
.pack(side: :left, padx: 4)
|
|
135
|
+
|
|
136
|
+
# Main area: canvas (left) + info panel (right)
|
|
137
|
+
main = @app.create_widget('ttk::frame')
|
|
138
|
+
main.pack(fill: :both, expand: true, padx: 8, pady: 4)
|
|
139
|
+
|
|
140
|
+
build_canvas(main)
|
|
141
|
+
build_info_panel(main)
|
|
142
|
+
|
|
143
|
+
# Status bar (left = status text, right = mouse coords on canvas)
|
|
144
|
+
status_frame = @app.create_widget('ttk::frame')
|
|
145
|
+
status_frame.pack(fill: :x, side: :bottom, padx: 8, pady: 4)
|
|
146
|
+
|
|
147
|
+
@status_var = next_var
|
|
148
|
+
set_var(@status_var, 'Select a gamepad to begin')
|
|
149
|
+
@app.create_widget('ttk::label', parent: status_frame,
|
|
150
|
+
textvariable: @status_var, anchor: :w)
|
|
151
|
+
.pack(fill: :x, side: :left, expand: true)
|
|
152
|
+
|
|
153
|
+
@mouse_var = next_var
|
|
154
|
+
set_var(@mouse_var, 'x: — y: —')
|
|
155
|
+
@app.create_widget('ttk::label', parent: status_frame,
|
|
156
|
+
textvariable: @mouse_var, anchor: :e,
|
|
157
|
+
width: 16, font: 'TkFixedFont')
|
|
158
|
+
.pack(side: :right, padx: [4, 0])
|
|
159
|
+
|
|
160
|
+
# Keyboard bindings for virtual mode (skip in calibrate — it uses its own)
|
|
161
|
+
unless @calibrate
|
|
162
|
+
@app.bind('all', 'KeyPress', :keysym) { |k| on_key_press(k) }
|
|
163
|
+
@app.bind('all', 'KeyRelease', :keysym) { |k| on_key_release(k) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_canvas(parent)
|
|
168
|
+
@canvas = @app.create_widget('canvas', parent: parent,
|
|
169
|
+
width: 479, height: 310,
|
|
170
|
+
background: '#2b2b2b', highlightthickness: 0)
|
|
171
|
+
@canvas.pack(side: :left, padx: [0, 8])
|
|
172
|
+
|
|
173
|
+
# Load controller image
|
|
174
|
+
img_path = File.join(__dir__, 'assets', 'controller.png')
|
|
175
|
+
@controller_img = "gp_controller"
|
|
176
|
+
@app.command(:image, :create, :photo, @controller_img, file: img_path)
|
|
177
|
+
@canvas.command(:create, :image, 0, 0, anchor: :nw, image: @controller_img,
|
|
178
|
+
tag: :controller)
|
|
179
|
+
|
|
180
|
+
# Mouse coordinate tracking for tweaking button positions
|
|
181
|
+
@canvas.bind('Motion', :x, :y) do |x, y|
|
|
182
|
+
set_var(@mouse_var, "x:#{x} y:#{y}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Create highlight ovals for each button
|
|
186
|
+
@highlight_items = {}
|
|
187
|
+
@calibrate_pos = {} # live positions for calibration mode
|
|
188
|
+
BUTTON_POS.each do |btn, (cx, cy, r)|
|
|
189
|
+
color = button_highlight_color(btn)
|
|
190
|
+
tag = "hl_#{btn}"
|
|
191
|
+
@canvas.command(:create, :oval, cx - r, cy - r, cx + r, cy + r,
|
|
192
|
+
fill: color, outline: '', tag: tag,
|
|
193
|
+
state: @calibrate ? :normal : :hidden)
|
|
194
|
+
@highlight_items[btn] = tag
|
|
195
|
+
@calibrate_pos[btn] = [cx, cy, r]
|
|
196
|
+
|
|
197
|
+
if @calibrate
|
|
198
|
+
# Label each highlight so you know which is which
|
|
199
|
+
@canvas.command(:create, :text, cx, cy - r - 8,
|
|
200
|
+
text: btn.to_s, fill: '#ffffff',
|
|
201
|
+
font: 'TkSmallCaptionFont', tag: "lbl_#{btn}")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if @calibrate
|
|
206
|
+
setup_calibration_drag
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Stick position dots
|
|
210
|
+
@stick_dots = {}
|
|
211
|
+
STICK_CENTER.each do |side, (cx, cy)|
|
|
212
|
+
tag = "stick_#{side}"
|
|
213
|
+
r = 6
|
|
214
|
+
@canvas.command(:create, :oval, cx - r, cy - r, cx + r, cy + r,
|
|
215
|
+
fill: '#00ff88', outline: '#00cc66', width: 2, tag: tag)
|
|
216
|
+
@stick_dots[side] = tag
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_info_panel(parent)
|
|
221
|
+
info = @app.create_widget('ttk::labelframe', parent: parent, text: 'State')
|
|
222
|
+
info.pack(side: :right, fill: :both, expand: true)
|
|
223
|
+
|
|
224
|
+
# Button indicators
|
|
225
|
+
btn_frame = @app.create_widget('ttk::labelframe', parent: info, text: 'Buttons')
|
|
226
|
+
btn_frame.pack(fill: :x, padx: 4, pady: 2)
|
|
227
|
+
|
|
228
|
+
@btn_labels = {}
|
|
229
|
+
GP.buttons.each_slice(4) do |row|
|
|
230
|
+
rf = @app.create_widget('ttk::frame', parent: btn_frame)
|
|
231
|
+
rf.pack(fill: :x)
|
|
232
|
+
row.each do |btn|
|
|
233
|
+
var = next_var
|
|
234
|
+
set_var(var, btn.to_s)
|
|
235
|
+
# Use plain label (not ttk) for background color support
|
|
236
|
+
lbl = @app.create_widget('label', parent: rf,
|
|
237
|
+
textvariable: var,
|
|
238
|
+
width: 12, relief: :groove,
|
|
239
|
+
anchor: :center, padx: 2, pady: 1)
|
|
240
|
+
lbl.pack(side: :left, padx: 1, pady: 1)
|
|
241
|
+
# Store default colors so we can restore them when button is released
|
|
242
|
+
default_bg = lbl.command(:cget, '-background')
|
|
243
|
+
default_fg = lbl.command(:cget, '-foreground')
|
|
244
|
+
@btn_labels[btn] = { var: var, label: lbl,
|
|
245
|
+
default_bg: default_bg, default_fg: default_fg }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Axis readouts
|
|
250
|
+
axis_frame = @app.create_widget('ttk::labelframe', parent: info, text: 'Axes')
|
|
251
|
+
axis_frame.pack(fill: :x, padx: 4, pady: 2)
|
|
252
|
+
|
|
253
|
+
@axis_labels = {}
|
|
254
|
+
GP.axes.each do |ax|
|
|
255
|
+
rf = @app.create_widget('ttk::frame', parent: axis_frame)
|
|
256
|
+
rf.pack(fill: :x)
|
|
257
|
+
@app.create_widget('ttk::label', parent: rf, text: "#{ax}:",
|
|
258
|
+
width: 14, anchor: :e)
|
|
259
|
+
.pack(side: :left)
|
|
260
|
+
var = next_var
|
|
261
|
+
set_var(var, '0')
|
|
262
|
+
@app.create_widget('ttk::label', parent: rf, textvariable: var,
|
|
263
|
+
width: 8, anchor: :w, font: 'TkFixedFont')
|
|
264
|
+
.pack(side: :left, padx: 4)
|
|
265
|
+
@axis_labels[ax] = var
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Key help for virtual mode
|
|
269
|
+
help_frame = @app.create_widget('ttk::labelframe', parent: info,
|
|
270
|
+
text: 'Virtual Keys')
|
|
271
|
+
help_frame.pack(fill: :x, padx: 4, pady: 2)
|
|
272
|
+
|
|
273
|
+
help_text = "WASD: L-stick IJKL: R-stick\n" \
|
|
274
|
+
"Arrows: D-pad Space: A\n" \
|
|
275
|
+
"Shift: B Z: X C: Y\n" \
|
|
276
|
+
"Enter: Start Tab: Back\n" \
|
|
277
|
+
"Q/E: Shoulders F/G: Sticks"
|
|
278
|
+
@app.create_widget('ttk::label', parent: help_frame,
|
|
279
|
+
text: help_text, justify: :left)
|
|
280
|
+
.pack(padx: 4, pady: 2)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# ── Gamepad selection ──────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def refresh_gamepad_list
|
|
286
|
+
@gamepads = [['Virtual (Keyboard)', :virtual]]
|
|
287
|
+
|
|
288
|
+
# Probe device indices for connected gamepads
|
|
289
|
+
8.times do |i|
|
|
290
|
+
gp = begin; GP.open(i); rescue; nil; end
|
|
291
|
+
next unless gp
|
|
292
|
+
@gamepads << [gp.name, i]
|
|
293
|
+
gp.close
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
values = @gamepads.map(&:first)
|
|
297
|
+
@combo.command(:configure, values: values)
|
|
298
|
+
|
|
299
|
+
# Select first if nothing selected
|
|
300
|
+
current = get_var(@combo_var)
|
|
301
|
+
if current.empty? || !values.include?(current)
|
|
302
|
+
set_var(@combo_var, values.first)
|
|
303
|
+
on_gamepad_selected
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def on_gamepad_selected
|
|
308
|
+
name = get_var(@combo_var)
|
|
309
|
+
entry = @gamepads.find { |n, _| n == name }
|
|
310
|
+
return unless entry
|
|
311
|
+
|
|
312
|
+
_, mode = entry
|
|
313
|
+
switch_gamepad(mode)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def switch_gamepad(mode)
|
|
317
|
+
# Close current
|
|
318
|
+
@current_gp&.close unless @current_gp&.closed?
|
|
319
|
+
@current_gp = nil
|
|
320
|
+
GP.detach_virtual
|
|
321
|
+
|
|
322
|
+
@prev_buttons.clear
|
|
323
|
+
@prev_axes.clear
|
|
324
|
+
|
|
325
|
+
if mode == :virtual
|
|
326
|
+
idx = GP.attach_virtual
|
|
327
|
+
@current_gp = GP.open(idx)
|
|
328
|
+
@current_mode = :virtual
|
|
329
|
+
set_var(@status_var, "Virtual gamepad active \u2014 use keyboard")
|
|
330
|
+
else
|
|
331
|
+
begin
|
|
332
|
+
@current_gp = GP.open(mode)
|
|
333
|
+
@current_mode = mode
|
|
334
|
+
set_var(@status_var, "Connected: #{@current_gp.name}")
|
|
335
|
+
rescue => e
|
|
336
|
+
set_var(@status_var, "Error: #{e.message}")
|
|
337
|
+
@current_mode = nil
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── Keyboard → Virtual gamepad ─────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def on_key_press(keysym)
|
|
345
|
+
return unless @current_mode == :virtual && @current_gp && !@current_gp.closed?
|
|
346
|
+
|
|
347
|
+
key = keysym.downcase
|
|
348
|
+
|
|
349
|
+
if (btn = KEY_MAP_BUTTONS[key])
|
|
350
|
+
@current_gp.set_virtual_button(btn, true)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if KEY_MAP_AXES[key]
|
|
354
|
+
@keys_held[key] = true
|
|
355
|
+
update_virtual_axes
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def on_key_release(keysym)
|
|
360
|
+
return unless @current_mode == :virtual && @current_gp && !@current_gp.closed?
|
|
361
|
+
|
|
362
|
+
key = keysym.downcase
|
|
363
|
+
|
|
364
|
+
if (btn = KEY_MAP_BUTTONS[key])
|
|
365
|
+
@current_gp.set_virtual_button(btn, false)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if KEY_MAP_AXES[key]
|
|
369
|
+
@keys_held.delete(key)
|
|
370
|
+
update_virtual_axes
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def update_virtual_axes
|
|
375
|
+
return unless @current_gp && !@current_gp.closed?
|
|
376
|
+
|
|
377
|
+
# Compute net axis values from held keys
|
|
378
|
+
axes = Hash.new(0)
|
|
379
|
+
@keys_held.each_key do |key|
|
|
380
|
+
ax, val = KEY_MAP_AXES[key]
|
|
381
|
+
axes[ax] = val
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Set each axis — reset to 0 if no key held for that axis
|
|
385
|
+
%i[left_x left_y right_x right_y].each do |ax|
|
|
386
|
+
@current_gp.set_virtual_axis(ax, axes[ax])
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# ── Poll loop ──────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
def start_poll_loop
|
|
393
|
+
@poll_timer = @app.every(50, on_error: ->(e) {
|
|
394
|
+
set_var(@status_var, "Poll error: #{e.message}")
|
|
395
|
+
}) {
|
|
396
|
+
GP.poll_events
|
|
397
|
+
if @current_gp && !@current_gp.closed?
|
|
398
|
+
update_buttons
|
|
399
|
+
update_axes
|
|
400
|
+
end
|
|
401
|
+
}
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def update_buttons
|
|
405
|
+
GP.buttons.each do |btn|
|
|
406
|
+
pressed = @current_gp.button?(btn)
|
|
407
|
+
next if @prev_buttons[btn] == pressed
|
|
408
|
+
|
|
409
|
+
@prev_buttons[btn] = pressed
|
|
410
|
+
info = @btn_labels[btn]
|
|
411
|
+
next unless info
|
|
412
|
+
|
|
413
|
+
if pressed
|
|
414
|
+
set_var(info[:var], "[ #{btn} ]")
|
|
415
|
+
info[:label].command(:configure,
|
|
416
|
+
background: button_highlight_color(btn),
|
|
417
|
+
foreground: '#ffffff')
|
|
418
|
+
else
|
|
419
|
+
set_var(info[:var], btn.to_s)
|
|
420
|
+
info[:label].command(:configure,
|
|
421
|
+
background: info[:default_bg],
|
|
422
|
+
foreground: info[:default_fg])
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
tag = @highlight_items[btn]
|
|
426
|
+
@canvas.command(:itemconfigure, tag, state: pressed ? :normal : :hidden) if tag
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def update_axes
|
|
431
|
+
GP.axes.each do |ax|
|
|
432
|
+
val = @current_gp.axis(ax)
|
|
433
|
+
next if @prev_axes[ax] == val
|
|
434
|
+
|
|
435
|
+
@prev_axes[ax] = val
|
|
436
|
+
set_var(@axis_labels[ax], val.to_s.rjust(6))
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Only move stick dots when their axes actually changed
|
|
440
|
+
lx = @current_gp.axis(:left_x); ly = @current_gp.axis(:left_y)
|
|
441
|
+
rx = @current_gp.axis(:right_x); ry = @current_gp.axis(:right_y)
|
|
442
|
+
move_stick_dot(:left, lx, ly) if @prev_axes[:left_x] != lx || @prev_axes[:left_y] != ly
|
|
443
|
+
move_stick_dot(:right, rx, ry) if @prev_axes[:right_x] != rx || @prev_axes[:right_y] != ry
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def move_stick_dot(side, raw_x, raw_y)
|
|
447
|
+
cx, cy = STICK_CENTER[side]
|
|
448
|
+
max_offset = 15.0 # pixels of travel on screen
|
|
449
|
+
nx = raw_x.to_f / GP::AXIS_MAX
|
|
450
|
+
ny = raw_y.to_f / GP::AXIS_MAX
|
|
451
|
+
px = cx + (nx * max_offset)
|
|
452
|
+
py = cy + (ny * max_offset)
|
|
453
|
+
r = 6
|
|
454
|
+
@canvas.command(:coords, @stick_dots[side],
|
|
455
|
+
(px - r).round, (py - r).round,
|
|
456
|
+
(px + r).round, (py + r).round)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
def button_highlight_color(btn)
|
|
462
|
+
case btn
|
|
463
|
+
when :a then '#22cc44'
|
|
464
|
+
when :b then '#dd3333'
|
|
465
|
+
when :x then '#3388ee'
|
|
466
|
+
when :y then '#ee8822'
|
|
467
|
+
when :dpad_up, :dpad_down, :dpad_left, :dpad_right then '#44aaff'
|
|
468
|
+
when :left_shoulder, :right_shoulder then '#aa66dd'
|
|
469
|
+
when :left_stick, :right_stick then '#00ff88'
|
|
470
|
+
when :back, :start, :guide then '#ffaa22'
|
|
471
|
+
else '#cccccc'
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# ── Calibration mode ────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
def setup_calibration_drag
|
|
478
|
+
@drag_btn = nil
|
|
479
|
+
@drag_offset = [0, 0]
|
|
480
|
+
|
|
481
|
+
@canvas.bind('ButtonPress-1', :x, :y) { |x, y| calibrate_press(x.to_i, y.to_i) }
|
|
482
|
+
@canvas.bind('B1-Motion', :x, :y) { |x, y| calibrate_drag(x.to_i, y.to_i) }
|
|
483
|
+
@canvas.bind('ButtonRelease-1') { @drag_btn = nil }
|
|
484
|
+
|
|
485
|
+
# Print final coordinates to console
|
|
486
|
+
@app.bind('all', 'KeyPress', :keysym) do |k|
|
|
487
|
+
print_calibrated_positions if k.downcase == 'return'
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
set_var(@status_var, 'Drag circles into position. Press Enter to print coordinates.')
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def calibrate_press(mx, my)
|
|
494
|
+
# Find the closest button highlight to the click
|
|
495
|
+
@drag_btn = nil
|
|
496
|
+
best_dist = 999
|
|
497
|
+
@calibrate_pos.each do |btn, (cx, cy, _r)|
|
|
498
|
+
d = Math.sqrt((mx - cx)**2 + (my - cy)**2)
|
|
499
|
+
if d < best_dist
|
|
500
|
+
best_dist = d
|
|
501
|
+
@drag_btn = btn
|
|
502
|
+
@drag_offset = [mx - cx, my - cy]
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
@drag_btn = nil if best_dist > 40
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def calibrate_drag(mx, my)
|
|
509
|
+
return unless @drag_btn
|
|
510
|
+
|
|
511
|
+
cx = mx - @drag_offset[0]
|
|
512
|
+
cy = my - @drag_offset[1]
|
|
513
|
+
_, _, r = @calibrate_pos[@drag_btn]
|
|
514
|
+
@calibrate_pos[@drag_btn] = [cx, cy, r]
|
|
515
|
+
|
|
516
|
+
tag = @highlight_items[@drag_btn]
|
|
517
|
+
@canvas.command(:coords, tag, cx - r, cy - r, cx + r, cy + r)
|
|
518
|
+
@canvas.command(:coords, "lbl_#{@drag_btn}", cx, cy - r - 8)
|
|
519
|
+
set_var(@mouse_var, "#{@drag_btn}: #{cx},#{cy}")
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def print_calibrated_positions
|
|
523
|
+
puts "\n# Updated BUTTON_POS — paste into gamepad_viewer.rb"
|
|
524
|
+
puts "BUTTON_POS = {"
|
|
525
|
+
@calibrate_pos.each do |btn, (cx, cy, r)|
|
|
526
|
+
puts " %-17s [%3d, %3d, %2d]," % ["#{btn}:", cx, cy, r]
|
|
527
|
+
end
|
|
528
|
+
puts "}.freeze"
|
|
529
|
+
|
|
530
|
+
# Also print stick centers from the stick buttons
|
|
531
|
+
ls = @calibrate_pos[:left_stick]
|
|
532
|
+
rs = @calibrate_pos[:right_stick]
|
|
533
|
+
puts "\nSTICK_CENTER = {"
|
|
534
|
+
puts " left: [#{ls[0]}, #{ls[1]}],"
|
|
535
|
+
puts " right: [#{rs[0]}, #{rs[1]}],"
|
|
536
|
+
puts "}.freeze"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Tcl variable helpers
|
|
540
|
+
def next_var
|
|
541
|
+
@var_counter += 1
|
|
542
|
+
"::gpv_#{@var_counter}"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def set_var(name, value)
|
|
546
|
+
@app.command(:set, name, value)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def get_var(name)
|
|
550
|
+
@app.command(:set, name)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
GamepadViewer.new(calibrate: ARGV.include?('--calibrate')).run
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# teek-record: title=Yet Another Minesweeper, audio=1
|
|
2
3
|
#
|
|
3
4
|
# Minesweeper clone built with Teek.
|
|
4
5
|
#
|
|
@@ -13,8 +14,14 @@
|
|
|
13
14
|
#
|
|
14
15
|
# Tile artwork: "Minesweeper Tile Set" by eugeneloza (CC0)
|
|
15
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
|
|
16
22
|
|
|
17
23
|
require_relative '../../lib/teek'
|
|
24
|
+
require_relative '../../teek-sdl2/lib/teek/sdl2'
|
|
18
25
|
|
|
19
26
|
class Minesweeper
|
|
20
27
|
# The source PNGs are 216x216. Tk can shrink them with "copy -subsample N N"
|
|
@@ -28,15 +35,22 @@ class Minesweeper
|
|
|
28
35
|
expert: { cols: 30, rows: 16, mines: 99 }
|
|
29
36
|
}.freeze
|
|
30
37
|
|
|
38
|
+
attr_reader :app
|
|
39
|
+
|
|
31
40
|
def initialize(app, level: :beginner)
|
|
32
41
|
@app = app
|
|
33
42
|
@level = level
|
|
34
43
|
apply_level
|
|
35
44
|
load_images
|
|
45
|
+
load_sounds
|
|
36
46
|
build_ui
|
|
37
47
|
new_game
|
|
38
48
|
end
|
|
39
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
|
+
|
|
40
54
|
private
|
|
41
55
|
|
|
42
56
|
# -- Setup ---------------------------------------------------------------
|
|
@@ -83,10 +97,21 @@ class Minesweeper
|
|
|
83
97
|
end
|
|
84
98
|
end
|
|
85
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
|
+
|
|
86
111
|
def build_ui
|
|
87
112
|
# "wm" commands control the window manager -- title, resizability, etc.
|
|
88
113
|
# "." is the root Tk window (every widget path starts from here).
|
|
89
|
-
@app.command(:wm, :title, '.', 'Minesweeper')
|
|
114
|
+
@app.command(:wm, :title, '.', 'Yet Another Minesweeper')
|
|
90
115
|
@app.command(:wm, :resizable, '.', 0, 0)
|
|
91
116
|
|
|
92
117
|
build_menu
|
|
@@ -165,6 +190,13 @@ class Minesweeper
|
|
|
165
190
|
font: 'TkFixedFont 14 bold', fg: :red, bg: :black,
|
|
166
191
|
relief: :sunken, anchor: :center)
|
|
167
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)
|
|
168
200
|
end
|
|
169
201
|
|
|
170
202
|
# The game grid is a single Tk canvas filled with image items.
|
|
@@ -184,15 +216,27 @@ class Minesweeper
|
|
|
184
216
|
@app.command(:canvas, @canvas, width: cw, height: ch, highlightthickness: 0)
|
|
185
217
|
@app.command(:pack, @canvas)
|
|
186
218
|
|
|
187
|
-
# Left-click:
|
|
188
|
-
|
|
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 { |*|
|
|
189
224
|
row, col = canvas_cell
|
|
190
|
-
|
|
225
|
+
on_left_press(row, col) if row
|
|
191
226
|
})
|
|
192
|
-
@app.tcl_eval("bind #{@canvas} <
|
|
227
|
+
@app.tcl_eval("bind #{@canvas} <ButtonPress-1> " \
|
|
193
228
|
"{set ::_ms_x [#{@canvas} canvasx %x]; " \
|
|
194
229
|
"set ::_ms_y [#{@canvas} canvasy %y]; " \
|
|
195
|
-
"ruby_callback #{
|
|
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}}")
|
|
196
240
|
|
|
197
241
|
# Right-click: toggle flag. Binding all three events covers:
|
|
198
242
|
# Button-2 -- right-click on macOS
|
|
@@ -240,6 +284,7 @@ class Minesweeper
|
|
|
240
284
|
@app.command(@face, :configure, text: ':)')
|
|
241
285
|
|
|
242
286
|
draw_board
|
|
287
|
+
@music.play if @music_on && !@music.playing?
|
|
243
288
|
end
|
|
244
289
|
|
|
245
290
|
# Changing difficulty resizes the canvas and resets. The window auto-shrinks
|
|
@@ -285,7 +330,8 @@ class Minesweeper
|
|
|
285
330
|
|
|
286
331
|
candidates = []
|
|
287
332
|
@rows.times { |r| @cols.times { |c| candidates << [r, c] unless safe[[r, c]] } }
|
|
288
|
-
|
|
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 }
|
|
289
335
|
|
|
290
336
|
# Precompute how many mines neighbor each cell
|
|
291
337
|
@rows.times do |r|
|
|
@@ -298,7 +344,29 @@ class Minesweeper
|
|
|
298
344
|
|
|
299
345
|
# -- Click handlers ------------------------------------------------------
|
|
300
346
|
|
|
301
|
-
def
|
|
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
|
+
|
|
302
370
|
return if @game_over || @flagged[r][c] || @revealed[r][c]
|
|
303
371
|
|
|
304
372
|
if @first_click
|
|
@@ -308,8 +376,10 @@ class Minesweeper
|
|
|
308
376
|
end
|
|
309
377
|
|
|
310
378
|
if @mine[r][c]
|
|
379
|
+
@snd_explosion.play
|
|
311
380
|
game_over_lose(r, c)
|
|
312
381
|
else
|
|
382
|
+
@cascading = false
|
|
313
383
|
reveal(r, c)
|
|
314
384
|
check_win
|
|
315
385
|
end
|
|
@@ -318,6 +388,7 @@ class Minesweeper
|
|
|
318
388
|
def on_right_click(r, c)
|
|
319
389
|
return if @game_over || @revealed[r][c]
|
|
320
390
|
|
|
391
|
+
@snd_flag.play
|
|
321
392
|
if @flagged[r][c]
|
|
322
393
|
@flagged[r][c] = false
|
|
323
394
|
@flags_placed -= 1
|
|
@@ -344,8 +415,13 @@ class Minesweeper
|
|
|
344
415
|
|
|
345
416
|
if count == 0
|
|
346
417
|
set_cell_image(r, c, :empty)
|
|
418
|
+
unless @cascading
|
|
419
|
+
@cascading = true
|
|
420
|
+
@snd_sweep.play
|
|
421
|
+
end
|
|
347
422
|
neighbors(r, c).each { |nr, nc| reveal(nr, nc) }
|
|
348
423
|
else
|
|
424
|
+
@snd_click.play unless @cascading
|
|
349
425
|
set_cell_image(r, c, count)
|
|
350
426
|
end
|
|
351
427
|
end
|
|
@@ -375,6 +451,7 @@ class Minesweeper
|
|
|
375
451
|
@game_over = true
|
|
376
452
|
stop_timer
|
|
377
453
|
@app.command(@face, :configure, text: ':(')
|
|
454
|
+
Teek::SDL2.fade_out_music(1500) if @music_on
|
|
378
455
|
|
|
379
456
|
@rows.times do |r|
|
|
380
457
|
@cols.times do |c|
|
|
@@ -405,6 +482,24 @@ class Minesweeper
|
|
|
405
482
|
@app.after(1000) { tick_timer }
|
|
406
483
|
end
|
|
407
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
|
+
|
|
408
503
|
# -- Helpers -------------------------------------------------------------
|
|
409
504
|
|
|
410
505
|
def in_bounds?(r, c)
|
|
@@ -437,16 +532,56 @@ app = Teek::App.new(track_widgets: false)
|
|
|
437
532
|
# The root window starts withdrawn by default in Teek -- show it.
|
|
438
533
|
app.show
|
|
439
534
|
|
|
440
|
-
Minesweeper.new(app)
|
|
535
|
+
game = Minesweeper.new(app)
|
|
441
536
|
|
|
442
537
|
# Automated demo support (for rake docker:test and recording)
|
|
443
538
|
require_relative '../../lib/teek/demo_support'
|
|
444
539
|
TeekDemo.app = app
|
|
445
540
|
|
|
446
|
-
if TeekDemo.
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
449
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
|
+
}
|
|
450
585
|
end
|
|
451
586
|
|
|
452
587
|
app.mainloop
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: teek
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Cook
|
|
@@ -141,22 +141,12 @@ files:
|
|
|
141
141
|
- lib/teek/widget.rb
|
|
142
142
|
- sample/calculator.rb
|
|
143
143
|
- sample/debug_demo.rb
|
|
144
|
+
- sample/gamepad_viewer/assets/controller.png
|
|
145
|
+
- sample/gamepad_viewer/gamepad_viewer.rb
|
|
144
146
|
- sample/goldberg.rb
|
|
145
147
|
- sample/goldberg_helpers.rb
|
|
146
|
-
- sample/minesweeper/assets/MINESWEEPER_0.png
|
|
147
|
-
- sample/minesweeper/assets/MINESWEEPER_1.png
|
|
148
|
-
- sample/minesweeper/assets/MINESWEEPER_2.png
|
|
149
|
-
- sample/minesweeper/assets/MINESWEEPER_3.png
|
|
150
|
-
- sample/minesweeper/assets/MINESWEEPER_4.png
|
|
151
|
-
- sample/minesweeper/assets/MINESWEEPER_5.png
|
|
152
|
-
- sample/minesweeper/assets/MINESWEEPER_6.png
|
|
153
|
-
- sample/minesweeper/assets/MINESWEEPER_7.png
|
|
154
|
-
- sample/minesweeper/assets/MINESWEEPER_8.png
|
|
155
|
-
- sample/minesweeper/assets/MINESWEEPER_F.png
|
|
156
|
-
- sample/minesweeper/assets/MINESWEEPER_M.png
|
|
157
|
-
- sample/minesweeper/assets/MINESWEEPER_X.png
|
|
158
|
-
- sample/minesweeper/minesweeper.rb
|
|
159
148
|
- sample/optcarrot.rb
|
|
149
|
+
- sample/optcarrot/thwaite.nes
|
|
160
150
|
- sample/optcarrot/vendor/optcarrot.rb
|
|
161
151
|
- sample/optcarrot/vendor/optcarrot/apu.rb
|
|
162
152
|
- sample/optcarrot/vendor/optcarrot/config.rb
|
|
@@ -183,6 +173,24 @@ files:
|
|
|
183
173
|
- sample/paint/sparse_pixel_buffer.rb
|
|
184
174
|
- sample/sdl2_demo.rb
|
|
185
175
|
- sample/threading_demo.rb
|
|
176
|
+
- sample/yam/assets/MINESWEEPER_0.png
|
|
177
|
+
- sample/yam/assets/MINESWEEPER_1.png
|
|
178
|
+
- sample/yam/assets/MINESWEEPER_2.png
|
|
179
|
+
- sample/yam/assets/MINESWEEPER_3.png
|
|
180
|
+
- sample/yam/assets/MINESWEEPER_4.png
|
|
181
|
+
- sample/yam/assets/MINESWEEPER_5.png
|
|
182
|
+
- sample/yam/assets/MINESWEEPER_6.png
|
|
183
|
+
- sample/yam/assets/MINESWEEPER_7.png
|
|
184
|
+
- sample/yam/assets/MINESWEEPER_8.png
|
|
185
|
+
- sample/yam/assets/MINESWEEPER_F.png
|
|
186
|
+
- sample/yam/assets/MINESWEEPER_M.png
|
|
187
|
+
- sample/yam/assets/MINESWEEPER_X.png
|
|
188
|
+
- sample/yam/assets/click.wav
|
|
189
|
+
- sample/yam/assets/explosion.wav
|
|
190
|
+
- sample/yam/assets/flag.wav
|
|
191
|
+
- sample/yam/assets/music.mp3
|
|
192
|
+
- sample/yam/assets/sweep.wav
|
|
193
|
+
- sample/yam/yam.rb
|
|
186
194
|
- teek.gemspec
|
|
187
195
|
homepage: https://github.com/jamescook/teek
|
|
188
196
|
licenses:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|