badline 0.7.0 → 0.8.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's question before something power cycles the machine:
6
+ # a cartridge going in or out, or Quick open starting a file. CANCEL
7
+ # has the focus, so Return alone changes nothing.
8
+ class Confirmation
9
+ TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN" }.freeze
10
+ WARNINGS = { cartridge: "INSERTING IT POWER CYCLES THE MACHINE.", remove: "REMOVING IT POWER CYCLES THE MACHINE.",
11
+ start: "THIS STARTS A NEW MACHINE." }.freeze
12
+ ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START" }.freeze
13
+
14
+ def initialize(painter, buttons)
15
+ @painter = painter
16
+ @buttons = buttons
17
+ @kind = :start
18
+ @path = ""
19
+ @open = false
20
+ end
21
+
22
+ def open? = @open
23
+
24
+ # Asks about `kind`, :cartridge, :remove or :start, for the file at
25
+ # `path`.
26
+ def ask(kind, path)
27
+ @kind = kind
28
+ @path = path
29
+ @open = true
30
+ @buttons.focus = :cancel
31
+ end
32
+
33
+ # Puts the question away, and returns its kind and path.
34
+ def close
35
+ @open = false
36
+ [@kind, @path]
37
+ end
38
+
39
+ def draw(left, top)
40
+ painter = @painter
41
+ painter.text(left, top, TITLES.fetch(@kind), PauseMenu::BRIGHT)
42
+ painter.text(left, top + 20, Painter.fit(File.basename(@path), 40), PauseMenu::TEXT) unless @path.empty?
43
+ painter.text(left, top + 44, WARNINGS.fetch(@kind), PauseMenu::TEXT)
44
+ painter.text(left, top + 56, "ANYTHING NOT SAVED IS LOST.", PauseMenu::TEXT)
45
+ @buttons.row([left, top + 80, MenuPages::ROW_WIDTH], "CANCEL", :cancel)
46
+ @buttons.row([left, top + 80 + Buttons::HEIGHT + 2, MenuPages::ROW_WIDTH], ANSWERS.fetch(@kind), :confirm)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,53 +2,72 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # Routes host input to the machine in one of the input modes, which Tab
6
- # steps through and shift-Tab steps back through:
5
+ # Routes host input to the machine. Two settings decide where it goes:
7
6
  #
8
- # - keyboard: every host key goes to the C64 keyboard.
9
- # - joystick: the arrow cluster and space drive one joystick and WASD
10
- # and left shift the other. The arrows start on joystick 2, and F9
11
- # swaps them.
12
- # - mouse1, mouse2: the host mouse is a 1351 mouse on control port 1 or
13
- # 2, and paddles1, paddles2 a pair of paddles there. Motion turns the
14
- # paddles or moves the mouse, and the host's left and right buttons go
15
- # to whichever lines the device puts them on.
7
+ # - The keys: every host key goes to the C64 keyboard, or, as Tab
8
+ # switches, the arrow cluster and space drive one joystick and WASD
9
+ # and left shift the other. The arrows start on joystick 2, and
10
+ # #swap_ports swaps them.
11
+ # - A pot device the pause menu plugs into a control port: a 1351
12
+ # mouse or a pair of paddles, which the host mouse drives. Motion
13
+ # turns the paddles or moves the mouse, and the host's left and right
14
+ # buttons go to whichever lines the device puts them on.
16
15
  class Controls
