cliptic 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/.gitignore +9 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +195 -0
- data/Rakefile +4 -0
- data/bin/cliptic +5 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/cliptic.gemspec +25 -0
- data/lib/cliptic.rb +53 -0
- data/lib/cliptic/config.rb +99 -0
- data/lib/cliptic/database.rb +247 -0
- data/lib/cliptic/interface.rb +270 -0
- data/lib/cliptic/lib.rb +64 -0
- data/lib/cliptic/main.rb +839 -0
- data/lib/cliptic/menus.rb +135 -0
- data/lib/cliptic/terminal.rb +72 -0
- data/lib/cliptic/version.rb +5 -0
- data/lib/cliptic/windows.rb +197 -0
- metadata +110 -0
data/lib/cliptic/lib.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
module Cliptic
|
2
|
+
class Pos
|
3
|
+
def self.mk(y,x)
|
4
|
+
{ y:y.to_i, x:x.to_i }
|
5
|
+
end
|
6
|
+
def self.wrap(val:, min:, max:)
|
7
|
+
val > max ? min : (val < min ? max : val)
|
8
|
+
end
|
9
|
+
def self.change_dir(dir)
|
10
|
+
dir == :a ? :d : :a
|
11
|
+
end
|
12
|
+
end
|
13
|
+
class Date < Date
|
14
|
+
def to_long
|
15
|
+
self.strftime('%A %b %-d %Y')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
class Time < Time
|
19
|
+
def self.abs(t)
|
20
|
+
Time.at(t).utc
|
21
|
+
end
|
22
|
+
def to_s
|
23
|
+
self.strftime("%T")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
module Chars
|
27
|
+
HL = "\u2501"
|
28
|
+
LL = "\u2517"
|
29
|
+
LU = "\u250F"
|
30
|
+
RL = "\u251B"
|
31
|
+
RU = "\u2513"
|
32
|
+
TD = "\u2533"
|
33
|
+
TL = "\u252B"
|
34
|
+
TR = "\u2523"
|
35
|
+
TU = "\u253B"
|
36
|
+
VL = "\u2503"
|
37
|
+
XX = "\u254B"
|
38
|
+
Tick = "\u2713"
|
39
|
+
Nums = ["\u2080", "\u2081", "\u2082", "\u2083",
|
40
|
+
"\u2084", "\u2085", "\u2086", "\u2087",
|
41
|
+
"\u2088", "\u2089"]
|
42
|
+
MS = "\u2588"
|
43
|
+
LS = "\u258C"
|
44
|
+
RS = "\u2590"
|
45
|
+
Block = RS+MS+LS
|
46
|
+
def self.small_num(n)
|
47
|
+
n.to_s.chars.map{|n| Nums[n.to_i]}.join
|
48
|
+
end
|
49
|
+
end
|
50
|
+
module Errors
|
51
|
+
class Invalid_Date < StandardError
|
52
|
+
attr_reader :date
|
53
|
+
def initialize(date)
|
54
|
+
@date = date
|
55
|
+
end
|
56
|
+
def message
|
57
|
+
<<~msg
|
58
|
+
Invalid date passed #{date}
|
59
|
+
Earliest date #{Date.today<<9}
|
60
|
+
msg
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/cliptic/main.rb
ADDED
@@ -0,0 +1,839 @@
|
|
1
|
+
module Cliptic
|
2
|
+
module Main
|
3
|
+
module Windows
|
4
|
+
class Top_Bar < Cliptic::Interface::Top_Bar
|
5
|
+
def initialize(date:Date.today)
|
6
|
+
super(date:date)
|
7
|
+
end
|
8
|
+
end
|
9
|
+
class Bottom_Bar < Cliptic::Interface::Bottom_Bar
|
10
|
+
def draw
|
11
|
+
super
|
12
|
+
add_str(x:-1, str:controls)
|
13
|
+
end
|
14
|
+
def mode(mode)
|
15
|
+
setpos.color($colors[mode]) << mode_str(mode)
|
16
|
+
.center(8)
|
17
|
+
color.refresh
|
18
|
+
end
|
19
|
+
def unsaved(bool)
|
20
|
+
add_str(x:9, str:(bool ? "| +" : " "), bold:bool)
|
21
|
+
end
|
22
|
+
private
|
23
|
+
def mode_str(mode)
|
24
|
+
{ N:"NORMAL", I:"INSERT" }[mode]
|
25
|
+
end
|
26
|
+
def controls
|
27
|
+
"^S save | ^R reveal | ^E reset | ^G check"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
class Grid < Cliptic::Windows::Grid
|
31
|
+
attr_reader :indices, :blocks
|
32
|
+
def initialize(puzzle:)
|
33
|
+
super(**puzzle.size, line:1)
|
34
|
+
@indices,@blocks = puzzle.indices,puzzle.blocks
|
35
|
+
link_cells_to_clues(clues:puzzle.clues)
|
36
|
+
end
|
37
|
+
def draw
|
38
|
+
super
|
39
|
+
add_indices
|
40
|
+
add_blocks
|
41
|
+
end
|
42
|
+
def cell(y:, x:)
|
43
|
+
cells[y][x]
|
44
|
+
end
|
45
|
+
private
|
46
|
+
def add_indices
|
47
|
+
indices.each{|i,pos|cell(**pos).set_number(n:i)}
|
48
|
+
end
|
49
|
+
def add_blocks
|
50
|
+
blocks.each{|pos| cell(**pos).set_block}
|
51
|
+
end
|
52
|
+
def make_cells(y:, x:)
|
53
|
+
y.times.map{|iy| x.times.map{|ix|
|
54
|
+
Cell.new(sq:Pos.mk(iy,ix), grid:self) }}
|
55
|
+
end
|
56
|
+
def link_cells_to_clues(clues:)
|
57
|
+
clues.each do |clue|
|
58
|
+
clue.cells=clue.coords.map{|pos|cell(**pos)}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
class Cell < Cliptic::Windows::Cell
|
63
|
+
attr_reader :index, :blocked, :buffer
|
64
|
+
attr_accessor :locked
|
65
|
+
def initialize(sq:, grid:)
|
66
|
+
super(sq:sq, grid:grid)
|
67
|
+
@index, @blocked, @locked = false, false, false
|
68
|
+
@buffer = " "
|
69
|
+
end
|
70
|
+
def set_number(n:index, active:false)
|
71
|
+
@index = n unless index
|
72
|
+
grid.color(active ?
|
73
|
+
$colors[:active_num] :
|
74
|
+
$colors[:num])
|
75
|
+
focus(y:-1, x:-1)
|
76
|
+
grid << Chars.small_num(n)
|
77
|
+
grid.color
|
78
|
+
end
|
79
|
+
def set_block
|
80
|
+
focus(x:-1).grid.color($colors[:block]) << Chars::Block
|
81
|
+
@blocked = true
|
82
|
+
grid.color
|
83
|
+
end
|
84
|
+
def underline
|
85
|
+
grid.attron(Curses::A_UNDERLINE)
|
86
|
+
write
|
87
|
+
grid.attroff(Curses::A_UNDERLINE)
|
88
|
+
end
|
89
|
+
def write(char=@buffer)
|
90
|
+
unless @locked
|
91
|
+
super(char)
|
92
|
+
@buffer = char
|
93
|
+
end; self
|
94
|
+
end
|
95
|
+
def unlock
|
96
|
+
@locked = false unless @blocked; self
|
97
|
+
end
|
98
|
+
def color(cp)
|
99
|
+
grid.color(cp)
|
100
|
+
write
|
101
|
+
grid.color
|
102
|
+
end
|
103
|
+
def clear
|
104
|
+
@locked, @blocked = false, false
|
105
|
+
@buffer = " "
|
106
|
+
end
|
107
|
+
end
|
108
|
+
class Cluebox < Cliptic::Windows::Window
|
109
|
+
def initialize(grid:)
|
110
|
+
super(y:Curses.lines-grid.y-2, line:grid.y+1, col:0)
|
111
|
+
end
|
112
|
+
def show(clue:)
|
113
|
+
draw(cp:$colors[:cluebox])
|
114
|
+
set_meta(clue)
|
115
|
+
set_hint(clue)
|
116
|
+
noutrefresh
|
117
|
+
end
|
118
|
+
private
|
119
|
+
def set_meta(clue)
|
120
|
+
add_str(y:0, x:2, str:clue.meta,
|
121
|
+
cp:clue.done ?
|
122
|
+
$colors[:correct] : $colors[:meta])
|
123
|
+
end
|
124
|
+
def set_hint(clue)
|
125
|
+
wrap_str(str:clue.hint, line:1)
|
126
|
+
end
|
127
|
+
def bottom_border
|
128
|
+
side_border
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
module Fetch
|
133
|
+
class Request
|
134
|
+
URL="https://data.puzzlexperts.com/puzzleapp-v3/data.php"
|
135
|
+
attr_reader :data
|
136
|
+
def initialize(date:Date.today, psid:100000160)
|
137
|
+
@data = {date:date, psid:psid}
|
138
|
+
end
|
139
|
+
def send_request
|
140
|
+
valid_input? ? raw : (raise Cliptic::Errors::Invalid_Date.new(data[:date]))
|
141
|
+
end
|
142
|
+
def valid_input?
|
143
|
+
JSON.parse(raw, symbolize_names:true)
|
144
|
+
.dig(:cells, 0, :meta, :data).length > 0
|
145
|
+
end
|
146
|
+
def raw
|
147
|
+
@raw || Curl.get(URL, data).body
|
148
|
+
end
|
149
|
+
end
|
150
|
+
class Cache < Request
|
151
|
+
Path = "#{Dir.home}/.cache/cliptic"
|
152
|
+
def initialize(date:Date.today)
|
153
|
+
super(date:date)
|
154
|
+
make_cache_dir
|
155
|
+
end
|
156
|
+
def query
|
157
|
+
date_cached? ? read_cache : send_request
|
158
|
+
.tap{|str| write_cache(str)}
|
159
|
+
end
|
160
|
+
def make_cache_dir
|
161
|
+
FileUtils.mkdir_p(Path) unless Dir.exist?(Path)
|
162
|
+
end
|
163
|
+
def date_cached?
|
164
|
+
File.exist?(file_path)
|
165
|
+
end
|
166
|
+
def file_path
|
167
|
+
"#{Path}/#{data[:date]}"
|
168
|
+
end
|
169
|
+
def read_cache
|
170
|
+
File.read(file_path)
|
171
|
+
end
|
172
|
+
def write_cache(str)
|
173
|
+
File.write(file_path, str)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
class Parser
|
177
|
+
attr_reader :raw
|
178
|
+
def initialize(date:Date.today)
|
179
|
+
@raw = Cache.new(date:date).query
|
180
|
+
end
|
181
|
+
def parse
|
182
|
+
[ parse_clues(raw), parse_size(raw) ]
|
183
|
+
end
|
184
|
+
private
|
185
|
+
def parse_size(raw)
|
186
|
+
Pos.mk(*["rows", "columns"]
|
187
|
+
.map{|field| raw.scan(/#{field}=(.*?(?=&))/)[0][0]}
|
188
|
+
)
|
189
|
+
end
|
190
|
+
def parse_clues(raw)
|
191
|
+
JSON.parse(raw, symbolize_names:true)
|
192
|
+
.dig(:cells, 0, :meta, :data)
|
193
|
+
.gsub(/^(.*?&){3}(.*)&id=.*$/, "\\2")
|
194
|
+
.split(/(?:^|&).*?=/).drop(1)
|
195
|
+
.each_slice(5).to_a
|
196
|
+
.map{|data| Puzzle::Clue.new(**struct_clue(data))}
|
197
|
+
end
|
198
|
+
def struct_clue(raw_clue)
|
199
|
+
{
|
200
|
+
ans:raw_clue[0].chars.map(&:upcase),
|
201
|
+
hint:CGI.unescape(raw_clue[1]),
|
202
|
+
dir:raw_clue[2].to_sym,
|
203
|
+
start:Pos.mk(raw_clue[3], raw_clue[4])
|
204
|
+
}
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
module Puzzle
|
209
|
+
class Puzzle
|
210
|
+
include Fetch
|
211
|
+
attr_reader :clues, :size, :indices, :map,
|
212
|
+
:sorted, :blocks
|
213
|
+
def initialize(date:Date.today)
|
214
|
+
@clues, @size = Parser.new(date:date).parse
|
215
|
+
@indices = index_clues
|
216
|
+
@map = map_clues
|
217
|
+
@sorted = order_clues
|
218
|
+
@blocks = find_blocks
|
219
|
+
chain_clues
|
220
|
+
end
|
221
|
+
def first_clue
|
222
|
+
sorted[:a][0].index == 1 ?
|
223
|
+
sorted[:a][0] :
|
224
|
+
sorted[:d][0]
|
225
|
+
end
|
226
|
+
def get_clue(y:, x:, dir:)
|
227
|
+
map[:index][dir][y][x].is_a?(Clue) ?
|
228
|
+
map[:index][dir][y][x] :
|
229
|
+
map[:index][Pos.change_dir(dir)][y][x]
|
230
|
+
end
|
231
|
+
def get_clue_by_index(i:, dir:)
|
232
|
+
sorted[dir]
|
233
|
+
.find{|clue| clue.index == i} ||
|
234
|
+
sorted[Pos.change_dir(dir)]
|
235
|
+
.find{|clue| clue.index == i}
|
236
|
+
end
|
237
|
+
def complete?
|
238
|
+
clues.all?{|c| c.done}
|
239
|
+
end
|
240
|
+
def n_clues_done
|
241
|
+
clues.select{|c| c.done}.count
|
242
|
+
end
|
243
|
+
def n_clues
|
244
|
+
clues.count
|
245
|
+
end
|
246
|
+
def check_all
|
247
|
+
clues.each{|c| c.check}
|
248
|
+
end
|
249
|
+
private
|
250
|
+
def index_clues
|
251
|
+
clues.map{|clue| clue.start.values}.uniq.sort
|
252
|
+
.each_with_index
|
253
|
+
.map{ |pos, n| [n+1, Pos.mk(*pos)] }.to_h
|
254
|
+
.each{|n, pos| clues.find_all{|clue| clue.start==pos}
|
255
|
+
.each{|clue| clue.index = n}}
|
256
|
+
end
|
257
|
+
def empty
|
258
|
+
Array.new(size[:y]){ Array.new(size[:x], ".") }
|
259
|
+
end
|
260
|
+
def map_clues
|
261
|
+
{ index:{a:empty, d:empty}, chars:empty }.tap do |map|
|
262
|
+
clues.each do |clue|
|
263
|
+
clue.coords.zip(clue.ans) do |pos, char|
|
264
|
+
map[:index][clue.dir][pos[:y]][pos[:x]] = clue
|
265
|
+
map[:chars][pos[:y]][pos[:x]] = char
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
def order_clues
|
271
|
+
{a:[], d:[]}.tap do |order|
|
272
|
+
clues.map{|clue| order[clue.dir] << clue}
|
273
|
+
end
|
274
|
+
end
|
275
|
+
def find_blocks
|
276
|
+
[].tap do |a|
|
277
|
+
map[:chars].each_with_index.map do |row, y|
|
278
|
+
row.each_with_index.map do |char, x|
|
279
|
+
a << Pos.mk(y,x) if char == "."
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
def chain_clues
|
285
|
+
sorted.each do |dir, clues|
|
286
|
+
clues.each_with_index do |clue, i|
|
287
|
+
clue.next = sorted[dir][i+1] ||
|
288
|
+
sorted[Pos.change_dir(dir)][0]
|
289
|
+
clue.prev = i == 0 ?
|
290
|
+
sorted[Pos.change_dir(dir)].last :
|
291
|
+
sorted[dir][i-1]
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
class Clue
|
297
|
+
attr_reader :ans, :dir, :start, :hint, :length,
|
298
|
+
:coords
|
299
|
+
attr_accessor :done, :index, :next, :prev, :cells
|
300
|
+
def initialize(ans:, hint:, dir:, start:)
|
301
|
+
@ans, @dir, @start = ans, dir, start
|
302
|
+
@length = ans.length
|
303
|
+
@hint = parse_hint(hint)
|
304
|
+
@coords = map_coords(**start, l:length)
|
305
|
+
@done = false
|
306
|
+
end
|
307
|
+
def meta
|
308
|
+
@meta || "#{index} #{dir==:a ? "across" : "down"}"
|
309
|
+
end
|
310
|
+
def activate
|
311
|
+
cells.first.set_number(active:true)
|
312
|
+
cells.each{|c| c.underline}
|
313
|
+
end
|
314
|
+
def deactivate
|
315
|
+
cells.first.set_number(active:false)
|
316
|
+
cells.each{|c| c.write}
|
317
|
+
check if $config[:auto_mark]
|
318
|
+
end
|
319
|
+
def has_cell?(y:, x:)
|
320
|
+
coords.include?(Pos.mk(y,x))
|
321
|
+
end
|
322
|
+
def check
|
323
|
+
if full?
|
324
|
+
correct? ? mark_correct : mark_incorrect
|
325
|
+
end
|
326
|
+
end
|
327
|
+
def full?
|
328
|
+
get_buffer.reject{|b| b == " "}.count == length
|
329
|
+
end
|
330
|
+
def clear
|
331
|
+
cells.each{|c| c.write(" ")}
|
332
|
+
end
|
333
|
+
def reveal
|
334
|
+
ans.zip(cells){|char, cell| cell.write(char)}
|
335
|
+
mark_correct
|
336
|
+
end
|
337
|
+
private
|
338
|
+
def parse_hint(hint)
|
339
|
+
hint.match?(/^.*\(.*\)$/) ?
|
340
|
+
hint : "#{hint} (#{length})"
|
341
|
+
end
|
342
|
+
def map_coords(y:, x:, l:)
|
343
|
+
case dir
|
344
|
+
when :a then x.upto(x+l-1)
|
345
|
+
.map{|ix| Pos.mk(y,ix)}
|
346
|
+
when :d then y.upto(y+l-1)
|
347
|
+
.map{|iy| Pos.mk(iy,x)}
|
348
|
+
end
|
349
|
+
end
|
350
|
+
def get_buffer
|
351
|
+
cells.map{|c| c.buffer}
|
352
|
+
end
|
353
|
+
def correct?
|
354
|
+
get_buffer.join == ans.join
|
355
|
+
end
|
356
|
+
def mark_correct
|
357
|
+
cells.each do |cell|
|
358
|
+
cell.color($colors[:correct])
|
359
|
+
cell.locked = true
|
360
|
+
end
|
361
|
+
@done = true
|
362
|
+
end
|
363
|
+
def mark_incorrect
|
364
|
+
cells.each{|c| c.color($colors[:incorrect])}
|
365
|
+
end
|
366
|
+
end
|
367
|
+
end
|
368
|
+
module Player
|
369
|
+
module Menus
|
370
|
+
class Pause < Interface::Menu
|
371
|
+
attr_reader :game
|
372
|
+
def initialize(game:)
|
373
|
+
super
|
374
|
+
@game = game
|
375
|
+
@draw_bars = false
|
376
|
+
end
|
377
|
+
def opts
|
378
|
+
{
|
379
|
+
"Continue" => ->{back; game.unpause},
|
380
|
+
"Exit Game" => ->{back; game.exit}
|
381
|
+
}
|
382
|
+
end
|
383
|
+
def title
|
384
|
+
"Paused"
|
385
|
+
end
|
386
|
+
def ctrls
|
387
|
+
super.merge({
|
388
|
+
?q => ->{back; game.unpause}
|
389
|
+
})
|
390
|
+
end
|
391
|
+
end
|
392
|
+
class Puzzle_Complete < Interface::Menu
|
393
|
+
def initialize
|
394
|
+
super
|
395
|
+
@draw_bars = false
|
396
|
+
end
|
397
|
+
def opts
|
398
|
+
{
|
399
|
+
"Exit" => ->{back; Screen.clear},
|
400
|
+
"Quit" => ->{exit}
|
401
|
+
}
|
402
|
+
end
|
403
|
+
def title
|
404
|
+
"Puzzle Complete!"
|
405
|
+
end
|
406
|
+
def ctrls
|
407
|
+
super.merge({
|
408
|
+
?q => ->{back; Screen.clear}
|
409
|
+
})
|
410
|
+
end
|
411
|
+
end
|
412
|
+
class Reset_Progress < Interface::Yes_No_Menu
|
413
|
+
def initialize(game:)
|
414
|
+
super(yes:->{game.reset},
|
415
|
+
post_proc:->{game.unpause})
|
416
|
+
@draw_bars = false
|
417
|
+
end
|
418
|
+
def title
|
419
|
+
"Reset puzzle progress?"
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
class Board
|
424
|
+
include Puzzle, Windows
|
425
|
+
attr_reader :puzzle, :grid, :box, :cursor, :clue, :dir
|
426
|
+
def initialize(date:Date.today)
|
427
|
+
@puzzle = Puzzle::Puzzle.new(date:date)
|
428
|
+
@grid = Grid.new(puzzle:puzzle)
|
429
|
+
@box = Cluebox.new(grid:grid)
|
430
|
+
@cursor = Cursor.new(grid:grid)
|
431
|
+
end
|
432
|
+
def setup(state:nil)
|
433
|
+
grid.draw
|
434
|
+
load_state(state:state)
|
435
|
+
set_clue(clue:puzzle.first_clue, mv:true)
|
436
|
+
update
|
437
|
+
self
|
438
|
+
end
|
439
|
+
def update
|
440
|
+
cursor.reset
|
441
|
+
grid.refresh
|
442
|
+
end
|
443
|
+
def redraw
|
444
|
+
grid.draw
|
445
|
+
grid.cells.flatten
|
446
|
+
.find_all{|c| c.buffer != " "}
|
447
|
+
.each{|c| c.unlock.write}
|
448
|
+
puzzle.check_all if $config[:auto_mark]
|
449
|
+
clue.activate
|
450
|
+
end
|
451
|
+
def move(y:0, x:0)
|
452
|
+
cursor.move(y:y, x:x)
|
453
|
+
if current_cell.blocked
|
454
|
+
move(y:y, x:x)
|
455
|
+
elsif outside_clue?
|
456
|
+
set_clue(clue:get_clue_at(**cursor.pos))
|
457
|
+
end
|
458
|
+
end
|
459
|
+
def insert_char(char:, advance:true)
|
460
|
+
addch(char:char)
|
461
|
+
move_after_insert(advance:advance)
|
462
|
+
check_current_clue
|
463
|
+
end
|
464
|
+
def delete_char(advance:true)
|
465
|
+
addch(char:" ").underline
|
466
|
+
advance_cursor(n:-1) if advance &&
|
467
|
+
!on_first_cell?
|
468
|
+
end
|
469
|
+
def next_clue(n:1)
|
470
|
+
n.times do
|
471
|
+
set_clue(clue:clue.next, mv:true)
|
472
|
+
end
|
473
|
+
next_clue if clue.done && !puzzle.complete?
|
474
|
+
end
|
475
|
+
def prev_clue(n:1)
|
476
|
+
n.times do
|
477
|
+
on_first_cell? ?
|
478
|
+
set_clue(clue:clue.prev, mv:true) :
|
479
|
+
to_start
|
480
|
+
end
|
481
|
+
prev_clue if clue.done && !puzzle.complete?
|
482
|
+
end
|
483
|
+
def to_start
|
484
|
+
cursor.set(**clue.coords.first)
|
485
|
+
end
|
486
|
+
def to_end
|
487
|
+
cursor.set(**clue.coords.last)
|
488
|
+
end
|
489
|
+
def swap_direction
|
490
|
+
set_clue(clue:get_clue_at(
|
491
|
+
**cursor.pos, dir:Pos.change_dir(dir)))
|
492
|
+
end
|
493
|
+
def save_state
|
494
|
+
[].tap do |state|
|
495
|
+
grid.cells.flatten.map do |cell|
|
496
|
+
state << { sq:cell.sq, char:cell.buffer } unless cell.blocked || cell.buffer == " "
|
497
|
+
end
|
498
|
+
end
|
499
|
+
end
|
500
|
+
def goto_clue(n:)
|
501
|
+
set_clue(clue:get_clue_by_index(i:n), mv:true)
|
502
|
+
end
|
503
|
+
def goto_cell(n:)
|
504
|
+
if n > 0 && n <= clue.length
|
505
|
+
cursor.set(**clue.cells[n-1].sq)
|
506
|
+
end
|
507
|
+
end
|
508
|
+
def clear_clue
|
509
|
+
clue.clear
|
510
|
+
clue.activate
|
511
|
+
end
|
512
|
+
def reveal_clue
|
513
|
+
clue.reveal
|
514
|
+
next_clue(n:1)
|
515
|
+
end
|
516
|
+
def clear_all_cells
|
517
|
+
grid.cells.flatten.each{|cell| cell.clear}
|
518
|
+
puzzle.clues.each{|clue| clue.done = false}
|
519
|
+
end
|
520
|
+
def advance_cursor(n:1)
|
521
|
+
case dir
|
522
|
+
when :a then move(x:n)
|
523
|
+
when :d then move(y:n)
|
524
|
+
end
|
525
|
+
end
|
526
|
+
private
|
527
|
+
def load_state(state:)
|
528
|
+
if state.exists?
|
529
|
+
state.chars.each do |s|
|
530
|
+
grid.cell(**s[:sq]).write(s[:char])
|
531
|
+
end
|
532
|
+
puzzle.check_all if $config[:auto_mark]
|
533
|
+
end
|
534
|
+
end
|
535
|
+
def set_clue(clue:, mv:false)
|
536
|
+
@clue.deactivate if @clue
|
537
|
+
@clue = clue
|
538
|
+
@dir = clue.dir
|
539
|
+
clue.activate
|
540
|
+
cursor.set(**clue.start.dup) if mv
|
541
|
+
box.show(clue:clue)
|
542
|
+
end
|
543
|
+
def current_cell
|
544
|
+
grid.cell(**cursor.pos)
|
545
|
+
end
|
546
|
+
def on_first_cell?
|
547
|
+
current_cell == clue.cells.first
|
548
|
+
end
|
549
|
+
def on_last_cell?
|
550
|
+
current_cell == clue.cells.last
|
551
|
+
end
|
552
|
+
def outside_clue?
|
553
|
+
!clue.has_cell?(**cursor.pos)
|
554
|
+
end
|
555
|
+
def get_clue_at(y:, x:, dir:@dir)
|
556
|
+
puzzle.get_clue(y:y, x:x, dir:dir)
|
557
|
+
end
|
558
|
+
def get_clue_by_index(i:)
|
559
|
+
puzzle.get_clue_by_index(i:i, dir:dir)
|
560
|
+
end
|
561
|
+
def addch(char:)
|
562
|
+
current_cell.write(char.upcase)
|
563
|
+
end
|
564
|
+
def move_after_insert(advance:)
|
565
|
+
if on_last_cell?
|
566
|
+
next_clue(n:1) if $config[:auto_advance]
|
567
|
+
elsif advance
|
568
|
+
advance_cursor(n:1)
|
569
|
+
end
|
570
|
+
end
|
571
|
+
def check_current_clue
|
572
|
+
clue.check if $config[:auto_mark]
|
573
|
+
end
|
574
|
+
end
|
575
|
+
class Cursor
|
576
|
+
attr_reader :grid, :pos
|
577
|
+
def initialize(grid:)
|
578
|
+
@grid = grid
|
579
|
+
end
|
580
|
+
def set(y:, x:)
|
581
|
+
@pos = Pos.mk(y,x)
|
582
|
+
end
|
583
|
+
def reset
|
584
|
+
grid.cell(**pos).focus
|
585
|
+
Curses.curs_set(1)
|
586
|
+
end
|
587
|
+
def move(y:, x:)
|
588
|
+
@pos[:y]+= y
|
589
|
+
@pos[:x]+= x
|
590
|
+
wrap
|
591
|
+
end
|
592
|
+
def wrap
|
593
|
+
pos[:x] += grid.sq[:x] while pos[:x] < 0
|
594
|
+
pos[:y] += grid.sq[:y] while pos[:y] < 0
|
595
|
+
pos[:x] -= grid.sq[:x] while pos[:x] >=
|
596
|
+
grid.sq[:x]
|
597
|
+
pos[:y] -= grid.sq[:y] while pos[:y] >=
|
598
|
+
grid.sq[:y]
|
599
|
+
end
|
600
|
+
end
|
601
|
+
class Game
|
602
|
+
include Database, Windows, Menus
|
603
|
+
attr_reader :state, :board, :top_b, :timer,
|
604
|
+
:bot_b, :ctrls , :date
|
605
|
+
attr_accessor :mode, :continue, :unsaved
|
606
|
+
def initialize(date:Date.today)
|
607
|
+
@date = date
|
608
|
+
@state = State.new(date:date)
|
609
|
+
@board = Board.new(date:date)
|
610
|
+
@top_b = Top_Bar.new(date:date)
|
611
|
+
@bot_b = Bottom_Bar.new
|
612
|
+
@timer = Timer.new(time:state.time, bar:top_b,
|
613
|
+
callback:->{board.update})
|
614
|
+
@ctrls = Controller.new(game:self)
|
615
|
+
@unsaved = false
|
616
|
+
@continue = true
|
617
|
+
setup
|
618
|
+
end
|
619
|
+
def play
|
620
|
+
if state.done
|
621
|
+
show_completed_menu
|
622
|
+
else
|
623
|
+
add_to_recents
|
624
|
+
game_and_timer_threads.map(&:join)
|
625
|
+
end
|
626
|
+
end
|
627
|
+
def setup
|
628
|
+
[top_b, bot_b].each(&:draw)
|
629
|
+
self.mode = :N
|
630
|
+
board.setup(state:state)
|
631
|
+
end
|
632
|
+
def unsaved=(bool)
|
633
|
+
@unsaved = bool
|
634
|
+
bot_b.unsaved(bool)
|
635
|
+
end
|
636
|
+
def mode=(mode)
|
637
|
+
@mode = mode
|
638
|
+
bot_b.mode(mode)
|
639
|
+
end
|
640
|
+
def user_input
|
641
|
+
board.grid.getch
|
642
|
+
end
|
643
|
+
def pause
|
644
|
+
timer.stop
|
645
|
+
Menus::Pause.new(game:self).choose_opt
|
646
|
+
end
|
647
|
+
def unpause
|
648
|
+
timer.start
|
649
|
+
board.redraw
|
650
|
+
end
|
651
|
+
def save
|
652
|
+
state.save(game:self)
|
653
|
+
bot_b.time_str(t:5, x:10, str:"Saved!")
|
654
|
+
self.unsaved = false
|
655
|
+
end
|
656
|
+
def reset_menu
|
657
|
+
timer.stop
|
658
|
+
Reset_Progress.new(game:self)
|
659
|
+
end
|
660
|
+
def reset
|
661
|
+
state.delete if state.exists?
|
662
|
+
board.clear_all_cells
|
663
|
+
timer.reset
|
664
|
+
end
|
665
|
+
def reveal
|
666
|
+
state.reveals+= 1
|
667
|
+
board.reveal_clue
|
668
|
+
end
|
669
|
+
def game_over
|
670
|
+
save if $config[:auto_save]
|
671
|
+
timer.stop
|
672
|
+
completed if board.puzzle.complete?
|
673
|
+
end
|
674
|
+
def exit
|
675
|
+
@continue = false
|
676
|
+
Screen.clear
|
677
|
+
end
|
678
|
+
private
|
679
|
+
def completed
|
680
|
+
save
|
681
|
+
log_score
|
682
|
+
show_completed_menu
|
683
|
+
end
|
684
|
+
def run
|
685
|
+
until game_finished?
|
686
|
+
ctrls.route(char:user_input)&.call
|
687
|
+
board.update
|
688
|
+
end
|
689
|
+
game_over
|
690
|
+
end
|
691
|
+
def game_finished?
|
692
|
+
board.puzzle.complete? || !continue
|
693
|
+
end
|
694
|
+
def game_and_timer_threads
|
695
|
+
[ Thread.new{run}, Thread.new{timer.start} ]
|
696
|
+
end
|
697
|
+
def show_completed_menu
|
698
|
+
Puzzle_Complete.new.choose_opt
|
699
|
+
end
|
700
|
+
def add_to_recents
|
701
|
+
Recents.new.add(date:date)
|
702
|
+
end
|
703
|
+
def log_score
|
704
|
+
Scores.new.add(game:self)
|
705
|
+
end
|
706
|
+
end
|
707
|
+
class Timer
|
708
|
+
attr_reader :time, :bar, :callback, :run
|
709
|
+
def initialize(time:0, bar:, callback:)
|
710
|
+
@time = Time.abs(time)
|
711
|
+
@bar, @callback = bar, callback
|
712
|
+
end
|
713
|
+
def start
|
714
|
+
Thread.new{tick}
|
715
|
+
@run = true
|
716
|
+
end
|
717
|
+
def stop
|
718
|
+
@run = false
|
719
|
+
end
|
720
|
+
def reset
|
721
|
+
@time = Time.abs(0)
|
722
|
+
@run = false
|
723
|
+
end
|
724
|
+
private
|
725
|
+
def tick
|
726
|
+
while @run
|
727
|
+
bar.add_str(x:-1, str:time_str)
|
728
|
+
callback.call
|
729
|
+
@time += 1
|
730
|
+
sleep(1)
|
731
|
+
end
|
732
|
+
end
|
733
|
+
def time_str
|
734
|
+
time.strftime("%T")
|
735
|
+
end
|
736
|
+
end
|
737
|
+
class Controller
|
738
|
+
attr_reader :game
|
739
|
+
def initialize(game:)
|
740
|
+
@game = game
|
741
|
+
end
|
742
|
+
def route(char:)
|
743
|
+
if is_ctrl_key?(char)
|
744
|
+
controls[:G][char.to_i]
|
745
|
+
elsif is_arrow_key?(char)
|
746
|
+
arrow(char)
|
747
|
+
else
|
748
|
+
case game.mode
|
749
|
+
when :N then normal(char:char)
|
750
|
+
when :I then insert(char:char)
|
751
|
+
end
|
752
|
+
end
|
753
|
+
end
|
754
|
+
def normal(char:, n:1)
|
755
|
+
if (?0..?9).cover?(char)
|
756
|
+
await_int(n:char.to_i)
|
757
|
+
else
|
758
|
+
controls(n)[:N][char]
|
759
|
+
end
|
760
|
+
end
|
761
|
+
def insert(char:)
|
762
|
+
case char
|
763
|
+
when 27 then ->{game.mode = :N}
|
764
|
+
when 127 then ->{game.board.delete_char}
|
765
|
+
when ?A..?z
|
766
|
+
->{game.board.insert_char(char:char);
|
767
|
+
game.unsaved = true }
|
768
|
+
end
|
769
|
+
end
|
770
|
+
def is_ctrl_key?(char)
|
771
|
+
(1..26).cover?(char)
|
772
|
+
end
|
773
|
+
def is_arrow_key?(char)
|
774
|
+
(258..261).cover?(char)
|
775
|
+
end
|
776
|
+
def arrow(char)
|
777
|
+
mv = case char
|
778
|
+
when Curses::KEY_UP then {y:-1}
|
779
|
+
when Curses::KEY_DOWN then {y:1}
|
780
|
+
when Curses::KEY_LEFT then {x:-1}
|
781
|
+
when Curses::KEY_RIGHT then {x:1}
|
782
|
+
end
|
783
|
+
->{game.board.move(**mv)}
|
784
|
+
end
|
785
|
+
def controls(n=1)
|
786
|
+
{
|
787
|
+
G:{
|
788
|
+
3 => ->{game.exit},
|
789
|
+
5 => ->{game.reset_menu.choose_opt},
|
790
|
+
7 => ->{game.board.puzzle.check_all},
|
791
|
+
9 => ->{game.board.swap_direction},
|
792
|
+
16 => ->{game.pause},
|
793
|
+
18 => ->{game.reveal},
|
794
|
+
19 => ->{game.save}
|
795
|
+
},
|
796
|
+
N:{
|
797
|
+
?j => ->{game.board.move(y:n)},
|
798
|
+
?k => ->{game.board.move(y:n*-1)},
|
799
|
+
?h => ->{game.board.move(x:n*-1)},
|
800
|
+
?l => ->{game.board.move(x:n)},
|
801
|
+
?i => ->{game.mode = :I},
|
802
|
+
?I => ->{game.board.to_start; game.mode=:I},
|
803
|
+
?w => ->{game.board.next_clue(n:n)},
|
804
|
+
?a => ->{game.board.advance_cursor(n:1); game.mode=:I},
|
805
|
+
?b => ->{game.board.prev_clue(n:n)},
|
806
|
+
?e => ->{game.board.to_end},
|
807
|
+
?r => ->{await_replace},
|
808
|
+
?c => ->{await_delete; game.mode=:I},
|
809
|
+
?d => ->{await_delete},
|
810
|
+
?x => ->{game.board.delete_char(advance:false)}
|
811
|
+
}
|
812
|
+
}
|
813
|
+
end
|
814
|
+
def await_int(n:)
|
815
|
+
char = game.user_input
|
816
|
+
case char
|
817
|
+
when ?g then ->{game.board.goto_clue(n:n)}
|
818
|
+
when ?G then ->{game.board.goto_cell(n:n)}
|
819
|
+
when ?0..?9 then await_int(n:(10*n)+char.to_i)
|
820
|
+
else normal(char:char, n:n)
|
821
|
+
end
|
822
|
+
end
|
823
|
+
def await_replace
|
824
|
+
char = game.user_input
|
825
|
+
case char
|
826
|
+
when ?A..?z
|
827
|
+
game.board.insert_char(
|
828
|
+
char:char, advance:false)
|
829
|
+
end
|
830
|
+
end
|
831
|
+
def await_delete
|
832
|
+
case game.user_input
|
833
|
+
when ?w then game.board.clear_clue
|
834
|
+
end
|
835
|
+
end
|
836
|
+
end
|
837
|
+
end
|
838
|
+
end
|
839
|
+
end
|