teek 0.1.1 → 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/README.md +46 -0
- data/Rakefile +162 -5
- data/ext/teek/extconf.rb +1 -1
- data/ext/teek/tcltkbridge.c +9 -0
- data/ext/teek/tcltkbridge.h +3 -0
- data/ext/teek/tkeventsource.c +195 -0
- data/ext/teek/tkphoto.c +169 -5
- data/ext/teek/tkwin.c +84 -0
- data/lib/teek/background_ractor4x.rb +32 -4
- data/lib/teek/photo.rb +232 -0
- data/lib/teek/version.rb +1 -1
- data/lib/teek.rb +202 -5
- 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/optcarrot/vendor/optcarrot/apu.rb +856 -0
- data/sample/optcarrot/vendor/optcarrot/config.rb +257 -0
- data/sample/optcarrot/vendor/optcarrot/cpu.rb +1162 -0
- data/sample/optcarrot/vendor/optcarrot/driver.rb +144 -0
- data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +14 -0
- data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +105 -0
- data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +153 -0
- data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +14 -0
- data/sample/optcarrot/vendor/optcarrot/nes.rb +105 -0
- data/sample/optcarrot/vendor/optcarrot/opt.rb +168 -0
- data/sample/optcarrot/vendor/optcarrot/pad.rb +92 -0
- data/sample/optcarrot/vendor/optcarrot/palette.rb +65 -0
- data/sample/optcarrot/vendor/optcarrot/ppu.rb +1468 -0
- data/sample/optcarrot/vendor/optcarrot/rom.rb +143 -0
- data/sample/optcarrot/vendor/optcarrot.rb +14 -0
- data/sample/optcarrot.rb +354 -0
- data/sample/paint/assets/bucket.png +0 -0
- data/sample/paint/assets/cursor.png +0 -0
- data/sample/paint/assets/eraser.png +0 -0
- data/sample/paint/assets/pencil.png +0 -0
- data/sample/paint/assets/spray.png +0 -0
- data/sample/paint/layer.rb +255 -0
- data/sample/paint/layer_manager.rb +179 -0
- data/sample/paint/paint_demo.rb +837 -0
- data/sample/paint/sparse_pixel_buffer.rb +202 -0
- data/sample/sdl2_demo.rb +318 -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 +50 -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
|
@@ -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
|