17
- MODES = %i[keyboard joystick mouse1 mouse2 paddles1 paddles2].freeze
18
- TAGS = ["", "JOY", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
16
+ POTS = %i[none mouse1 mouse2 paddles1 paddles2].freeze
17
+ TAGS = ["", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
19
18
 
20
19
  # SDL mouse button numbers.
21
20
  MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
22
21
 
23
- attr_reader :mode, :arrows_port
22
+ # The pot device plugged in, one of POTS.
23
+ attr_reader :pot, :arrows_port
24
24
 
25
25
  def initialize(computer)
26
26
  @computer = computer
27
- @mode = :keyboard
27
+ @joystick = false
28
+ @pot = :none
28
29
  @arrows_port = 2
29
30
  @mouse = Input::Mouse1351.new
30
31
  @paddles = Input::Paddles.new
31
32
  end
32
33
 
33
- # Routes the input to another machine, the mode's mouse or paddles
34
- # going into its port.
34
+ # Routes the input to another machine, the pot device going into its
35
+ # port.
35
36
  def computer=(computer)
36
37
  release_all
37
38
  @computer = computer
38
39
  attach_pot_device
39
40
  end
40
41
 
41
- def joystick_mode? = @mode == :joystick
42
+ def joystick_mode? = @joystick
43
+
44
+ # Sends the keys to the joysticks, or with false to the keyboard.
45
+ def joystick_mode=(joystick)
46
+ @joystick = joystick
47
+ release_all
48
+ end
49
+
50
+ def toggle_keys
51
+ self.joystick_mode = !@joystick
52
+ end
53
+
54
+ # Plugs in one of POTS, in place of the one before.
55
+ def plug(pot)
56
+ @pot = pot
57
+ release_all
58
+ attach_pot_device
59
+ end
42
60
 
43
61
  # Whether the host mouse drives a pot device, and so should be held
44
62
  # in relative mode.
45
- def pot_device? = MODES.index(@mode) >= 2
63
+ def pot_device? = @pot != :none
46
64
 
47
- # The title bar's tag for the mode, empty for the keyboard.
65
+ # The title bar's tag for the settings, empty for the keyboard alone.
48
66
  def tag
49
- return "JOY #{@arrows_port}" if joystick_mode?
50
-
51
- TAGS[MODES.index(@mode)]
67
+ tags = []
68
+ tags << "JOY #{@arrows_port}" if @joystick
69
+ tags << TAGS[POTS.index(@pot)] if pot_device?
70
+ tags.join(", ")
52
71
  end
53
72
 
54
73
  def key(scancode, down)
@@ -59,13 +78,6 @@ module Badline
59
78
  end
60
79
  end
61
80
 
62
- # Steps to the next mode, or back with a negative step.
63
- def cycle_mode(step)
64
- @mode = MODES[(MODES.index(@mode) + step) % MODES.size]
65
- release_all
66
- attach_pot_device
67
- end
68
-
69
81
  def swap_ports
70
82
  @arrows_port = 3 - @arrows_port
71
83
  release_all
@@ -99,9 +111,9 @@ module Badline
99
111
  end
100
112
  end
101
113
 
102
- def paddles? = MODES.index(@mode) >= 4
114
+ def paddles? = POTS.index(@pot) >= 3
103
115
 
104
- # A fresh device on the mode's port, and none on the other.
116
+ # A fresh pot device on its port, and none on the other.
105
117
  def attach_pot_device
106
118
  ports = @computer.control_ports
107
119
  ports.device1 = nil
@@ -133,7 +145,7 @@ module Badline
133
145
  end
134
146
  end
135
147
 
136
- def port_one? = %i[mouse1 paddles1].include?(@mode)
148
+ def port_one? = %i[mouse1 paddles1].include?(@pot)
137
149
 
138
150
  def joystick_key(scancode, down)
139
151
  direction = Keys.arrows(scancode)
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's file browser, which lists a folder's subfolders and
6
+ # the files a device takes, folders first, each by name. The keys move
7
+ # through the list, open a folder or pick a file, go up a folder, and
8
+ # jump to the next name starting with a letter or digit. A click picks
9
+ # the row under it, and the wheel scrolls.
10
+ class FileBrowser
11
+ ROW = 12
12
+ ROWS = 12
13
+ COLUMNS = 40
14
+
15
+ RETURN = 40
16
+ ESCAPE = 41
17
+ BACKSPACE = 42
18
+ HOME = 74
19
+ PAGE_UP = 75
20
+ END_KEY = 77
21
+ PAGE_DOWN = 78
22
+ RIGHT = 79
23
+ LEFT = 80
24
+ DOWN = 81
25
+ UP = 82
26
+ DIGITS = "1234567890"
27
+
28
+ attr_reader :directory
29
+
30
+ def initialize(painter, buttons)
31
+ @painter = painter
32
+ @buttons = buttons
33
+ @open = false
34
+ @directory = Dir.pwd
35
+ @extensions = []
36
+ @names = []
37
+ @folders = 0
38
+ @selected = 0
39
+ @top = 0
40
+ @list_top = 0
41
+ end
42
+
43
+ def open? = @open
44
+
45
+ # What a name sorts by: lower case, with its numbers padded, so disk 2
46
+ # comes before disk 10.
47
+ def self.order(name) = name.downcase.gsub(/\d+/) { |digits| digits.rjust(10, "0") }
48
+
49
+ # Lists `directory` for the files with one of `extensions`.
50
+ def open(directory, extensions)
51
+ @open = true
52
+ @extensions = extensions
53
+ enter(directory)
54
+ end
55
+
56
+ def close
57
+ @open = false
58
+ end
59
+
60
+ # Handles a key, and returns the path of a file picked, :cancel when
61
+ # Esc leaves the browser, or nil.
62
+ def key(scancode)
63
+ return :cancel if scancode == ESCAPE
64
+ return pick if [RETURN, RIGHT].include?(scancode)
65
+ return up if [LEFT, BACKSPACE].include?(scancode)
66
+
67
+ select(@selected + step(scancode)) unless step(scancode).zero?
68
+ jump(scancode)
69
+ nil
70
+ end
71
+
72
+ # Picks the row at `top`, as Return would.
73
+ def click(top)
74
+ row = (top - @list_top) / ROW
75
+ return nil if row.negative? || @top + row >= @names.size
76
+
77
+ @selected = @top + row
78
+ pick
79
+ end
80
+
81
+ def scroll(rows)
82
+ @top = (@top + rows).clamp(0, [@names.size - ROWS, 0].max)
83
+ end
84
+
85
+ def draw(left, top, width, title)
86
+ @painter.text(left, top, title, PauseMenu::BRIGHT)
87
+ @painter.text(left, top + ROW + 2, shown_directory, PauseMenu::DIM)
88
+ @list_top = top + (ROW * 2) + 8
89
+ @buttons.area([left, @list_top, width, ROW * ROWS], :browse)
90
+ draw_rows(left, width)
91
+ draw_bar(left + width - 2) if @names.size > ROWS
92
+ @painter.text(left, @list_top + (ROW * ROWS) + 6, "RETURN: OPEN LEFT: BACK ESC: CANCEL",
93
+ PauseMenu::DIM)
94
+ end
95
+
96
+ private
97
+
98
+ # The folder's path, cut at the start to fit.
99
+ def shown_directory
100
+ length = @directory.length
101
+ length > COLUMNS ? "...#{@directory[length - COLUMNS + 3, COLUMNS - 3]}" : @directory
102
+ end
103
+
104
+ def draw_rows(left, width)
105
+ ROWS.times do |row|
106
+ index = @top + row
107
+ break if index >= @names.size
108
+
109
+ name = label(index)
110
+ top = @list_top + (row * ROW)
111
+ if index == @selected
112
+ @painter.box(left - 2, top - 2, width - 4, ROW, PauseMenu::TEXT)
113
+ @painter.text(left, top, name, PauseMenu::PANEL)
114
+ else
115
+ @painter.text(left, top, name, index < @folders ? PauseMenu::BRIGHT : PauseMenu::TEXT)
116
+ end
117
+ end
118
+ end
119
+
120
+ def draw_bar(left)
121
+ height = ROW * ROWS
122
+ size = [(height * ROWS) / @names.size, 4].max
123
+ offset = ((height - size) * @top) / [@names.size - ROWS, 1].max
124
+ @painter.box(left, @list_top - 2, 2, height, PauseMenu::EDGE)
125
+ @painter.box(left, @list_top - 2 + offset, 2, size, PauseMenu::TEXT)
126
+ end
127
+
128
+ def label(index)
129
+ name = @names[index]
130
+ name += "/" if index < @folders
131
+ Painter.fit(name, COLUMNS)
132
+ end
133
+
134
+ def step(scancode)
135
+ case scancode
136
+ when UP then -1
137
+ when DOWN then 1
138
+ when PAGE_UP then -ROWS
139
+ when PAGE_DOWN then ROWS
140
+ when HOME then -@names.size
141
+ when END_KEY then @names.size
142
+ else 0
143
+ end
144
+ end
145
+
146
+ def select(index)
147
+ return if @names.empty?
148
+
149
+ @selected = index.clamp(0, @names.size - 1)
150
+ @top = @selected if @selected < @top
151
+ @top = @selected - ROWS + 1 if @selected >= @top + ROWS
152
+ end
153
+
154
+ # Selects the next name after the one selected that starts with the
155
+ # key's letter or digit.
156
+ def jump(scancode)
157
+ char = if scancode.between?(4, 29) then (97 + scancode - 4).chr
158
+ elsif scancode.between?(30, 39) then DIGITS[scancode - 30]
159
+ else ""
160
+ end
161
+ return if char.empty? || @names.empty?
162
+
163
+ @names.size.times do |offset|
164
+ index = (@selected + 1 + offset) % @names.size
165
+ next unless @names[index].downcase.start_with?(char)
166
+
167
+ select(index)
168
+ break
169
+ end
170
+ end
171
+
172
+ def pick
173
+ return nil if @names.empty?
174
+
175
+ path = File.join(@directory, @names[@selected])
176
+ return path unless @selected < @folders
177
+
178
+ @names[@selected] == ".." ? up : enter(path)
179
+ end
180
+
181
+ def up
182
+ parent = File.dirname(@directory)
183
+ return nil if parent == @directory
184
+
185
+ from = File.basename(@directory)
186
+ enter(parent)
187
+ index = @names.index(from)
188
+ select(index) unless index.nil?
189
+ nil
190
+ end
191
+
192
+ def enter(directory)
193
+ @directory = File.expand_path(directory)
194
+ folders = []
195
+ files = []
196
+ children(@directory).each do |name|
197
+ next if name.start_with?(".")
198
+
199
+ if File.directory?(File.join(@directory, name))
200
+ folders << name
201
+ elsif @extensions.include?(File.extname(name).downcase)
202
+ files << name
203
+ end
204
+ end
205
+ listing(folders, files)
206
+ nil
207
+ end
208
+
209
+ def listing(folders, files)
210
+ folders = folders.sort_by { |name| FileBrowser.order(name) }
211
+ folders.unshift("..") unless File.dirname(@directory) == @directory
212
+ @folders = folders.size
213
+ @names = folders + files.sort_by { |name| FileBrowser.order(name) }
214
+ @selected = 0
215
+ @top = 0
216
+ end
217
+
218
+ def children(directory)
219
+ Dir.children(directory)
220
+ rescue SystemCallError
221
+ []
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The --verbose report every 50 frames: the frame rate, the time each
6
+ # stage of a frame took on average, the slowest frame's work, and how
7
+ # the sound's queue fared.
8
+ class FrameReport
9
+ STAGES = %w[events emulate audio blit present wait].freeze
10
+
11
+ def initialize(sound)
12
+ @sound = sound
13
+ @reported_samples = 0
14
+ clear
15
+ end
16
+
17
+ # Adds a frame's stamps, one as each stage starts and one after the
18
+ # last.
19
+ def add(stamps)
20
+ STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
21
+ took = stamps[-2] - stamps.first
22
+ @slowest = took if took > @slowest
23
+ end
24
+
25
+ # Reports the 50 frames added over the last `seconds`, and starts
26
+ # over.
27
+ def show(seconds)
28
+ stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
29
+ puts "#{(50 / seconds).round(1)} fps, per frame ms: #{stages.join(' ')}, " \
30
+ "slowest work #{(@slowest * 1000).round(2)}"
31
+ show_sound(seconds) if @sound.on?
32
+ clear
33
+ end
34
+
35
+ private
36
+
37
+ def clear
38
+ @spent = Array.new(STAGES.size, 0.0)
39
+ @slowest = 0.0
40
+ end
41
+
42
+ def show_sound(seconds)
43
+ sound = @sound
44
+ rate = (sound.queued - @reported_samples) / seconds
45
+ queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
46
+ puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
47
+ sound.reset_levels
48
+ @reported_samples = sound.queued
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's screens that take over its panel: the file browser
6
+ # INSERT and Quick open bring up, and the question before a cartridge
7
+ # or Quick open power cycles the machine. A disk or tape picked goes
8
+ # straight in.
9
+ class MenuDialogs
10
+ EXTENSIONS = {
11
+ disk: PauseMenu::DISKS, tape: %w[.tap], cartridge: %w[.crt],
12
+ program: %w[.d64 .d71 .d81 .g64 .t64 .tap .crt .prg .p00 .sid]
13
+ }.freeze
14
+ TITLES = { disk: "INSERT A DISK", tape: "INSERT A TAPE", cartridge: "INSERT A CARTRIDGE",
15
+ program: "QUICK OPEN" }.freeze
16
+
17
+ ESCAPE = 41
18
+ RETURN = 40
19
+ SPACE = 44
20
+
21
+ # The machine Quick open started.
22
+ attr_reader :computer
23
+
24
+ def initialize(painter, buttons, media, options)
25
+ media_path = options.media_path.to_s
26
+ @buttons = buttons
27
+ @media = media
28
+ @options = options
29
+ @browser = FileBrowser.new(painter, buttons)
30
+ @confirmation = Confirmation.new(painter, buttons)
31
+ @browsing = nil
32
+ @browse_from = media_path.empty? ? Dir.pwd : File.dirname(File.expand_path(media_path))
33
+ @computer = nil
34
+ end
35
+
36
+ def open? = !@browsing.nil? || @confirmation.open?
37
+
38
+ # Opens the browser on the files for `kind`: :disk, :tape, :cartridge
39
+ # or :program.
40
+ def browse(kind)
41
+ @browsing = kind
42
+ @browser.open(@browse_from, EXTENSIONS.fetch(kind))
43
+ end
44
+
45
+ def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
46
+
47
+ # Puts a dropped disk or tape in its device, or asks before a
48
+ # cartridge or a program starts, and says whether it asks. Other
49
+ # files are left alone.
50
+ def drop(path)
51
+ extension = File.extname(path).downcase
52
+ return @media.insert(:disk, path) && false if EXTENSIONS[:disk].include?(extension)
53
+ return @media.insert(:tape, path) && false if EXTENSIONS[:tape].include?(extension)
54
+ return false unless EXTENSIONS[:program].include?(extension)
55
+
56
+ @confirmation.ask(extension == ".crt" ? :cartridge : :start, path)
57
+ true
58
+ end
59
+
60
+ # Handles a key, and returns :resume or :swap once a question is
61
+ # answered yes, or nil.
62
+ def key(scancode)
63
+ return browsed(@browser.key(scancode)) unless @browsing.nil?
64
+ return answer(:cancel) if scancode == ESCAPE
65
+ return answer(@buttons.focus) if [RETURN, SPACE].include?(scancode)
66
+
67
+ direction = PauseMenu::ARROWS[scancode]
68
+ @buttons.shift(direction == :up ? -1 : 1, 1) if %i[up down].include?(direction)
69
+ nil
70
+ end
71
+
72
+ # Handles a click on the browser's list at `top`, or on a question's
73
+ # answer, `action`.
74
+ def click(action, top) = action == :browse ? browsed(@browser.click(top)) : answer(action)
75
+
76
+ def scroll(rows) = @browser.scroll(rows)
77
+
78
+ def draw(left, top, width)
79
+ return @browser.draw(left, top, width, TITLES.fetch(@browsing)) unless @browsing.nil?
80
+
81
+ @confirmation.draw(left, top)
82
+ end
83
+
84
+ private
85
+
86
+ # Puts the disk or tape picked in, or asks before a cartridge or
87
+ # Quick open power cycles the machine, and leaves the browser, as Esc
88
+ # does.
89
+ def browsed(picked)
90
+ return nil if picked.nil?
91
+
92
+ kind = @browsing
93
+ @browse_from = @browser.directory
94
+ @browsing = nil
95
+ return nil if picked == :cancel
96
+
97
+ if %i[disk tape].include?(kind)
98
+ @media.insert(kind, picked)
99
+ else
100
+ @confirmation.ask(kind == :cartridge ? :cartridge : :start, picked)
101
+ end
102
+ nil
103
+ end
104
+
105
+ # Does what the question asked about if `action` is :confirm, and
106
+ # returns :resume, or :swap for a new machine.
107
+ def answer(action)
108
+ question = @confirmation.close
109
+ return nil unless action == :confirm
110
+
111
+ path = question[1]
112
+ case question[0]
113
+ when :cartridge then @media.attach_cartridge(path) ? :resume : nil
114
+ when :remove then @media.remove_cartridge && :resume
115
+ else
116
+ @computer = @media.start(@options, path)
117
+ @computer.nil? ? nil : :swap
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end