-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp-progress.js
More file actions
668 lines (620 loc) · 38.7 KB
/
Copy pathapp-progress.js
File metadata and controls
668 lines (620 loc) · 38.7 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
// ============================================================
// LVL-2/6 · Вкладка «Развитие» (#tab-progress) и экран «Об умении»
// (#screen-featureinfo)
//
// Сборка В2+ из мокапа tests/style-progress-mockups.html: класс — строка с
// раскрытием, поэтому высота не растёт с уровнем. Ни одной коробки:
// строки, действия и ромб раскрытия переиспользуют рецепт «Здоровье и Бой»
// (.hp-row* / .hp-act / .disc-diamond, v3.83.0), своего тут только шапка,
// метки секций и строка умения.
//
// Экран отвечает на жалобу подписчика: уровень КЛАССА и уровень ПЕРСОНАЖА
// разведены и подписаны словами, статус подкласса сказан прямо («архетип
// с 3 ур.»), а не выводится из суммарного уровня.
// ============================================================
// Аргумент inline-обработчика: сначала экранируем для JS, потом для HTML —
// парсер декодирует ' до разбора кода, и апостроф в имени умения иначе
// закрыл бы строку.
function _pgArg(s) {
return escapeHtml(String(s === null || s === undefined ? "" : s).replace(/\\/g, "\\\\").replace(/'/g, "\\'"));
}
/** Классы персонажа единым списком: [{cls, level, sub, die, idx}] */
function _pgClassList(char) {
var out = [];
if (!char) return out;
var die = function(cls, own) {
return own || ((typeof CLASS_HIT_DICE !== "undefined" && CLASS_HIT_DICE[cls]) || 8);
};
if (char.classes && char.classes.length) {
char.classes.forEach(function(c, i) {
if (!c || !c.class) return;
out.push({ cls: c.class, level: c.level || 0, sub: c.subclass || "", die: die(c.class, c.hitDie), idx: i });
});
} else if (char.class) {
out.push({ cls: char.class, level: char.level || 1, sub: char.subclass || "", die: die(char.class), idx: 0 });
}
return out;
}
// ── Кирпичи строк ───────────────────────────────────────────
function _pgDisc(name, meta, body, open, opts) {
opts = opts || {};
// mute приглушает только ИМЯ строки. Знак трогать нельзя: пустой ромб по
// легенде (Справка → «Условные знаки») значит «продолжения нет», а строка
// здесь как раз раскрывается.
return '<div class="hp-row' + (open ? " is-open" : "") + '"' + (opts.attr || "") + ' onclick="hpToggleRow(this)">' +
'<span class="disc-diamond"></span>' +
'<span class="hp-row-name' + (opts.mute ? " pg-name--mute" : "") + '">' + name + '</span>' +
(meta ? '<span class="hp-row-meta' + (opts.warn ? " pg-meta--warn" : "") + '">' + meta + '</span>' : "") +
'</div><div class="hp-row-body' + (open ? " is-open" : "") + '">' + body + '</div>';
}
function _pgStatic(name, meta) {
return '<div class="hp-row hp-row--static"><span class="hp-row-name">' + name + '</span>' +
(meta ? '<span class="hp-row-meta">' + meta + '</span>' : "") + '</div>';
}
/** Строка «что осталось сделать»: текст слева, текстовое действие справа */
function _pgAttn(text, act, onclick) {
return '<div class="hp-row hp-row--static"><span class="pg-attn-n">' + text + '</span>' +
'<button type="button" class="hp-act pg-attn-act" onclick="' + onclick + '">' + act + '</button></div>';
}
function _pgFeat(cls, sub, level, name, isNew) {
return '<div class="pg-feat' + (isNew ? " pg-feat--new" : "") + '" onclick="openFeatureInfo(\'' +
_pgArg(cls) + '\', \'' + _pgArg(sub) + '\', ' + level + ', \'' + _pgArg(name) + '\')">' +
'<span class="pg-feat-lv">' + level + ' ур.</span>' +
'<span class="pg-feat-n">' + escapeHtml(name) + (sub ? ' <u>· ' + escapeHtml(sub) + '</u>' : "") + '</span></div>';
}
function _pgActRow(html) { return '<div class="pg-body-acts">' + html + '</div>'; }
// ── Шапка ───────────────────────────────────────────────────
// LVL-3: внутренность шапки нужна и разделу на листе — там обёртка .pg-head
// стоит в разметке (#cd-head), поэтому сборка разведена на две функции.
function _pgHeadInner(char, list) {
var label = list.length
? list.map(function(e) { return escapeHtml(e.cls) + (list.length > 1 ? " " + e.level : ""); }).join(' <i>/</i> ')
: "Класс не выбран";
var lvl = char.level || 1;
var prof = (typeof getProficiencyBonus === "function") ? getProficiencyBonus(lvl) : 2;
var meta = lvl + ' уровень <span class="hp-dot">·</span> мастерство <b>+' + prof + "</b>";
var dice = list.map(function(e) { return e.level + "к" + e.die; }).join(" + ");
if (dice) meta += ' <span class="hp-dot">·</span> ' + dice;
return '<div class="pg-head-cls">' + label + '</div>' +
'<div class="pg-head-meta">' + meta + '</div>';
}
function _pgHead(char, list) {
return '<div class="pg-head">' + _pgHeadInner(char, list) + '</div>';
}
// Раскрытие для новичка: главное различие всего раздела — уровень персонажа
// против уровня класса (PHB 2014, «Мультиклассирование», стр. 163–164).
function _pgAboutRow() {
var body =
"<p><b>Уровень персонажа</b> — сумма уровней всех классов. От него считается бонус мастерства, " +
"и он один на все классы.</p>" +
"<p><b>Уровень класса</b> — сколько уровней взято именно в этом классе. От него зависят умения, " +
"заряды и уровень, на котором открывается подкласс.</p>" +
"<p>Кость хитов у каждого класса своя, поэтому хиты растут той костью, чей класс вы повышаете. " +
"Прибавка — среднее по кости (у к10 это 6) плюс модификатор Телосложения; кидать кубик " +
"не обязательно, среднее по книге законно.</p>" +
_pgActRow('<button type="button" class="hp-act" onclick="openHelp(\'progress\')">Подробно в справке →</button>');
return _pgDisc("Что это значит", "", body, false, { mute: true });
}
// LVL-4: бонус мастерства — величина, которую мультикласс путает чаще всего:
// он считается от уровня ПЕРСОНАЖА и второй раз за второй класс не даётся.
function _pgProfRow(char) {
var lvl = char.level || 1;
var prof = (typeof getProficiencyBonus === "function") ? getProficiencyBonus(lvl) : 2;
var next = 0, at = 0, steps = [5, 9, 13, 17];
for (var i = 0; i < steps.length; i++) {
if (lvl < steps[i]) { at = steps[i]; next = prof + 1; break; }
}
var body =
"<p>Один на все классы и растёт по уровню персонажа: +2 на 1–4, +3 на 5–8, +4 на 9–12, " +
"+5 на 13–16, +6 на 17–20. Взяв второй класс, второй бонус мастерства вы не получаете.</p>" +
"<p>Прибавляется к броскам, которыми персонаж владеет: атаки, спасброски классов, навыки " +
"и инструменты. При компетентности он удваивается.</p>" +
(at ? "<p>Следующий рост — на " + at + " уровне персонажа, до +" + next + ".</p>" : "");
return _pgDisc("Бонус мастерства", "<b>+" + prof + "</b>", body, false, { mute: true });
}
// LVL-4: расписание АСИ у каждого класса своё и считается по уровню КЛАССА —
// именно здесь мультикласс обещает лишние увеличения, если считать по сумме.
function _pgAsiRow(char, list) {
if (!list.length) return "";
var table = (typeof ASI_LEVELS !== "undefined") ? ASI_LEVELS : null;
if (!table) return "";
var lines = list.map(function(e) {
var sched = table[e.cls] || table["default"] || [];
var got = sched.filter(function(l) { return e.level >= l; }).length;
return "<p><b>" + escapeHtml(e.cls) + "</b> — " + sched.join(", ") +
" уровни класса; заработано " + got + " из " + sched.length + ".</p>";
}).join("");
var earned = (typeof charAsiSlots === "function") ? charAsiSlots(char).length : 0;
var used = 0;
var map = (char.asiUsed && typeof char.asiUsed === "object" && !Array.isArray(char.asiUsed)) ? char.asiUsed : {};
Object.keys(map).forEach(function(k) { if (Array.isArray(map[k])) used += map[k].length; });
var body =
"<p>Каждое увеличение — это +2 к одной характеристике, +1 к двум разным либо черта вместо " +
"прибавки. Выше 20 характеристику поднять нельзя.</p>" + lines +
"<p>Уровни считаются по классу, а не по сумме: Воин 3 / Плут 2 увеличения «на 4 уровне» не получает.</p>";
var meta = earned ? "<b>" + Math.min(used, earned) + "</b> <i>/ " + earned + "</i> использовано" : "пока нет";
return _pgDisc("Увеличение характеристик", meta, body, false, { mute: true });
}
// Строка опыта — только при char.exp > 0: партии на вехах порогов не ведут.
function _pgXpRow(char) {
var exp = parseInt(char.exp, 10) || 0;
if (exp <= 0 || typeof charXpNext !== "function") return "";
var x = charXpNext(char);
if (!x.level) return _pgStatic("Опыт", "<b>" + exp + "</b> <i>· порогов больше нет</i>");
var meta = "<b>" + x.have + '</b> <i>/ ' + x.need + '</i> <span class="hp-dot">·</span> ' +
(x.canLevel ? '<span class="pg-meta--warn">можно повысить</span>' : "до " + x.level + " уровня " + x.left);
return _pgStatic("Опыт", meta);
}
// Ячейки заклинаний. Самое непонятное место мультикласса: уровни классов
// складываются в общий пул (PHB стр. 164), а не дают по своей строке таблицы.
function _pgSlotRows(char) {
if (typeof charCasterLevel !== "function") return "";
var cl = charCasterLevel(char);
if (!cl.casters.length) return "";
var out = "";
// Общий пул — только когда «Использование заклинаний» у двух и более классов;
// пакт-магия Колдуна в этот счёт не входит (PHB стр. 164).
var casting = cl.casters.filter(function(c) { return c.type !== "pact"; });
var multi = casting.length > 1;
var slots = (casting.length && typeof getMulticlassSpellSlots === "function") ? getMulticlassSpellSlots(char) : [];
var parts = [];
for (var i = 1; i < slots.length; i++) {
if (slots[i] > 0) parts.push(i + " круг — <b>" + slots[i] + "</b>");
}
// Ячеек нет вовсе (Паладин 1) или весь колдовской ресурс — пакт-магия:
// строки «Ячейки заклинаний» тогда не должно быть, у Колдуна своя ниже.
if (parts.length) {
// Подпись: при одном заклинателе — его класс и его уровень; производное
// «N ур.» из общего пула тут показывать нельзя, такого числа у книги нет.
var meta = multi
? "как заклинатель " + cl.level + " ур."
: escapeHtml(casting[0].cls) + " " + casting[0].level + " ур.";
var body = "<p>" + parts.join(' <span class="hp-dot">·</span> ') + "</p>";
if (multi) {
body += "<p>Уровни всех классов-заклинателей складываются в один общий пул ячеек: полные заклинатели " +
"целиком, паладин и следопыт — половиной, мистический рыцарь и мистический ловкач — третью.</p>" +
"<p>Заклинания при этом готовятся раздельно, каждым классом по своему уровню. Ячейка общая — " +
"потратить её можно на любое подготовленное заклинание.</p>";
}
out += _pgDisc("Ячейки заклинаний", meta, body, false, { attr: ' data-pg-row="slots"' });
}
if (cl.pact) {
out += _pgDisc("Ячейки договора", escapeHtml(cl.pact.cls) + " " + cl.pact.level + " ур.",
"<p>Ячейки договора Колдуна не смешиваются с общим пулом и восстанавливаются на коротком отдыхе. " +
"Приложение считает их отдельно — они живут на вкладке «Заклинания».</p>", false, { mute: true });
}
return out;
}
// ── «Осталось выбрать» ──────────────────────────────────────
// Секции нет вовсе, когда выбирать нечего — ноль пустых состояний.
function _pgAttention(char) {
var rows = [];
var earned = (typeof charAsiSlots === "function") ? charAsiSlots(char) : [];
var used = (char.asiUsed && typeof char.asiUsed === "object" && !Array.isArray(char.asiUsed)) ? char.asiUsed : {};
earned.forEach(function(slot) {
var u = used[slot.cls];
if (Array.isArray(u) && u.indexOf(slot.level) !== -1) return;
rows.push(_pgAttn("Увеличение характеристик <u>· " + escapeHtml(slot.cls) + " " + slot.level + " ур.</u>",
"Выбрать →", "openASIModalForLevel(" + slot.level + ", '" + _pgArg(slot.cls) + "')"));
});
var pending = (typeof charSubclassPending === "function") ? charSubclassPending(char) : [];
pending.forEach(function(p) {
rows.push(_pgAttn("Подкласс <u>· " + escapeHtml(p.cls) + " " + p.at + " ур.</u>",
"Выбрать →", "pgFocusSubclass('" + _pgArg(p.cls) + "')"));
});
if (typeof ccGetAllChoicesFor === "function") {
ccGetAllChoicesFor(char).forEach(function(it) {
if (it.isComplete || !it.choice) return;
rows.push(_pgAttn(escapeHtml(it.choice.name || it.choice.id) +
" <u>· " + escapeHtml(it.className) + " " + it.classLevel + " ур.</u>",
"Выбрать →", "openClassChoiceModal('" + _pgArg(it.className) + "', '" + _pgArg(it.choice.id) + "')"));
});
}
if (!rows.length) return "";
return '<div class="pg-grp" data-pg-grp="attn">Осталось выбрать</div><div class="hp-rows">' + rows.join("") + "</div>";
}
// ── «Классы» ────────────────────────────────────────────────
/** Класс, выросший последним, — по снимку отката, а не по памяти раскрытия
* (память раскрытого состояния в проекте отвергнута на DISC). */
function _pgGrownLast(char, list) {
var snap = char._prevLevelSnapshot;
if (!snap) return list.length === 1 ? list[0].cls : "";
var before = {};
((snap.classes && snap.classes.length) ? snap.classes : (snap.class ? [{ class: snap.class, level: snap.level }] : []))
.forEach(function(c) { if (c && c.class) before[c.class] = c.level || 0; });
var grown = "";
list.forEach(function(e) {
if (e.level > (before[e.cls] || 0)) grown = e.cls;
});
return grown;
}
function _pgClassRow(char, e, open) {
var at = (typeof SUBCLASS_LEVEL !== "undefined") ? SUBCLASS_LEVEL[e.cls] : 0;
var meta = e.level + " ур.", warn = false;
if (e.sub) {
meta += ' <span class="hp-dot">·</span> ' + escapeHtml(e.sub);
} else if (at && e.level >= at) {
meta += ' <span class="hp-dot">·</span> подкласс не выбран';
warn = true;
} else if (at) {
meta += ' <span class="hp-dot">·</span> подкласс с ' + at + " ур.";
}
var body = "";
var cf = (typeof CLASS_FEATURES !== "undefined") ? CLASS_FEATURES[e.cls] : null;
var sf = (typeof SUBCLASS_FEATURES !== "undefined" && e.sub) ? SUBCLASS_FEATURES[e.sub] : null;
for (var l = 1; l <= e.level; l++) {
var isNew = (l === e.level);
if (cf && cf[l]) cf[l].forEach(function(f) { body += _pgFeat(e.cls, "", l, f.name, isNew); });
if (sf && sf[l]) sf[l].forEach(function(f) { body += _pgFeat(e.cls, e.sub, l, f.name, isNew); });
}
if (!body) body = '<p class="hp-row-hint">Умений этого класса в справочнике пока нет.</p>';
if (!e.sub && at) {
if (e.level >= at) {
var opts = '<option value="">Выберите подкласс</option>';
var all = (typeof SUBCLASSES !== "undefined" && SUBCLASSES[e.cls]) ? SUBCLASSES[e.cls] : [];
all.forEach(function(s) {
var src = (typeof subclassSourceShort === "function") ? subclassSourceShort(s) : "";
opts += '<option value="' + escapeHtml(s) + '">' + escapeHtml(src ? s + " · " + src : s) + "</option>";
});
body += '<select class="field flat-field pg-sub-select" onchange="pgSetSubclass(' + e.idx + ', this.value)">' +
opts + "</select>";
} else {
body += '<p class="hp-row-hint">Подкласс выбирается на ' + at + " уровне класса — сейчас взято " + e.level +
". Суммарный уровень персонажа (" + (char.level || 0) + ") на это не влияет.</p>";
}
}
body += _pgActRow('<button type="button" class="hp-act" onclick="openClassPlan(\'' + _pgArg(e.cls) + '\')">План класса 1–20 →</button>');
return _pgDisc(escapeHtml(e.cls), meta, body, open, { warn: warn, attr: ' data-pg-cls="' + escapeHtml(e.cls) + '"' });
}
function _pgClasses(char, list) {
var rows;
if (!list.length) {
rows = _pgStatic("Класс не выбран", "выберите класс на листе");
} else {
var grown = _pgGrownLast(char, list);
rows = list.map(function(e) {
return _pgClassRow(char, e, list.length === 1 || e.cls === grown);
}).join("");
}
return '<div class="pg-grp" data-pg-grp="classes">Классы</div><div class="hp-rows">' + rows + "</div>";
}
// ── «Дальше» ────────────────────────────────────────────────
// Что даст следующий уровень — ДО нажатия «Повысить»: сейчас это видно только
// начав повышение и увидев превью.
function _pgNext(char, list) {
if (!list.length) return "";
var total = char.level || 0;
if (total >= 20) return "";
var conMod = (typeof getMod === "function" && char.stats) ? getMod(char.stats.con) : 0;
var rows = "";
list.forEach(function(e) {
if (e.level >= 20) return;
var nl = e.level + 1;
var names = [], body = "";
var cf = (typeof CLASS_FEATURES !== "undefined") ? CLASS_FEATURES[e.cls] : null;
var sf = (typeof SUBCLASS_FEATURES !== "undefined" && e.sub) ? SUBCLASS_FEATURES[e.sub] : null;
var add = function(f) {
names.push(f.name);
body += "<p><b>" + escapeHtml(f.name) + "</b> — " + escapeHtml(f.desc || "") + "</p>";
};
if (cf && cf[nl]) cf[nl].forEach(add);
if (sf && sf[nl]) sf[nl].forEach(add);
// Умение подкласса стоит в таблице класса своим именем («Архетип плута»),
// поэтому вторым чипом его не дублируем — только поясняем в раскрытии.
var at = (typeof SUBCLASS_LEVEL !== "undefined") ? SUBCLASS_LEVEL[e.cls] : 0;
if (at === nl && !e.sub) {
body += "<p>На этом уровне класса впервые выбирается подкласс.</p>";
}
var profNow = (typeof getProficiencyBonus === "function") ? getProficiencyBonus(total) : 2;
var profNext = (typeof getProficiencyBonus === "function") ? getProficiencyBonus(total + 1) : 2;
if (profNext > profNow) {
names.push("мастерство +" + profNext);
body += "<p><b>Бонус мастерства</b> — вырастет до +" + profNext + ": он считается от уровня персонажа и общий для всех классов.</p>";
}
var avg = Math.floor(e.die / 2) + 1;
body += '<p class="hp-row-hint">Хиты: +1к' + e.die + " (в среднем " + avg + ") + модификатор ТЕЛ (" +
(conMod >= 0 ? "+" : "") + conMod + "). Приложение прибавляет среднее — по книге это законный вариант, " +
"кубик кидать не обязательно.</p>";
rows += _pgDisc(escapeHtml(e.cls) + " " + nl, names.length ? escapeHtml(names.join(", ")) : "без новых умений", body, false);
});
if (typeof checkMulticlassPrereqs === "function") {
var ok = _pgAvailableClasses(char, list);
var nbody = ok.length
? "<p>Требования выполнены: " + escapeHtml(ok.join(", ")) + ".</p>"
: '<p class="hp-row-hint">Ни один класс сейчас недоступен: для входа нужна характеристика 13 и выше ' +
"(PHB, «Мультиклассирование»).</p>";
nbody += "<p>Новый класс начинается с 1 уровня класса: умения 1-го уровня, владения по укороченному списку " +
"мультикласса и своя кость хитов.</p>";
nbody += _pgActRow('<button type="button" class="hp-act" onclick="pgAddClass()">Добавить класс →</button>');
rows += _pgDisc("Новый класс", ok.length ? "доступно " + ok.length : "требования не выполнены", nbody, false, { mute: true });
}
return rows ? '<div class="pg-grp" data-pg-grp="next">Дальше</div><div class="hp-rows">' + rows + "</div>" : "";
}
/** Классы, доступные для взятия: ещё не взятые и с выполненными требованиями
* (PHB, «Мультиклассирование» — характеристика 13 на вход и на выход). */
function _pgAvailableClasses(char, list) {
var out = [];
if (typeof checkMulticlassPrereqs !== "function") return out;
var have = (list || []).map(function(e) { return e.cls; });
["Варвар", "Бард", "Воин", "Волшебник", "Друид", "Жрец", "Колдун", "Монах", "Паладин", "Плут", "Следопыт", "Чародей"]
.forEach(function(c) {
if (have.indexOf(c) !== -1) return;
var chk = checkMulticlassPrereqs(char, c);
if (chk && chk.ok) out.push(c);
});
return out;
}
function _pgActions(char, list) {
var acts = '<button type="button" class="hp-act" onclick="pgLevelUp()">Повысить уровень →</button>';
// LVL-7: раньше единственный вход в мультикласс был спрятан в свёрнутой
// строке «Новый класс» внизу — подписчик его попросту не нашёл.
if (list && list.length && (char.level || 0) < 20 && _pgAvailableClasses(char, list).length) {
acts += '<button type="button" class="hp-act" onclick="pgAddClass()">Добавить класс →</button>';
}
if ((char.level || 0) > 1 && char._prevLevelSnapshot) {
acts += '<button type="button" class="hp-act" onclick="pgLevelDown()">Откатить →</button>';
}
if (char.buildId) {
acts += '<button type="button" class="hp-act" onclick="openBuildPlan()">План билда 1–20 →</button>';
}
return '<div class="pg-acts">' + acts + "</div>";
}
// ── Сборка и открытие ───────────────────────────────────────
function _pgBuild(char) {
var list = _pgClassList(char);
var top = _pgXpRow(char) + _pgAboutRow() + _pgProfRow(char) + _pgAsiRow(char, list) + _pgSlotRows(char);
return _pgHead(char, list) +
'<div class="hp-rows">' + top + "</div>" +
_pgAttention(char) +
_pgClasses(char, list) +
_pgNext(char, list) +
_pgActions(char, list);
}
/** Наполнить вкладку. Зовётся из switchTab при каждом заходе — тап по кнопке
* вкладки идёт мимо openProgress(), поэтому сборка живёт здесь. */
function openProgressTab() {
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
if (!char) return;
if (typeof migrateToMulticlass === "function") migrateToMulticlass(char);
var body = $("pg-body");
if (body) body.innerHTML = _pgBuild(char);
}
/** Вход «Развитие →» с листа и из строк-сводок. Тур стартует сам —
* через maybeStartTabTour() в конце switchTab. */
function openProgress() {
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
if (!char) {
if (typeof showToast === "function") showToast("Сначала выберите персонажа", "warn");
return;
}
if (typeof _closeOpenModals === "function") _closeOpenModals();
if (typeof switchTab === "function") switchTab("progress", null);
}
/** Открыта ли вкладка «Развитие» прямо сейчас. */
function pgTabActive() {
var tab = $("tab-progress");
return !!(tab && tab.classList.contains("active"));
}
/** Перерисовать, если вкладка открыта. Зовётся из updateClassFeatures() — она
* идёт следом за повышением уровня, откатом, АСИ и классовыми выборами. */
function pgRefresh() {
if (!pgTabActive()) return;
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
var body = $("pg-body");
if (char && body) body.innerHTML = _pgBuild(char);
}
// ── Действия экрана ─────────────────────────────────────────
function pgSetSubclass(idx, name) {
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
if (!char || !name || !char.classes || !char.classes[idx]) return;
char.classes[idx].subclass = name;
if (typeof syncClassFields === "function") syncClassFields(char);
if (typeof saveToLocal === "function") saveToLocal();
if (typeof loadCharacter === "function" && currentId) loadCharacter(currentId);
if (typeof updateClassFeatures === "function") updateClassFeatures();
if (typeof showToast === "function") showToast("Подкласс: " + name, "success");
openProgress();
}
/** Из строки «Осталось выбрать» — раскрыть класс и подвести к выбору подкласса.
* LVL-3: та же строка есть на листе, поэтому вкладка сначала открывается. */
function pgFocusSubclass(cls) {
if (!pgTabActive()) openProgress();
var body = $("pg-body");
if (!body) return;
var row = body.querySelector('.hp-row[data-pg-cls="' + cls + '"]');
if (!row) return;
if (typeof hpSetRowOpen === "function") hpSetRowOpen(row, true);
if (row.scrollIntoView) row.scrollIntoView({ block: "center" });
var sel = row.nextElementSibling ? row.nextElementSibling.querySelector(".pg-sub-select") : null;
if (sel && sel.focus) sel.focus();
}
// Повышение и откат общие с листом и заканчиваются на нём: confirmLevelUp зовёт
// loadCharacter, а та переключает вкладку на «Лист». Пришедшего с «Развития»
// возвращаем назад.
var _pgFromProgress = false;
function pgLevelUp() {
_pgFromProgress = true;
if (typeof openLevelUpModal === "function") openLevelUpModal();
}
function pgLevelDown() {
_pgFromProgress = true;
if (typeof openLevelDownConfirm === "function") openLevelDownConfirm();
}
/** Зовётся из closeLevelUpModal() — конец и повышения, и отката */
function pgAfterLevelModal() {
if (!_pgFromProgress) return;
_pgFromProgress = false;
if (pgTabActive()) {
pgRefresh();
return;
}
openProgress();
}
/** «Добавить класс →» — сразу к блоку нового класса в модалке повышения */
function pgAddClass() {
if (typeof openLevelUpModal !== "function") return;
_pgFromProgress = true;
openLevelUpModal();
if (typeof openMulticlassNewClass === "function") openMulticlassNewClass();
}
// ── LVL-3 · Раздел «Класс и развитие» на листе ──────────────
// Сводка и «Осталось выбрать» — те же кирпичи, что на экране: список умений
// с листа уехал, здесь остаются только сводка, ресурсы (рендерит app-ui.js),
// невыбранное ИМЕНЕМ и текстовые действия.
function renderClassDev() {
var head = $("cd-head");
if (!head) return;
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
if (!char) return;
if (typeof migrateToMulticlass === "function") migrateToMulticlass(char);
var list = _pgClassList(char);
head.innerHTML = _pgHeadInner(char, list);
var attn = $("cd-attn");
if (attn) attn.innerHTML = _pgAttention(char);
// LVL-4: то же объяснение, что на экране, но короче — новичку хватает его,
// не уходя с листа; за подробностями строка ведёт в справку.
var about = $("cd-about");
if (about) about.innerHTML = _pgSheetAboutRow(char, list);
syncClassFieldUI(char);
}
/** Раскрытие «что это значит» в разделе листа: три величины, которые
* мультикласс путает чаще всего, — уровень, бонус мастерства, кость хитов. */
function _pgSheetAboutRow(char, list) {
var lvl = char.level || 1;
var prof = (typeof getProficiencyBonus === "function") ? getProficiencyBonus(lvl) : 2;
var multi = list.length > 1;
var body =
"<p><b>Уровень персонажа</b> — " + lvl + ", это сумма уровней всех классов. От него считается " +
"бонус мастерства <b>+" + prof + "</b>, один на все классы.</p>" +
"<p><b>Уровень класса</b> — сколько уровней взято в этом классе. От него зависят умения, " +
"заряды и уровень, на котором открывается подкласс" +
(multi ? ": " + list.map(function(e) { return escapeHtml(e.cls) + " " + e.level; }).join(", ") : "") + ".</p>" +
"<p>Кость хитов у каждого класса своя, поэтому хиты растут костью того класса, чей уровень вы повышаете.</p>" +
_pgActRow('<button type="button" class="hp-act" onclick="openProgress()">Развитие →</button>' +
'<button type="button" class="hp-act" onclick="openHelp(\'progress\')">Подробно в справке →</button>');
return _pgDisc("Что это значит", "", body, false, { mute: true });
}
/** Подкласс по каждому классу строками: выбранный, ожидающий выбора и ещё
* не открытый по уровню класса. */
function _pgSubclassRows(list) {
var out = "";
list.forEach(function(e) {
var at = (typeof SUBCLASS_LEVEL !== "undefined" && SUBCLASS_LEVEL[e.cls]) || 3;
var val, act, go;
if (e.sub) {
val = e.sub; act = "Развитие →"; go = "openProgress()";
} else if (e.level >= at) {
val = "не выбран"; act = "Выбрать →"; go = "pgFocusSubclass('" + _pgArg(e.cls) + "')";
} else {
val = "с " + at + " уровня класса"; act = ""; go = "openProgress()";
}
out += '<div class="cd-class-mc" onclick="' + go + '">' +
"<span>" + escapeHtml(e.cls) + " — " + escapeHtml(val) + "</span>" +
(act ? '<span class="cd-class-mc-act">' + act + "</span>" : "") +
"</div>";
});
return out;
}
/** Поля «Класс», «Уровень» и «Подкласс» на листе: каждое умеет только первый
* класс (а уровень — и вовсе сумма), поэтому у мультикласса они прячутся, и на
* их месте встают строки со всеми классами и входом в «Развитие».
* Сами поля остаются в разметке — их значения читают updateChar() и calcStats(),
* поэтому скрывать можно только через display, но не disabled. */
function syncClassFieldUI(char) {
var sel = $("char-class"), mc = $("char-class-mc"), lbl = $("char-class-mc-label");
if (!sel || !mc) return;
var multi = !!(char && char.classes && char.classes.length > 1);
sel.style.display = multi ? "none" : "";
mc.style.display = multi ? "" : "none";
if (multi && lbl && typeof getClassLabel === "function") lbl.textContent = getClassLabel(char);
var list = multi ? _pgClassList(char) : [];
var lvlInp = $("char-level"), lvlMc = $("char-level-mc");
if (lvlInp && lvlMc) {
lvlInp.style.display = multi ? "none" : "";
lvlMc.style.display = multi ? "" : "none";
if (multi) {
lvlMc.innerHTML = '<span class="cd-level-mc-val">' + (char.level || 1) + "</span>" +
'<span class="cd-level-mc-hint">сумма классов</span>';
}
}
var subSel = $("char-subclass"), subRec = $("char-subclass-rec"), subMc = $("char-subclass-mc");
if (subSel && subMc) {
subSel.style.display = multi ? "none" : "";
subMc.style.display = multi ? "" : "none";
if (multi) {
if (subRec) subRec.style.display = "none";
subMc.innerHTML = _pgSubclassRows(list);
}
}
}
// ── Экран «Об умении» ───────────────────────────────────────
function openFeatureInfo(cls, sub, level, name) {
var found = null, src = "";
var pick = function(table, label) {
if (found || !table || !table[level]) return;
table[level].forEach(function(f) {
if (!found && f.name === name) { found = f; src = label; }
});
};
if (sub && typeof SUBCLASS_FEATURES !== "undefined") pick(SUBCLASS_FEATURES[sub], sub);
if (typeof CLASS_FEATURES !== "undefined") pick(CLASS_FEATURES[cls], cls);
if (!found) {
if (typeof showToast === "function") showToast("Нет описания этого умения", "warn");
return;
}
var title = $("fi-title-h");
if (title) title.textContent = found.name;
var srcLabel = escapeHtml(src) + " <span class=\"hp-dot\">·</span> " + level + " ур.";
if (sub && src === sub && typeof subclassSourceShort === "function") {
var s = subclassSourceShort(sub);
if (s) srcLabel += ' <span class="hp-dot">·</span> ' + escapeHtml(s);
}
// LVL-4: описание проходит через глоссарий — термины становятся нажимаемыми
// с поповером, а найденные в тексте объясняются ещё и блоком ниже, чтобы
// новичку не нужно было догадываться, что слово можно нажать.
var char = (typeof getCurrentChar === "function") ? getCurrentChar() : null;
var ed = (char && char.edition === "2024") ? "2024" : "2014";
var descEsc = escapeHtml(found.desc || "");
var descHtml = descEsc;
if (typeof glossarizeHtml === "function") {
try { descHtml = glossarizeHtml(descEsc, {}, ed); } catch (e) { descHtml = descEsc; }
}
var html = '<p class="ai-mine">' + srcLabel + "</p>" +
'<p class="ai-lead">' + descHtml + "</p>" + _fiRuleNotes(found.desc || "", ed);
var body = $("fi-body");
if (body) body.innerHTML = html;
if (typeof _glossBindOnce === "function") _glossBindOnce();
showScreen("featureinfo");
}
/** Блок «По правилам»: до трёх терминов глоссария, встреченных в описании
* умения, с их определениями. Термин ищется по границе слова — «ки» внутри
* «броски» сработать не должен. */
function _fiRuleNotes(desc, ed) {
var list = (typeof window !== "undefined" && Array.isArray(window.GLOSSARY)) ? window.GLOSSARY.slice() : [];
if (ed === "2024" && typeof window !== "undefined" && Array.isArray(window.GLOSSARY_2024)) {
list = window.GLOSSARY_2024.concat(list);
}
if (!list.length || !desc) return "";
var text = String(desc).toLowerCase();
var hits = [], seen = {};
list.forEach(function(entry) {
if (hits.length >= 3 || !entry || !entry.terms || seen[entry.term]) return;
var found = entry.terms.some(function(form) {
var f = String(form).toLowerCase();
var at = text.indexOf(f);
while (at !== -1) {
var before = at === 0 ? " " : text.charAt(at - 1);
var after = text.charAt(at + f.length) || " ";
if (!/[а-яёa-z0-9]/.test(before) && !/[а-яёa-z0-9]/.test(after)) return true;
at = text.indexOf(f, at + 1);
}
return false;
});
if (found) { seen[entry.term] = true; hits.push(entry); }
});
if (!hits.length) return "";
return '<div class="ai-block"><div class="ai-block-title">По правилам ' + ed + "</div>" +
hits.map(function(e) {
return "<p><b>" + escapeHtml(e.term) + "</b> — " + escapeHtml(e.def) + "</p>";
}).join("") + "</div>";
}