Repository navigation
Expand file tree
/
Copy pathrender.js
More file actions
executable file
·1966 lines (1824 loc) · 66.3 KB
/
Copy pathrender.js
File metadata and controls
executable file
·1966 lines (1824 loc) · 66.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use strict'
/**
* The visual layer: pure functions from state to a block of text.
*
* Nothing here holds state and nothing here touches the outside world. Every
* function takes the width and height it is allowed to use and returns a block
* of exactly that size. That is not tidiness, it is what keeps the diff
* renderer honest: a frame one line taller than the terminal makes the terminal
* itself scroll, and from that moment on every absolute cursor move addresses
* the wrong row and the screen looks like it jumped.
*
* Three constraints shape everything below.
*
* 1. ASCII 128 only. A box drawing character that a font does not have is not
* merely ugly, it renders as a replacement box of a different cell width,
* and the whole line drifts. `+`, `-` and `|` are one cell in every
* terminal on every machine, forever.
*
* 2. A terminal cell is about twice as tall as it is wide. A map drawn one
* character per tile comes out stretched vertically, so `mapPane` draws
* every tile two columns wide and the world reads as square. This is the
* only reason `CELL_W` exists.
*
* 3. All layout math goes through `style.width` / `style.truncate`, never
* `.length` / `.slice`. The strings here carry ANSI colour, whose byte
* count has nothing to do with the number of cells the user sees.
*
* The model lives outside. This file only draws it.
*/
const { style } = require('bare-tui')
const { makeMovingHeroSprite } = require('./sprites.js')
const { WORLD_BOSS } = require('./world-boss.js')
const { bossCamera } = require('./world-boss-event.js')
const TITLE_TURNTABLE = require('../assets/ascii/paisaje.json')
const HERO_TURNTABLE = require('../assets/ascii/heroe.json')
const HELMET_TURNTABLE = require('../assets/ascii/yelmo.json')
const FIELD_COLOSSUS = require('../assets/ascii/coloso-field.json')
function turntableFrame(model, w, h, frame = 0) {
const variant = model.variants.find((size) => size.width <= w && size.height <= h)
if (!variant) return null
return variant.frames[Math.floor(Math.max(0, Number(frame) || 0)) % variant.frames.length]
}
/** Smallest terminal the split layout is designed for. */
const MIN_WIDTH = 64
/** Smallest terminal the split layout is designed for. */
const MIN_HEIGHT = 16
/** Inner width of the right hand column (stats above, log below). */
const SIDE_W = 26
/** Terminal columns per map tile. Two, because cells are about 1:2. */
const CELL_W = 2
/** Torso column inside the approved asymmetric hero drawing. */
const HERO_ANCHOR_X = 2
/**
* Tiles that fill their cell rather than sit in it.
*
* A tile is two columns wide (see CELL_W), so every tile has to decide what
* goes in the second column. Terrain and walls repeat, because a wall with a
* gap in it is a doorway the player will walk into and bounce off. Everything
* else gets a trailing space, which is what makes a floor of dots read as open
* ground instead of static. `|` and `+` are in here so that a map drawn as
* line art survives the doubling: `+--+` comes out `++----++`, still a box.
*/
const SOLID = '#%^=~":,-|+'
/** ASCII border charset, for `style().border()` or for `box()` below. */
const BORDER = {
topLeft: '+',
top: '-',
topRight: '+',
left: '|',
right: '|',
bottomLeft: '+',
bottom: '-',
bottomRight: '+'
}
const COLOR = {
border: 'gray',
hpHigh: 'green',
hpMid: 'yellow',
hpLow: 'red',
xp: 'magenta',
gold: 'yellow',
foe: 'red',
hero: 'cyan',
cooldown: 'blue',
yes: 'green',
no: 'red'
}
/** Colour belongs to terrain, not map data: collision stays plain ASCII. */
function mapColor(ch) {
if (ch === ',' || ch === '"' || ch === 't') return 'green'
if (ch === '~') return 'blue'
if (ch === '*' || ch === '^' || ch === '/' || ch === '\\') return 'magenta'
if (ch === 'O' || ch === '[' || ch === ']' || '(){}'.includes(ch)) return 'cyan'
if (ch === 'o' || ch === '%' || '#+=-|;_'.includes(ch)) return 'gray'
if (ch === ':' || /[A-Z<>a-z]/.test(ch)) return 'yellow'
return null
}
// ---------------------------------------------------------------------------
// small text helpers
// ---------------------------------------------------------------------------
/**
* @param {number} n
* @param {number} lo
* @param {number} hi
* @returns {number}
*/
function clamp(n, lo, hi) {
if (!Number.isFinite(n)) return lo
return n < lo ? lo : n > hi ? hi : n
}
/**
* Force a string into ASCII 128. Anything outside the printable range becomes
* `?`, which is visibly wrong at exactly one cell wide instead of invisibly
* wrong at two. Do not run this over styled text: it would eat the escapes.
* @param {string} s
* @returns {string}
*/
function ascii(s) {
let out = ''
for (const ch of String(s)) {
const cp = ch.codePointAt(0)
out += cp >= 32 && cp <= 126 ? ch : '?'
}
return out
}
/**
* Pad or truncate to exactly `w` visible cells, content to the left.
* @param {string} s
* @param {number} w
* @returns {string}
*/
function padTo(s, w) {
if (w <= 0) return ''
const t = style.truncate(String(s), w)
return t + ' '.repeat(Math.max(0, w - style.width(t)))
}
/**
* Pad or truncate to exactly `w` visible cells, content to the right.
* @param {string} s
* @param {number} w
* @returns {string}
*/
function padLeftTo(s, w) {
if (w <= 0) return ''
const t = style.truncate(String(s), w)
return ' '.repeat(Math.max(0, w - style.width(t))) + t
}
/**
* One line of exactly `w` cells with `left` flush left and `right` flush
* right. When they cannot both fit, the left side is the one that gets cut,
* because the right side is almost always a number and half a number is a lie.
* @param {number} w
* @param {string} left
* @param {string} [right]
* @returns {string}
*/
function row(w, left, right = '') {
if (w <= 0) return ''
const r = String(right)
const rw = style.width(r)
if (rw >= w) return padLeftTo(r, w)
const l = style.truncate(String(left), w - rw)
return l + ' '.repeat(w - style.width(l) - rw) + r
}
/**
* Plain text word wrap. Words longer than the column are hard split rather
* than allowed to overflow, since one long token would otherwise push a border
* off the screen edge.
* @param {string} text
* @param {number} w
* @returns {string[]}
*/
function wrap(text, w) {
if (w < 1) return ['']
const out = []
for (const para of String(text).split('\n')) {
let line = ''
for (const word of para.split(' ')) {
if (word === '') continue
if (line === '') line = word
else if (style.width(line) + 1 + style.width(word) <= w) line = line + ' ' + word
else {
out.push(line)
line = word
}
while (style.width(line) > w) {
const head = style.truncate(line, w)
out.push(head)
line = line.slice(head.length)
}
}
out.push(line)
}
return out
}
/**
* @param {string} s
* @param {string} color
* @returns {string}
*/
function paint(s, color) {
return color ? style().foreground(color).render(s) : s
}
/**
* Paint a row in colour runs instead of wrapping every individual character in
* its own ANSI open/reset pair. The visible result is identical, but a moving
* field actor no longer turns one 120-cell row into thousands of output bytes.
*
* @param {string[]|string} cells
* @param {Array<string|null>} colors
* @returns {string}
*/
function paintRuns(cells, colors) {
const chars = Array.isArray(cells) ? cells : String(cells).split('')
if (chars.length === 0) return ''
const foreground = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90
}
// One SGR change replaces the preceding foreground directly. Reset only
// when returning to uncoloured text or at the end of the row; closing and
// reopening every short terrain run doubles the bytes around transparent
// actor cells and can revive the terminal redraw problem.
if (colors.every((color) => !color || foreground[color])) {
let out = ''
let active = null
for (let i = 0; i < chars.length; i++) {
const color = colors[i] || null
if (color !== active) {
out += color ? `\u001b[${foreground[color]}m` : '\u001b[39m'
active = color
}
out += chars[i]
}
if (active) out += '\u001b[39m'
return out
}
let out = ''
let from = 0
while (from < chars.length) {
const color = colors[from] || null
let to = from + 1
while (to < chars.length && (colors[to] || null) === color) to++
out += paint(chars.slice(from, to).join(''), color)
from = to
}
return out
}
// ---------------------------------------------------------------------------
// bars
// ---------------------------------------------------------------------------
/**
* An ASCII meter, `[####------]`, exactly `w` cells wide including brackets.
*
* Two rounding rules matter more than they look. A fighter with one hit point
* left keeps one visible block, and a fighter one hit from death never shows a
* full bar. Without those, the bar lies at precisely the two moments the
* player is actually reading it.
*
* @param {number} value
* @param {number} max
* @param {number} w total width including the brackets
* @param {{fill?: string, empty?: string, open?: string, close?: string}} [opts]
* @returns {string}
*/
function bar(value, max, w, opts = {}) {
const open = opts.open === undefined ? '[' : opts.open
const close = opts.close === undefined ? ']' : opts.close
const fill = opts.fill || '#'
const empty = opts.empty || '-'
const inner = Math.max(1, w - open.length - close.length)
const frac = clamp(max > 0 ? value / max : 0, 0, 1)
let n = Math.round(inner * frac)
if (frac > 0 && n === 0) n = 1
if (frac < 1 && n === inner) n = inner - 1
return open + fill.repeat(n) + empty.repeat(inner - n) + close
}
/**
* Colour for a health fraction: green, then yellow, then red.
* @param {number} frac
* @returns {string}
*/
function hpColor(frac) {
return frac > 0.5 ? COLOR.hpHigh : frac > 0.25 ? COLOR.hpMid : COLOR.hpLow
}
/**
* `label [####------] 16/20` in exactly `w` cells.
* @param {string} label
* @param {number} value
* @param {number} max
* @param {number} w
* @param {string} [color]
* @returns {string}
*/
function statLine(label, value, max, w, color) {
const nums = `${Math.max(0, Math.round(value))}/${Math.max(0, Math.round(max))}`
const lw = style.width(label)
const barW = w - lw - style.width(nums) - 2
if (barW < 3) return row(w, label, nums)
const meter = paint(bar(value, max, barW), color)
return label + ' ' + meter + ' ' + nums
}
// ---------------------------------------------------------------------------
// boxes
// ---------------------------------------------------------------------------
/**
* An ASCII box around content, `w` inner columns by `h` inner rows, with an
* optional caption bitten out of the top border:
*
* +- log --------------+
* | pegas 4 |
* +--------------------+
*
* Content shorter than `h` is padded, content longer is cut. Both are on
* purpose: the box owns its size so the caller can add up a layout without
* having to trust what is inside.
*
* @param {string} content
* @param {number} w inner width
* @param {number} [h] inner height, omitted to fit the content
* @param {string} [caption]
* @returns {string}
*/
function box(content, w, h, caption) {
const iw = Math.max(1, w)
let s = style().width(iw)
if (h) s = s.height(Math.max(1, h))
const lines = s.render(String(content)).split('\n')
const bar_ = (n) => '-'.repeat(Math.max(0, n))
let top = '+' + bar_(iw) + '+'
if (caption) {
const cap = ' ' + style.truncate(ascii(caption), Math.max(1, iw - 4)) + ' '
top = '+-' + cap + bar_(iw - 1 - style.width(cap)) + '+'
}
const edge = paint('|', COLOR.border)
const out = [paint(top, COLOR.border)]
for (const line of lines) out.push(edge + line + edge)
out.push(paint('+' + bar_(iw) + '+', COLOR.border))
return out.join('\n')
}
// ---------------------------------------------------------------------------
// the too small screen
// ---------------------------------------------------------------------------
/**
* The frame to show when the terminal is smaller than the layout needs, or
* null when it fits. It is deliberately a full frame of exactly the size we
* were given: refusing to draw is still drawing, and it has to obey the same
* rule as everything else.
*
* @param {number} width
* @param {number} height
* @returns {string|null}
*/
function tooSmall(width, height) {
const w = Math.max(0, Math.floor(width))
const h = Math.max(0, Math.floor(height))
if (w >= MIN_WIDTH && h >= MIN_HEIGHT) return null
if (w < 1 || h < 1) return ''
const msg = [
'la terminal es muy chica',
'',
`tenes ${w}x${h}`,
`necesito ${MIN_WIDTH}x${MIN_HEIGHT}`,
'',
'agranda la ventana'
]
const body = msg.slice(0, h)
const pad = Math.max(0, Math.floor((h - body.length) / 2))
const out = []
for (let i = 0; i < pad; i++) out.push(' '.repeat(w))
for (const line of body) {
const left = Math.max(0, Math.floor((w - style.width(line)) / 2))
out.push(padTo(' '.repeat(left) + line, w))
}
while (out.length < h) out.push(' '.repeat(w))
return out.slice(0, h).join('\n')
}
// ---------------------------------------------------------------------------
// right hand column
// ---------------------------------------------------------------------------
/**
* Render an item slot. Accepts a content.js item object, a bare id string, or
* nothing at all, because the caller reads its equipment from three different
* places (the world's `held`, the script's snapshot, the shop) and none of
* them should have to normalise first.
*
* @param {object|string|null|undefined} item
* @returns {string}
*/
function itemLabel(item) {
if (!item) return '-'
if (typeof item === 'string') return ascii(item)
const glyph = item.glyph ? ascii(item.glyph) + ' ' : ''
return glyph + ascii(item.name || item.id || '?')
}
/**
* The character sheet: health, experience, money, equipment.
*
* @param {object} p
* @param {string} [p.name]
* @param {number} [p.level]
* @param {number} p.hp
* @param {number} p.maxhp
* @param {number} [p.xp]
* @param {number} [p.xpNext]
* @param {number} [p.gold]
* @param {number} [p.potions]
* @param {{x:number,y:number,area:string}} [p.coordinates]
* @param {object|string|null} [p.left]
* @param {object|string|null} [p.right]
* @param {object|string|null} [p.chest]
* @param {object|string|null} [p.head]
* @param {object|string|null} [p.boots]
* @param {number} w inner width
* @returns {string}
*/
function statsPanel(p, w) {
if (!p) return ''
const hp = Number(p.hp) || 0
const maxhp = Number(p.maxhp) || 1
const xp = Number(p.xp) || 0
const xpNext = Number(p.xpNext) || 1
const lines = [
row(
w,
style()
.bold(true)
.render(ascii(p.name || 'vos')),
'nv ' + (p.level === undefined ? 1 : p.level)
)
]
lines.push(
statLine('hp', hp, maxhp, w, hpColor(hp / maxhp)),
statLine('xp', xp, xpNext, w, COLOR.xp),
row(
w,
paint('oro ' + (p.gold === undefined ? 0 : p.gold), COLOR.gold),
'pociones ' + (p.potions === undefined ? 0 : p.potions)
),
row(w, 'izq ' + itemLabel(p.left)),
row(w, 'der ' + itemLabel(p.right)),
row(w, 'pecho ' + itemLabel(p.chest)),
row(w, 'casco ' + itemLabel(p.head)),
row(w, 'botas ' + itemLabel(p.boots))
)
if (p.quest) {
const progress = `${Number(p.quest.progress) || 0}/${Number(p.quest.count) || 0}`
lines.push(
row(w, p.quest.ready ? 'mision lista: volver' : `mision ${p.quest.label} ${progress}`)
)
}
return lines.join('\n')
}
/**
* The last lines of the log, newest at the bottom.
*
* Padding goes on top rather than the bottom so a fresh message always appears
* on the same row instead of the whole log sliding down the panel as it fills.
*
* @param {Array<string|{text: string}>} entries
* @param {number} w inner width
* @param {number} h inner height
* @returns {string}
*/
function logPanel(entries, w, h) {
const rows = Math.max(1, h)
const lines = []
for (const e of entries || []) {
if (e === null || e === undefined) continue
const text = typeof e === 'string' ? e : String(e.text === undefined ? e : e.text)
for (const l of wrap(ascii(text), w)) lines.push(l)
}
const tail = lines.slice(Math.max(0, lines.length - rows))
while (tail.length < rows) tail.unshift('')
return tail.map((l) => padTo(l, w)).join('\n')
}
// ---------------------------------------------------------------------------
// the map
// ---------------------------------------------------------------------------
/**
* Expand one tile into `cellW` terminal columns.
* @param {string} ch
* @param {number} cellW
* @param {boolean} isActor
* @returns {string}
*/
function expandCell(ch, cellW, isActor) {
if (cellW <= 1) return ch
if (!isActor && SOLID.indexOf(ch) !== -1) return ch.repeat(cellW)
return ch + ' '.repeat(cellW - 1)
}
const WORDMARK_LINES = 6
const LOGO = [
' ____ _ _ _ _ _',
' | _ \\| | | | \\ | | / \\',
' | |_) | | | | \\| | / _ \\',
' | _ <| |_| | |\\ |/ ___ \\',
' |_| \\_\\\\___/|_| \\_/_/ \\_\\',
' RUNA // UN RPG HECHO EN BARE',
'',
' |>>> |>>>',
' _ _|_ _ _ _|_ _',
' |;|_|;|_|;| .-^-. |;|_|;|_|;|',
' \\\\. . / |+ +| \\\\. . /',
' ||: . | |_-_| ||: . |',
' ||: | __/| |\\__ ||: |',
' __||______|_______/__/| |\\__\\_______||______|__',
' / \\ /_| |_\\ / \\',
' /_/ \\_\\'
]
/**
* The title card. Centred on whatever the terminal gives us, and it
* degrades to just the word when the terminal is too narrow for the art.
* @param {number} w
* @param {number} h
* @param {string} [status]
* @param {object} [menu]
* @returns {string}
*/
function titleScreen(w, h, status, menu = null) {
w = Math.max(0, Math.floor(w))
h = Math.max(0, Math.floor(h))
const wordmark = LOGO.slice(0, WORDMARK_LINES)
const fits = (lines, extraRows) =>
h >= lines.length + extraRows && Math.max(...lines.map((line) => line.length)) + 2 <= w
const slots = menu && Array.isArray(menu.slots) ? menu.slots : null
const frameIndex = Math.floor(Math.max(0, Number(menu && menu.frame) || 0))
const turntable =
menu && menu.page === 'main'
? TITLE_TURNTABLE.variants.find(
(variant) => variant.width + 2 <= w && variant.height + WORDMARK_LINES + 13 <= h
)
: null
const full = !turntable && fits(LOGO, slots ? 12 : 4)
const art = turntable
? [
...wordmark,
'EL REINO | HEROE Y COLOSO',
...turntable.frames[frameIndex % turntable.frames.length]
]
: full
? LOGO
: fits(wordmark, 4)
? wordmark
: ['RUNA']
const centre = (t) => ' '.repeat(Math.max(0, Math.floor((w - style.width(t)) / 2))) + t
const frameWidth = () => Math.min(w - 2, Math.max(42, Math.min(72, w - 8)))
const frameTop = (label, width) => {
const title = '[ ' + label + ' ]'
return '+' + title + '-'.repeat(Math.max(0, width - style.width(title) - 2)) + '+'
}
const frameLine = (value, width) => '| ' + padTo(value, width - 4) + ' |'
const frameBottom = (width) => '+' + '-'.repeat(Math.max(0, width - 2)) + '+'
const tag = 'no controlas a tu personaje. escribis las reglas que sigue.'
const sceneWidth = Math.max(...LOGO.slice(WORDMARK_LINES + 1).map((line) => line.length))
const painted = art.map((line, i) => {
const color = i < WORDMARK_LINES ? 'yellow' : 'gray'
const canvas = full && i > WORDMARK_LINES ? line.padEnd(sceneWidth) : line
return centre(
style()
.bold(i < WORDMARK_LINES)
.foreground(color)
.render(canvas)
)
})
const body = [...painted, '']
if (!slots) {
body.push(
centre(
style()
.faint(true)
.render(tag.length <= w - 2 ? tag : 'escribis las reglas')
),
'',
centre(style().reverse(true).render(' ENTER / ESPACIO nueva partida ')),
centre(style().faint(true).render(' Q salir '))
)
} else {
const page = menu.page || 'slots'
if (page === 'main') {
const playable = slots.filter((slot) => slot && !slot.empty && !slot.corrupt)
const latest = playable
.slice()
.sort((a, b) => String(b.savedAt || '').localeCompare(a.savedAt || ''))[0]
const choices = [
{
label: latest
? `CONTINUAR R${latest.slot} - ${ascii(latest.name)}`
: 'CONTINUAR (sin partidas)',
disabled: !latest
},
{ label: 'NUEVA PARTIDA', disabled: false },
{
label: playable.length ? 'CARGAR PARTIDA' : 'CARGAR PARTIDA (sin partidas)',
disabled: !playable.length
},
{ label: 'CONTROLES', disabled: false },
{ label: 'SALIR', disabled: false }
]
const cursor = Math.max(0, Math.min(choices.length - 1, Number(menu.cursor) || 0))
const width = frameWidth()
body.push(
centre(style().bold(true).foreground('yellow').render(frameTop('RUNA LAUNCHER', width)))
)
body.push(
centre(
frameLine(
style().bold(true).render('MENU PRINCIPAL') +
' ' +
style().foreground('cyan').render('VERSION 0.2.0') +
' ' +
style().faint(true).render('reinos | coloso | inventario'),
width
)
)
)
body.push(centre(frameLine('', width)))
for (let index = 0; index < choices.length; index++) {
const choice = choices[index]
const selected = index === cursor
const line = (selected ? '> ' : ' ') + choice.label
const paint = style().faint(choice.disabled).reverse(selected).bold(selected)
body.push(centre(frameLine(paint.render(padTo(line, width - 4)), width)))
}
body.push(
centre(
frameLine(
style()
.faint(true)
.render('ARRIBA/ABAJO ENTER / ESPACIO aceptar ? controles Q salir'),
width
)
),
centre(style().bold(true).foreground('yellow').render(frameBottom(width)))
)
} else {
body.push(centre(style().bold(true).foreground('yellow').render('PARTIDAS GUARDADAS')))
const cursor = Math.max(0, Math.min(slots.length - 1, Number(menu.cursor) || 0))
for (let index = 0; index < slots.length; index++) {
const slot = slots[index] || { slot: index + 1, empty: true }
const selected = index === cursor
let label = `[${slot.slot || index + 1}] `
if (slot.empty) label += 'VACIA'
else if (slot.corrupt) label += 'GUARDADO DANADO - N para reemplazar'
else {
label += `${ascii(slot.name || 'viajero')} | ${ascii(slot.realm || 'runa')} | nv ${slot.level || 1} | ${ascii(slot.place || 'ciudad')}`
}
const line = (selected ? '> ' : ' ') + label
body.push(
centre(
selected
? style()
.reverse(true)
.render(' ' + line + ' ')
: line
)
)
}
body.push(
'',
centre(
style().faint(true).render('ENTER cargar | N nueva/reemplazar | ESC volver | Q salir')
)
)
}
const message = ascii(String(menu.message || '')).trim()
if (message) body.push(centre(style().foreground('red').render(message)))
}
// Who else is out there, if the caller knows. It goes last and it is the
// first thing dropped when the terminal is short: the logo and the prompt are
// what this screen is for, and a status line that pushes them off the bottom
// has made things worse than saying nothing.
const note = ascii(String(status || '')).trim()
if (note && note.length <= w - 2) {
body.push('')
body.push(centre(style().faint(true).render(note)))
if (body.length > h) body.length -= 2
}
const pad = Math.max(0, Math.floor((h - body.length) / 2))
const out = Array(pad).fill('').concat(body)
while (out.length < h) out.push('')
return out
.slice(0, h)
.map((line) => padTo(line, w))
.join('\n')
}
/** Name entry shown between the main menu and a fresh game. */
function newGameScreen(w, h, input, initial, error, options = {}) {
w = Math.max(0, Math.floor(w))
h = Math.max(0, Math.floor(h))
const centre = (line) =>
' '.repeat(Math.max(0, Math.floor((w - style.width(line)) / 2))) + style.truncate(line, w)
const realms = Array.isArray(options.realms) ? options.realms : []
const extraRows = 15 + realms.length + (options.replacing ? 2 : 0) + (error ? 2 : 0)
const preview = turntableFrame(HERO_TURNTABLE, w - 2, h - extraRows, options.frame)
const sprite = preview || heroSprite({ initial, items: [] })
const realmCursor = Math.max(
0,
Math.min(realms.length - 1, Math.floor(Number(options.realmCursor) || 0))
)
const realmChoices = realms.map((realm, index) => {
const label = `${ascii(realm.name)} - ${ascii(realm.about)}`
return index === realmCursor ? style().reverse(true).render(` < ${label} > `) : ` ${label} `
})
const body = [
centre(style().bold(true).foreground('yellow').render('NUEVA PARTIDA')),
centre(
style()
.faint(true)
.render(`RANURA ${options.slot || 1}`)
),
'',
...sprite.map((line) => centre(style().foreground(COLOR.hero).render(line))),
'',
centre('tu inicial va en el pecho del personaje'),
'',
centre('NOMBRE ' + String(input || '')),
'',
centre(style().bold(true).render('REINO DE ORIGEN')),
...realmChoices.map((choice) => centre(choice)),
centre(style().faint(true).render('IZQUIERDA / DERECHA elegir reino')),
'',
centre(style().reverse(true).render(' ENTER comenzar ')),
centre(style().faint(true).render(' ESC volver '))
]
if (options.replacing) {
body.push(
'',
centre(
style()
.foreground('yellow')
.render(`reemplazara la partida de ${ascii(options.replacing)}`)
)
)
}
if (error) body.push('', centre(style().foreground('red').render(ascii(error))))
const pad = Math.max(0, Math.floor((h - body.length) / 2))
const out = Array(pad).fill('').concat(body).slice(0, h)
while (out.length < h) out.push('')
return out.map((line) => padTo(line, w)).join('\n')
}
/**
* The walking view: a window onto the tile map, centred on the hero and
* clamped to the map edges so the camera never shows a void it does not have
* to.
*
* Every tile is drawn `cellW` columns wide, which is the whole reason a square
* room looks square. Draw it one column per tile and the same room is a
* letterbox.
*
* @param {object} map
* @param {string[]} map.tiles rows of ASCII, one character per tile
* @param {{x: number, y: number, glyph?: string, sprite?: string[]}} map.hero
* @param {Array<{x: number, y: number, glyph?: string, sprite?: string[], color?: string}>} [map.actors]
* @param {Array<{cadence?:number,frames:Array<Array<{x:number,y:number,glyph:string,color?:string}>>}>} [map.animations]
* @param {number} [map.frame]
* @param {number} w
* @param {number} h
* @param {{cellW?: number}} [opts]
* @returns {string}
*/
function mapPane(map, w, h, opts = {}) {
const cellW = Math.max(1, opts.cellW || CELL_W)
const tiles = (map && map.tiles) || []
const mapH = tiles.length
let mapW = 0
for (const r of tiles) mapW = Math.max(mapW, String(r).length)
const cols = Math.max(1, Math.floor(w / cellW))
const hero = (map && map.hero) || { x: 0, y: 0 }
const hx = Math.round(Number(hero.x) || 0)
const hy = Math.round(Number(hero.y) || 0)
// Centre on the hero, then stop at the map edge so the camera never shows a
// void it does not have to. A map smaller than the pane is centred in it
// instead, since a small room pinned to the top left corner reads as a bug.
const camX =
mapW <= cols ? -Math.floor((cols - mapW) / 2) : clamp(hx - Math.floor(cols / 2), 0, mapW - cols)
const camY = mapH <= h ? -Math.floor((h - mapH) / 2) : clamp(hy - Math.floor(h / 2), 0, mapH - h)
// Overlay everything that moves, hero last so nothing can hide the player.
const heroSpriteData = Array.isArray(hero.sprite) && hero.sprite.length ? hero.sprite : null
const heroW = heroSpriteData
? heroSpriteData.reduce((widest, line) => Math.max(widest, ascii(line).length), 1)
: 1
const occupied = new Set()
const over = new Map()
const actorColors = new Map()
const animationFrame = Math.max(0, Math.floor(Number(map && map.frame) || 0))
for (const animation of (map && map.animations) || []) {
const frames = animation && Array.isArray(animation.frames) ? animation.frames : []
if (!frames.length) continue
const cadence = Math.max(1, Math.floor(Number(animation.cadence) || 1))
const cells = frames[Math.floor(animationFrame / cadence) % frames.length] || []
for (const cell of cells) {
const x = Math.round(Number(cell && cell.x))
const y = Math.round(Number(cell && cell.y))
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
const key = y + ',' + x
over.set(key, ascii(cell.glyph || ' ')[0] || ' ')
actorColors.set(key, cell.color || 'blue')
}
}
for (const a of (map && map.actors) || []) {
if (!a) continue
const ax = Math.round(Number(a.x) || 0)
const ay = Math.round(Number(a.y) || 0)
const actorSprite = Array.isArray(a.sprite) && a.sprite.length ? a.sprite : null
if (!actorSprite) {
const cell = ay + ',' + ax
over.set(cell, ascii(a.glyph || '?')[0] || '?')
actorColors.set(cell, a.color || COLOR.foe)
continue
}
const actorW = actorSprite.reduce((widest, line) => Math.max(widest, ascii(line).length), 1)
let drawX = ax
const drawY = ay
const playerAnchor = clamp(HERO_ANCHOR_X, 0, heroW - 1)
const foeAnchor = Math.floor(actorW / 2)
const heroLeft = hx - playerAnchor
const heroRight = heroLeft + heroW - 1
const defaultActorLeft = ax - foeAnchor
const defaultActorRight = defaultActorLeft + actorW - 1
// The horizontal test uses the real sprite boxes, so the vertical one has to
// as well. It used to be `Math.abs(ay - hy) <= 2`, a literal that predates the
// taller mover sprites: MOVER_HEIGHT is 4 and the hero is 3 rows, so a hero
// standing three tiles above an NPC overlapped its head while the guard said
// they were clear, and the hero painted over it.
const heroH = heroSpriteData ? heroSpriteData.length : 1
const heroAnchorRow = Number.isFinite(Number(hero.anchorY))
? Math.round(Number(hero.anchorY))
: Math.floor(heroH / 2)
const heroTop = hy - clamp(heroAnchorRow, 0, heroH - 1)
const heroBottom = heroTop + heroH - 1
const actorH = actorSprite.length
const actorAnchorRow = Number.isFinite(Number(a.anchorY))
? Math.round(Number(a.anchorY))
: Math.floor(actorH / 2)
const actorTop = ay - clamp(actorAnchorRow, 0, actorH - 1)
const actorBottom = actorTop + actorH - 1
const rowsOverlap = heroTop <= actorBottom && heroBottom >= actorTop
const isOverlapping =
heroLeft <= defaultActorRight && heroRight >= defaultActorLeft && rowsOverlap
if (isOverlapping || Math.max(Math.abs(ax - hx), Math.abs(ay - hy)) <= 2) {
const wantedSide = Math.sign(ax - hx) || 1
const rightGap = heroW - 1 - playerAnchor + foeAnchor + 1
const leftGap = playerAnchor + (actorW - 1 - foeAnchor) + 1
const right = hx + rightGap
const left = hx - leftGap
const minX = camX + foeAnchor
const maxX = camX + cols - 1 - Math.floor(actorW / 2)
const preferred = wantedSide > 0 ? right : left
const alternate = wantedSide > 0 ? left : right
drawX = preferred >= minX && preferred <= maxX ? preferred : clamp(alternate, minX, maxX)
}
const left = drawX - Math.floor(actorW / 2)
const anchorY = Number.isFinite(Number(a.anchorY))
? Math.round(Number(a.anchorY))
: Math.floor(actorSprite.length / 2)
const top = drawY - clamp(anchorY, 0, actorSprite.length - 1)
const footprint = []
for (let sy = 0; sy < actorSprite.length; sy++) {
const line = ascii(actorSprite[sy]).padEnd(actorW)
const first = line.search(/\S/)
if (first === -1) continue
let last = line.length - 1
while (last > first && line[last] === ' ') last--
for (let sx = first; sx <= last; sx++) {
if (line[sx] !== ' ') footprint.push(top + sy + ',' + (left + sx))
}
}
if (footprint.some((cell) => occupied.has(cell))) {
const centre = drawY + ',' + drawX
if (!occupied.has(centre)) {
over.set(centre, ascii(a.glyph || '?')[0] || '?')
actorColors.set(centre, a.color || COLOR.foe)
occupied.add(centre)
}
continue
}
for (let sy = 0; sy < actorSprite.length; sy++) {
const line = ascii(actorSprite[sy]).padEnd(actorW)
const first = line.search(/\S/)
if (first === -1) continue
let last = line.length - 1
while (last > first && line[last] === ' ') last--
for (let sx = first; sx <= last; sx++) {
if (line[sx] === ' ') continue
const cell = top + sy + ',' + (left + sx)
over.set(cell, line[sx])
actorColors.set(cell, a.color || COLOR.foe)
occupied.add(cell)
}
}
}
const heroCells = new Set()
const sprite = Array.isArray(hero.sprite) && hero.sprite.length ? hero.sprite : null
if (sprite) {
const spriteW = sprite.reduce((widest, line) => Math.max(widest, ascii(line).length), 1)
const left = hx - clamp(HERO_ANCHOR_X, 0, spriteW - 1)
// World coordinates are feet cells. Keeping the whole body above that
// cell prevents a mover beside a wall from painting through its next row.
const heroAnchorY = Number.isFinite(Number(hero.anchorY))
? Math.round(Number(hero.anchorY))
: sprite.length - 1
const top = hy - clamp(heroAnchorY, 0, sprite.length - 1)
for (let sy = 0; sy < sprite.length; sy++) {
const line = ascii(sprite[sy]).padEnd(spriteW)
const first = line.search(/\S/)
if (first === -1) continue
let last = line.length - 1
while (last > first && line[last] === ' ') last--
for (let sx = first; sx <= last; sx++) {
if (line[sx] === ' ') continue
const cell = top + sy + ',' + (left + sx)
over.set(cell, line[sx])
heroCells.add(cell)
}
}
} else {
const cell = hy + ',' + hx
over.set(cell, ascii(hero.glyph || '@')[0] || '@')
heroCells.add(cell)
}
const out = []
for (let r = 0; r < h; r++) {