azeroth-shell 0.1.2 → 0.3.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.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
// and the loot taken so far. The log is the game's own log (tui.mjs); the meter is phonecraft's DPSMate
|
|
3
3
|
// aggregator fed every tick's events; threat reads the engine's ThreatManager through the session; loot is a
|
|
4
4
|
// session-long tally of what the "You receive" lines handed over. The choice sticks between fights.
|
|
5
|
-
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct } from './text.mjs';
|
|
5
|
+
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, coinText } from './text.mjs';
|
|
6
6
|
import { createDpsMateMeter, partyMeterSnapshot } from '../domain/bots/dpsMate.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { runPaneLines, questPaneLines, bagsPaneLines } from './tuiAutopilot.mjs';
|
|
8
|
+
import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
|
|
8
9
|
|
|
9
10
|
export const FIGHT_PANES = [
|
|
10
11
|
{ id: 'log', label: 'Log' },
|
|
11
12
|
{ id: 'meter', label: 'Meter' },
|
|
12
13
|
{ id: 'threat', label: 'Threat' },
|
|
13
14
|
{ id: 'loot', label: 'Loot' },
|
|
15
|
+
{ id: 'run', label: 'Run' }, // the autopilot's ledger: only once a run exists
|
|
16
|
+
{ id: 'quests', label: 'Quests' }, // during a run: the quests it works on
|
|
17
|
+
{ id: 'bags', label: 'Bags' }, // during a run: the bags, with a cursor, so items can be used while it plays
|
|
14
18
|
];
|
|
19
|
+
const panesOf = (app) => FIGHT_PANES.filter((p) => (p.id === 'run' ? app.auto || app.lastRun : p.id === 'quests' || p.id === 'bags' ? !!app.auto : true));
|
|
15
20
|
// The meter's views, like a classic meter addon's modes. Each ranks the party by its own number.
|
|
16
21
|
const METER_VIEWS = [
|
|
17
22
|
{ id: 'damage', title: 'Damage done', value: (a) => a.damage },
|
|
@@ -26,10 +31,22 @@ const meterView = (app) => METER_VIEWS.find((v) => v.id === app.meterSort) ?? ME
|
|
|
26
31
|
|
|
27
32
|
export const fightPane = (app) => app.fightPane ?? 'log';
|
|
28
33
|
|
|
34
|
+
/** The pane shows in a fight and on the autopilot's between-fight panel, so a run holds the chosen pane
|
|
35
|
+
* instead of dropping to the log after every kill. */
|
|
36
|
+
export const showsPanes = (app) => !app.stack.length && (app.auto ? !app.auto.browsing : !!app.fight);
|
|
37
|
+
|
|
38
|
+
/** What Tab moves to next ("meter", "threat", "loot", "log"), for the hint line. */
|
|
39
|
+
export function nextPaneId(app) {
|
|
40
|
+
const panes = panesOf(app);
|
|
41
|
+
const i = Math.max(0, panes.findIndex((p) => p.id === fightPane(app)));
|
|
42
|
+
return panes[(i + 1) % panes.length].id;
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
/** Tab / Shift-Tab in a fight: the next or previous pane. */
|
|
30
46
|
export function cycleFightPane(app, dir = 1) {
|
|
31
|
-
const
|
|
32
|
-
|
|
47
|
+
const panes = panesOf(app);
|
|
48
|
+
const i = Math.max(0, panes.findIndex((p) => p.id === fightPane(app)));
|
|
49
|
+
app.fightPane = panes[(i + dir + panes.length) % panes.length].id;
|
|
33
50
|
app.render();
|
|
34
51
|
}
|
|
35
52
|
|
|
@@ -67,21 +84,23 @@ export function noteLoot(app, taken = [], copper = 0) {
|
|
|
67
84
|
|
|
68
85
|
/** The rule between the frames and the pane. In a fight it carries the pane tabs. */
|
|
69
86
|
export function fightPaneRule(app, L) {
|
|
70
|
-
if (!app
|
|
87
|
+
if (!showsPanes(app)) return dim('─'.repeat(L.W));
|
|
71
88
|
const cur = fightPane(app);
|
|
72
|
-
const tabs =
|
|
89
|
+
const tabs = panesOf(app).map((p) => (p.id === cur ? inverse(` ${p.label} `) : dim(` ${p.label} `))).join(dim('─'));
|
|
73
90
|
const left = `${dim('──')}${tabs}`;
|
|
74
|
-
const
|
|
91
|
+
const folded = cur === 'log' && app.fight?.folded?.() ? dim(`log folded at ${app.fight.speed}x, - unfolds it `) : '';
|
|
92
|
+
const right = `${folded}${dim('Tab: switch')}${dim('──')}`;
|
|
75
93
|
const mid = Math.max(0, L.W - vis(left) - vis(right));
|
|
76
94
|
return cut(`${left}${dim('─'.repeat(mid))}${right}`, L.W);
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
/** The pane's lines (exactly L.logH of them). */
|
|
80
98
|
export function fightPaneLines(app, L) {
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
if (!showsPanes(app) || fightPane(app) === 'log') return app.logLines(L);
|
|
100
|
+
const ctl = app.fight ?? app.lastFight; // between autopilot fights: the one just over
|
|
83
101
|
const pane = fightPane(app);
|
|
84
|
-
const lines = pane === '
|
|
102
|
+
const lines = pane === 'run' ? runPaneLines(app, L) : pane === 'quests' ? questPaneLines(app, L) : pane === 'bags' ? bagsPaneLines(app, L)
|
|
103
|
+
: pane === 'loot' ? lootLines(app, L) : !ctl ? [dim(' No fight yet.')] : pane === 'meter' ? meterLines(app, ctl, L) : threatLines(app, ctl, L);
|
|
85
104
|
const out = lines.slice(0, L.logH).map((l) => fit(l, L.W));
|
|
86
105
|
while (out.length < L.logH) out.push('');
|
|
87
106
|
return out;
|
|
@@ -211,16 +230,25 @@ function threatLines(app, ctl, L) {
|
|
|
211
230
|
function lootLines(app, L) {
|
|
212
231
|
const t = app.lootTally ?? { rows: [], copper: 0 };
|
|
213
232
|
const n = t.rows.reduce((a, r) => a + r.count, 0);
|
|
214
|
-
const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${
|
|
233
|
+
const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${coinText(t.copper)}` : ''}`)}`];
|
|
215
234
|
if (!t.rows.length && !t.copper) { lines.push(dim(' Nothing looted yet. Whatever you take shows up here.')); return lines; }
|
|
216
235
|
const colW = Math.max(24, Math.floor((L.W - 2) / Math.max(1, Math.min(3, Math.floor(L.W / 30)))));
|
|
217
236
|
const cols = Math.max(1, Math.floor((L.W - 2) / colW));
|
|
218
|
-
|
|
219
|
-
|
|
237
|
+
// With icons each cell is two lines: a 4px icon down its left, the name beside the top and the count and
|
|
238
|
+
// kind beside the bottom.
|
|
239
|
+
const icons = iconsOn() && L.W >= 40;
|
|
240
|
+
const h = icons ? 2 : 1;
|
|
241
|
+
const cells = t.rows.map((r) => {
|
|
242
|
+
if (!icons) return [`${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`];
|
|
243
|
+
const it = app.ds.item(r.item);
|
|
244
|
+
const art = iconArt(it?.icon, 4) ?? iconBlank(4);
|
|
245
|
+
return [`${art[0]} ${app.itemLabel(r.item)}`, `${art[1]} ${dim([r.count > 1 ? `x${r.count}` : '', it?.typeName].filter(Boolean).join(' · '))}`];
|
|
246
|
+
});
|
|
247
|
+
const rowsAvail = Math.floor((L.logH - 1) / h);
|
|
220
248
|
for (let i = 0; i < rowsAvail; i++) {
|
|
221
249
|
const slice = cells.slice(i * cols, i * cols + cols);
|
|
222
250
|
if (!slice.length) break;
|
|
223
|
-
lines.push(` ${slice.map((c) => fit(c, colW - 1)).join(' ')}`);
|
|
251
|
+
for (let k = 0; k < h; k++) lines.push(` ${slice.map((c) => fit(c[k], colW - 1)).join(' ')}`);
|
|
224
252
|
}
|
|
225
253
|
const shown = Math.min(cells.length, rowsAvail * cols);
|
|
226
254
|
if (shown < cells.length) lines[lines.length - 1] = fit(lines[lines.length - 1], L.W - 10) + dim(`+${cells.length - shown} more`);
|
package/src/cli/tuiFloor.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { dirname, join } from 'node:path';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { bold, dim, yellow, green, red, cyan, fit } from './text.mjs';
|
|
23
23
|
import { startFight } from './tuiFight.mjs';
|
|
24
|
-
import { pullStop, restBetween } from './tuiDungeon.mjs';
|
|
24
|
+
import { pullStop, restBetween, readyUp } from './tuiDungeon.mjs';
|
|
25
25
|
import { prePull } from './squad.mjs';
|
|
26
26
|
import { decodeNavMesh, registerNavMeshForMap, navMeshForMap } from '../domain/nav/navmesh.ts';
|
|
27
27
|
import { bakeSlugForInstance } from '../world/bakeSlug.ts';
|
|
@@ -82,9 +82,13 @@ function stampTemplates(app, ents) {
|
|
|
82
82
|
/** The floor for the current run, built on first use and kept on the app while the run lasts. */
|
|
83
83
|
function floorFor(app, def) {
|
|
84
84
|
const run = app.g.state.instance;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// One floor per entry: a hearth or a taxi out never cleared app.floor, so the next run of the dungeon walked the old one.
|
|
86
|
+
const enteredAt = app.g.state.instanceEnteredAtMs ?? null;
|
|
87
|
+
if (app.floor?.dungeonId === run.dungeonId && app.floor.enteredAt === enteredAt) return app.floor;
|
|
88
|
+
// Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before. The kills
|
|
89
|
+
// ride on the run, not the dungeon: a fresh run of the same dungeon stood the last run's dead back up, and an
|
|
90
|
+
// event's body (the Ring of Law's champion, one guid every run) was born dead and paid out unfought.
|
|
91
|
+
const cleared = new Set(run.floorClearedGuids ?? []);
|
|
88
92
|
for (const enc of def.encounters) if (isCleared(run, enc.id)) for (const gd of enc.spawnGuids ?? []) cleared.add(gd);
|
|
89
93
|
const bossEntries = new Set(def.encounters.filter((e) => e.kind === 'boss' && e.creatureEntry > 0).map((e) => e.creatureEntry));
|
|
90
94
|
const world = createContinuousWorld({
|
|
@@ -96,7 +100,7 @@ function floorFor(app, def) {
|
|
|
96
100
|
skipEntry: (entry) => !bossEntries.has(entry) && isTechnicalTriggerCreature(app.ds.creature(entry)),
|
|
97
101
|
});
|
|
98
102
|
stampTemplates(app, world.entities);
|
|
99
|
-
const f = { dungeonId: run.dungeonId, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
103
|
+
const f = { dungeonId: run.dungeonId, enteredAt, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
100
104
|
rng: mulberry32(((app.g.state.rngSeed ?? 1) ^ 0x9e3779b9) >>> 0) };
|
|
101
105
|
f.script = scriptContext(app, f);
|
|
102
106
|
app.floor = f;
|
|
@@ -158,8 +162,13 @@ function markEngaged(f, ents) {
|
|
|
158
162
|
|
|
159
163
|
function remember(app, f, guids) {
|
|
160
164
|
resolveKill(f.world, guids);
|
|
161
|
-
app
|
|
162
|
-
|
|
165
|
+
keepKills(app, f);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The floor's dead onto the run, so a step outside keeps them and a fresh run starts without them. */
|
|
169
|
+
function keepKills(app, f) {
|
|
170
|
+
const run = app.g.state.instance;
|
|
171
|
+
if (run?.dungeonId === f.dungeonId) app.g.state.instance = { ...run, floorClearedGuids: [...f.world.clearedGuids] };
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
/** One floor tick: patrols, the walk, the scripts, then what sees you. Returns true when a pull started. */
|
|
@@ -277,7 +286,7 @@ function engage(app, f, ents) {
|
|
|
277
286
|
if (!live.length) return false;
|
|
278
287
|
const fight = app.fight;
|
|
279
288
|
if (!fight) return fightBodies(app, f, live);
|
|
280
|
-
if (!fight.floor) { f.waiting.push(...live); return false; }
|
|
289
|
+
if (!fight.floor || fight.ended) { f.waiting.push(...live); return false; } // a fight over (its win still on screen) takes nobody in
|
|
281
290
|
let n = 0;
|
|
282
291
|
for (const e of live) {
|
|
283
292
|
if (fight.session.sideOfGuid?.(e.guid)) continue;
|
|
@@ -339,6 +348,7 @@ function fightBodies(app, f, ents) {
|
|
|
339
348
|
/** The event is lost: its summoned bodies go, claimed ones stand down, and it waits a minute before it arms again. */
|
|
340
349
|
function failEvent(app, f) {
|
|
341
350
|
f.script.resetGauntletOnWipe();
|
|
351
|
+
keepKills(app, f); // the event's fallen came off the floor's dead; a rebuilt floor must not bring them back dead
|
|
342
352
|
f.eventHoldUntil = f.runMs + EVENT_HOLD_MS;
|
|
343
353
|
app.say(red('The event fails. Rest (z) before you go again; it waits a minute.'));
|
|
344
354
|
}
|
|
@@ -398,6 +408,8 @@ function reopen(app) {
|
|
|
398
408
|
export function walkTo(app, f, enc) {
|
|
399
409
|
const dest = enc.origin;
|
|
400
410
|
if (!dest) { app.flashMsg(`${enc.name} has no place on the floor.`); return; }
|
|
411
|
+
// Buff and drink before setting off: whatever sees you on the way pulls with no time for it.
|
|
412
|
+
readyUp(app);
|
|
401
413
|
const r = setMoveOrder(f.world, dest, f.def.mapId);
|
|
402
414
|
f.target = enc.id;
|
|
403
415
|
f.walking = true;
|
|
@@ -407,7 +419,7 @@ export function walkTo(app, f, enc) {
|
|
|
407
419
|
|
|
408
420
|
/** P on a stop: an event starts where it stands, anything else is pulled from here. */
|
|
409
421
|
function pullFromHere(app, f, enc) {
|
|
410
|
-
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
422
|
+
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, ready: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
411
423
|
const run = app.g.state.instance;
|
|
412
424
|
const need = requiredFlags(enc).filter((fl) => !hasFlag(run, fl));
|
|
413
425
|
if (need.length) { app.flashMsg(`${enc.name} has not been set off yet (${need.join(', ')}).`); return; }
|
package/src/cli/tuiGear.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The Character tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs the
|
|
2
2
|
// game uses for you out of combat. Per-slot durability and r (repair this one) ride on the slot rows themselves.
|
|
3
|
-
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText } from './text.mjs';
|
|
4
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
3
|
+
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText, coinText } from './text.mjs';
|
|
5
4
|
import { equippedEntry, equippedInstance } from '../domain/items/itemInstance.ts';
|
|
6
5
|
import { gearEnchantDisplayName } from '../domain/professions/gearEnchant.ts';
|
|
7
6
|
import { hasDurability, maxDurability, currentDurability, repairCostSlot } from '../domain/items/durability.ts';
|
|
@@ -49,14 +48,23 @@ export function repairOne(app, slot) {
|
|
|
49
48
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
50
49
|
if (!cost) { app.flashMsg('That piece needs no repair.'); return; }
|
|
51
50
|
const r = app.g.repairSlot(slot, q.vendorEntry ?? undefined);
|
|
52
|
-
app.result(r, r.ok ? `${q.vendorName} repairs it for ${
|
|
51
|
+
app.result(r, r.ok ? `${q.vendorName} repairs it for ${coinText(r.spent)}.` : '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Enter on Repair all, or R on the Character tab: mend everything you wear at the repairer the quote prices against. */
|
|
55
|
+
export function repairAll(app) {
|
|
56
|
+
const q = app.g.repairQuote();
|
|
57
|
+
if (!q.cost) { app.flashMsg('Nothing needs repair.'); return; }
|
|
58
|
+
if (!q.vendorName) { app.flashMsg('Nobody here repairs.'); return; }
|
|
59
|
+
const r = app.g.repairAll(q.vendorEntry ?? undefined);
|
|
60
|
+
app.result(r, r.ok ? `Repaired for ${coinText(r.spent)}.` : '');
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export function slotRepairLines(app, slot) {
|
|
56
64
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
57
65
|
if (!cost) return [];
|
|
58
66
|
const q = app.g.repairQuote();
|
|
59
|
-
return [q.vendorName ? dim(`r: repair this piece for ${
|
|
67
|
+
return [q.vendorName ? dim(`r: repair this piece for ${coinText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${coinText(cost)}.`)];
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
const rangedWeapon = (app) => { const e = equippedEntry(app.ch.equipped.ranged); return e != null ? app.ds.item(e) : undefined; };
|
|
@@ -118,19 +126,14 @@ export function autoConsumablesDialog(app) {
|
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
/** The rows under the paperdoll on the
|
|
129
|
+
/** The rows under the paperdoll on the Character tab. */
|
|
122
130
|
export function gearExtraRows(app) {
|
|
123
131
|
const rows = [H(''), H('Upkeep')];
|
|
124
132
|
const q = app.g.repairQuote();
|
|
125
133
|
rows.push({
|
|
126
|
-
text: `Repair all ${q.cost ?
|
|
127
|
-
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${
|
|
128
|
-
act: (a) =>
|
|
129
|
-
if (!q.cost) { a.flashMsg('Nothing needs repair.'); return; }
|
|
130
|
-
if (!q.vendorName) { a.flashMsg('Nobody here repairs.'); return; }
|
|
131
|
-
const r = a.g.repairAll(q.vendorEntry ?? undefined);
|
|
132
|
-
a.result(r, r.ok ? `Repaired for ${moneyText(r.spent)}.` : '');
|
|
133
|
-
},
|
|
134
|
+
text: `Repair all ${q.cost ? coinText(q.cost) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
|
|
135
|
+
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${coinText(q.cost)}.` : 'Everything you wear is in good shape.', q.vendorName ? dim(`Priced at ${q.vendorName}.`) : dim('A smith or general goods vendor repairs.'), '', dim('Enter: repair all r on a slot: repair one piece')],
|
|
136
|
+
act: (a) => repairAll(a),
|
|
134
137
|
});
|
|
135
138
|
const weapon = rangedWeapon(app);
|
|
136
139
|
const hasAmmo = app.g.state.inventory.stacks.some((s) => isAmmoItem(app.ds.item(s.item)));
|
package/src/cli/tuiLoot.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// keeps the haul in `state.pendingLoot`; Enter takes one row into the bags, a takes everything that fits, Esc
|
|
3
3
|
// closes it (outdoor leftovers are gone then, an opened bag item keeps its remainder). With auto-loot on (the
|
|
4
4
|
// default) fights never hold their loot and this window does not open.
|
|
5
|
-
import { bold, dim, yellow, green, red, qualityPaint } from './text.mjs';
|
|
5
|
+
import { bold, dim, yellow, green, red, qualityPaint, coinText } from './text.mjs';
|
|
6
6
|
import { itemLines } from './tuiViews.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { formatItemName } from '../domain/items/itemDisplayName.ts';
|
|
9
8
|
import { farmNext } from './tuiFight.mjs';
|
|
10
9
|
import { noteLoot } from './tuiFightPanes.mjs';
|
|
@@ -32,7 +31,7 @@ export function takeAll(app) {
|
|
|
32
31
|
if (!app.result(r)) return;
|
|
33
32
|
noteLoot(app, r.taken, r.copper);
|
|
34
33
|
for (const t of r.taken) app.say(` ${yellow('You receive')} ${app.itemLabel(t.item)}${t.count > 1 ? ` x${t.count}` : ''}`);
|
|
35
|
-
if (r.copper) app.say(` ${yellow('You loot')} ${
|
|
34
|
+
if (r.copper) app.say(` ${yellow('You loot')} ${coinText(r.copper)}`);
|
|
36
35
|
if (r.remaining) app.say(red(` ${r.remaining} left in there: your bags are full.`));
|
|
37
36
|
if (r.emptied && app.top()?.id === 'loot') app.pop();
|
|
38
37
|
}
|
|
@@ -69,14 +68,15 @@ export function lootDialog(app) {
|
|
|
69
68
|
const rows = [{ text: `${KIND_LABEL[L.kind] ?? L.kind}: ${L.title}`, header: true }];
|
|
70
69
|
if (L.copper > 0) {
|
|
71
70
|
rows.push({
|
|
72
|
-
text:
|
|
73
|
-
detail: () => [bold('Money'),
|
|
74
|
-
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${
|
|
71
|
+
text: coinText(L.copper),
|
|
72
|
+
detail: () => [bold('Money'), coinText(L.copper)],
|
|
73
|
+
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${coinText(r.copper)}`); if (r.emptied) a2.pop(); } },
|
|
75
74
|
});
|
|
76
75
|
}
|
|
77
76
|
L.rows.forEach((r, i) => {
|
|
78
77
|
rows.push({
|
|
79
78
|
text: `${rowName(a, r)}${r.count > 1 ? ` x${r.count}` : ''}${r.blocked ? red(' bags full') : ''}`,
|
|
79
|
+
icon: a.ds.item(r.item)?.icon, sub: dim(a.ds.item(r.item)?.typeName ?? ''),
|
|
80
80
|
detail: () => itemLines(a, a.ds.item(r.item), r.blocked ? ['', red('No room in your bags for this.')] : []),
|
|
81
81
|
act: (a2) => {
|
|
82
82
|
const res = a2.g.takeLoot(i);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The world map on the Travel tab: phonecraft's combined Azeroth terrain map (the picture every zone's
|
|
2
|
+
// `mapPos` is measured on; scripts/bake-world-map.mjs) drawn in the detail pane with pins for where you
|
|
3
|
+
// stand, the zone under the cursor and the route a journey takes between them. On the road the pin walks its
|
|
4
|
+
// leg. Two packs, like the creature art: src/data/world-map.bin.gz as truecolour half-block cells (two map
|
|
5
|
+
// pixels a row), or src/data/world-map-sixel.bin.br as the terminal's own pixels where it draws sixel
|
|
6
|
+
// (sixel.mjs). No pack, no colour, or no room: no map, never an error.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { gunzipSync, brotliDecompressSync } from 'node:zlib';
|
|
9
|
+
import { useColor, cyan, yellow, dim, vis } from './text.mjs';
|
|
10
|
+
import { sixelImage, sixelOn, cellSize } from './sixel.mjs';
|
|
11
|
+
|
|
12
|
+
const SMALL_URL = new URL('../data/world-map.bin.gz', import.meta.url);
|
|
13
|
+
const SIXEL_URL = new URL('../data/world-map-sixel.bin.br', import.meta.url);
|
|
14
|
+
|
|
15
|
+
// ---- the packs ----------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A pack as { w, h, pal, idx } (a palette index per pixel, every pixel drawn), or null. */
|
|
18
|
+
function parse(buf) {
|
|
19
|
+
if (buf.toString('ascii', 0, 5) !== 'AZMAP' || buf[5] !== 1) return null;
|
|
20
|
+
const w = buf.readUInt16LE(6), h = buf.readUInt16LE(8), palLen = buf[10], palEnd = 11 + palLen * 3;
|
|
21
|
+
const idx = buf.subarray(palEnd, palEnd + w * h);
|
|
22
|
+
return idx.length === w * h ? { w, h, pal: buf.subarray(11, palEnd), idx } : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let small, big; // undefined: not read yet; null: not there
|
|
26
|
+
function smallMap() {
|
|
27
|
+
if (small === undefined) { try { small = parse(gunzipSync(readFileSync(SMALL_URL))); } catch { small = null; } }
|
|
28
|
+
return small;
|
|
29
|
+
}
|
|
30
|
+
function sixelMap() {
|
|
31
|
+
if (big === undefined) { try { big = parse(brotliDecompressSync(readFileSync(SIXEL_URL))); } catch { big = null; } }
|
|
32
|
+
return big;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is there a map to draw? */
|
|
36
|
+
export const hasWorldMap = () => useColor && !!smallMap();
|
|
37
|
+
|
|
38
|
+
/** The map area-averaged to tw x th, as packed rgb. */
|
|
39
|
+
function resample(m, tw, th) {
|
|
40
|
+
const out = new Uint8Array(tw * th * 3), sx = m.w / tw, sy = m.h / th;
|
|
41
|
+
for (let ty = 0; ty < th; ty++) {
|
|
42
|
+
const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
|
|
43
|
+
for (let tx = 0; tx < tw; tx++) {
|
|
44
|
+
const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
|
|
45
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
46
|
+
for (let y = y0; y < y1 && y < m.h; y++) for (let x = x0; x < x1 && x < m.w; x++) {
|
|
47
|
+
const p = m.idx[y * m.w + x] * 3;
|
|
48
|
+
r += m.pal[p]; g += m.pal[p + 1]; b += m.pal[p + 2]; n++;
|
|
49
|
+
}
|
|
50
|
+
if (!n) continue;
|
|
51
|
+
const d = (ty * tw + tx) * 3;
|
|
52
|
+
out[d] = r / n; out[d + 1] = g / n; out[d + 2] = b / n;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- what to pin -----------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pins in map fractions (0..1 from the top-left, a zone's `mapPos`): `you`, walking its leg when a journey
|
|
62
|
+
* is under way; `dest`, the zone under the cursor (a journey's destination when none is given); `route`, the
|
|
63
|
+
* zones the trip passes after you, in order. `legs` is a travel preview's plan (or any list of { to }); a
|
|
64
|
+
* journey under way brings its own legs.
|
|
65
|
+
*/
|
|
66
|
+
function marks(app, dest, legs) {
|
|
67
|
+
const ds = app.ds, pos = (id) => ds.zone(id)?.mapPos ?? null;
|
|
68
|
+
const st = app.g.state.char.journey ? app.g.journeyStatus() : null;
|
|
69
|
+
let you = pos(app.ch.zoneId);
|
|
70
|
+
if (st) {
|
|
71
|
+
const i = st.progress.legIndex, leg = st.legs[i];
|
|
72
|
+
const a = leg ? pos(leg.from) : null, b = leg ? pos(leg.to) : null;
|
|
73
|
+
const t = leg?.seconds > 0 ? Math.min(1, Math.max(0, st.progress.legElapsedSeconds / leg.seconds)) : 1;
|
|
74
|
+
// Snapped to 1/250 of the map, so a tick that moves the pin under a pixel does not redraw (or re-encode) it.
|
|
75
|
+
you = a && b ? [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t].map((v) => Math.round(v * 250) / 250) : b ?? a ?? you;
|
|
76
|
+
legs = st.legs.slice(i);
|
|
77
|
+
dest ??= ds.zone(st.destination);
|
|
78
|
+
}
|
|
79
|
+
const route = [];
|
|
80
|
+
for (const l of legs ?? []) { const p = pos(l.to); if (p) route.push(p); }
|
|
81
|
+
return { you, dest: dest?.mapPos ?? null, route };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const at3 = (p) => (p ? `${p[0].toFixed(3)},${p[1].toFixed(3)}` : '-');
|
|
85
|
+
|
|
86
|
+
// ---- half-block cells -------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const RESET = '\x1b[0m';
|
|
89
|
+
const YOU = { glyph: '●', rgb: '255;210;60' }, DEST = { glyph: '◆', rgb: '90;220;255' }, ROUTE = { glyph: '·', rgb: '255;240;190' };
|
|
90
|
+
|
|
91
|
+
/** The cells a straight line crosses between two cells (Bresenham), both ends included. */
|
|
92
|
+
function lineCells([x0, y0], [x1, y1]) {
|
|
93
|
+
const out = [], dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
|
94
|
+
let err = dx + dy, x = x0, y = y0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
out.push([x, y]);
|
|
97
|
+
if (x === x1 && y === y1) return out;
|
|
98
|
+
const e2 = 2 * err;
|
|
99
|
+
if (e2 >= dy) { err += dy; x += sx; }
|
|
100
|
+
if (e2 <= dx) { err += dx; y += sy; }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The map as half-block lines that fit maxCols x maxRows keeping its proportions, the pins as glyphs on
|
|
105
|
+
* their cells (the cell's own colour behind them). */
|
|
106
|
+
function halfBlockLines(m, mk, maxCols, maxRows) {
|
|
107
|
+
const scale = Math.min(maxCols / m.w, (maxRows * 2) / m.h);
|
|
108
|
+
const gw = Math.max(2, Math.round(m.w * scale)), gh = Math.max(2, Math.round(m.h * scale) + 1) & ~1, rows = gh / 2;
|
|
109
|
+
const s = resample(m, gw, gh);
|
|
110
|
+
const cell = (p) => [Math.min(gw - 1, Math.floor(p[0] * gw)), Math.min(rows - 1, Math.floor(p[1] * rows))];
|
|
111
|
+
const over = new Map();
|
|
112
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(cell);
|
|
113
|
+
for (let i = 0; i + 1 < pts.length; i++) for (const c of lineCells(pts[i], pts[i + 1])) over.set(c.join(), ROUTE);
|
|
114
|
+
if (mk.dest) over.set(cell(mk.dest).join(), DEST);
|
|
115
|
+
if (mk.you) over.set(cell(mk.you).join(), YOU);
|
|
116
|
+
const lines = [];
|
|
117
|
+
for (let y = 0; y < rows; y++) {
|
|
118
|
+
let line = '';
|
|
119
|
+
for (let x = 0; x < gw; x++) {
|
|
120
|
+
const t = (y * 2 * gw + x) * 3, u = ((y * 2 + 1) * gw + x) * 3, o = over.get(`${x},${y}`);
|
|
121
|
+
if (o) line += `\x1b[38;2;${o.rgb}m\x1b[48;2;${(s[t] + s[u]) >> 1};${(s[t + 1] + s[u + 1]) >> 1};${(s[t + 2] + s[u + 2]) >> 1}m${o.glyph}${RESET}`;
|
|
122
|
+
else line += `\x1b[38;2;${s[t]};${s[t + 1]};${s[t + 2]}m\x1b[48;2;${s[u]};${s[u + 1]};${s[u + 2]}m▀${RESET}`;
|
|
123
|
+
}
|
|
124
|
+
lines.push(line);
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const CACHE_MAX = 48;
|
|
131
|
+
function remember(key, lines) {
|
|
132
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
133
|
+
cache.set(key, lines);
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- sixel pixels ------------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const fitted = new Map(); // `${w}|${tw}x${th}` -> the map resampled to that size, kept for the next pin move
|
|
140
|
+
const FIT_MAX = 4;
|
|
141
|
+
|
|
142
|
+
/** The pins painted on the fitted map: a dashed route, a cyan diamond at the destination, a gold disc on you,
|
|
143
|
+
* each with a dark rim so it reads on any terrain. Sizes follow the picture's width. */
|
|
144
|
+
function paintPixels(base, tw, th, mk) {
|
|
145
|
+
const rgba = new Uint8Array(tw * th * 4);
|
|
146
|
+
for (let i = 0; i < tw * th; i++) { rgba[i * 4] = base[i * 3]; rgba[i * 4 + 1] = base[i * 3 + 1]; rgba[i * 4 + 2] = base[i * 3 + 2]; rgba[i * 4 + 3] = 255; }
|
|
147
|
+
const put = (x, y, c) => { if (x >= 0 && y >= 0 && x < tw && y < th) { const d = (y * tw + x) * 4; rgba[d] = c[0]; rgba[d + 1] = c[1]; rgba[d + 2] = c[2]; } };
|
|
148
|
+
const disc = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) put(x, y, c); };
|
|
149
|
+
const diamond = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if (Math.abs(x - cx) + Math.abs(y - cy) <= r) put(x, y, c); };
|
|
150
|
+
const DARK = [24, 20, 16], r = Math.max(3, tw / 90), px = (p) => [p[0] * tw, p[1] * th];
|
|
151
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(px);
|
|
152
|
+
const dash = r * 2.2, gap = r * 1.6;
|
|
153
|
+
for (const [rad, c] of [[r / 2.6 + 1, DARK], [r / 2.6, [255, 240, 190]]]) {
|
|
154
|
+
let along = 0;
|
|
155
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
156
|
+
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], len = Math.hypot(x1 - x0, y1 - y0);
|
|
157
|
+
for (let d = 0; d <= len; d++) if ((along + d) % (dash + gap) < dash) disc(x0 + ((x1 - x0) * d) / len, y0 + ((y1 - y0) * d) / len, rad, c);
|
|
158
|
+
along += len;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mk.dest) { const [x, y] = px(mk.dest); diamond(x, y, r * 1.5 + 1.5, DARK); diamond(x, y, r * 1.5, [90, 220, 255]); }
|
|
162
|
+
if (mk.you) { const [x, y] = px(mk.you); disc(x, y, r + 1.5, DARK); disc(x, y, r, [255, 210, 60]); disc(x, y, r / 3, DARK); }
|
|
163
|
+
return rgba;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The map as sixel placeholder lines (sixel.mjs) fitted to maxCols x maxRows, or null when sixel is off. */
|
|
167
|
+
function sixelLines(m, mk, key, maxCols, maxRows) {
|
|
168
|
+
if (!sixelOn()) return null;
|
|
169
|
+
const { w: cw, h: ch } = cellSize();
|
|
170
|
+
const scale = Math.min((maxCols * cw) / m.w, (maxRows * ch) / m.h);
|
|
171
|
+
const tw = Math.max(1, Math.round(m.w * scale)), th = Math.max(1, Math.round(m.h * scale));
|
|
172
|
+
return sixelImage(`map|${m.w}|${tw}x${th}|${key}`, () => {
|
|
173
|
+
const fitKey = `${m.w}|${tw}x${th}`;
|
|
174
|
+
let base = fitted.get(fitKey);
|
|
175
|
+
if (!base) {
|
|
176
|
+
if (fitted.size >= FIT_MAX) fitted.delete(fitted.keys().next().value);
|
|
177
|
+
fitted.set(fitKey, (base = resample(m, tw, th)));
|
|
178
|
+
}
|
|
179
|
+
return { w: tw, h: th, rgba: paintPixels(base, tw, th, mk) };
|
|
180
|
+
}, maxCols, maxRows);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- the lines a pane draws ---------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The world map for a detail pane, as lines no wider than `maxCols` and no more than `maxRows` of them (the
|
|
187
|
+
* last a legend): you pinned where you stand, `dest` (a zone) pinned if given, the `legs` of a planned trip
|
|
188
|
+
* (or the journey under way) drawn as a route. Null without colour, a pack, a position, or room.
|
|
189
|
+
*/
|
|
190
|
+
export function worldMapLines(app, { dest = null, legs = null, maxCols, maxRows } = {}) {
|
|
191
|
+
if (!useColor || !(maxCols >= 10) || !(maxRows >= 6)) return null;
|
|
192
|
+
const m = smallMap();
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
const mk = marks(app, dest, legs);
|
|
195
|
+
if (!mk.you && !mk.dest) return null;
|
|
196
|
+
const key = [mk.you, mk.dest, ...mk.route].map(at3).join('|');
|
|
197
|
+
const rows = maxRows - 1;
|
|
198
|
+
let art = sixelLines(sixelMap() ?? m, mk, key, maxCols, rows);
|
|
199
|
+
if (!art) {
|
|
200
|
+
const ck = `${maxCols}x${rows}|${key}`;
|
|
201
|
+
art = cache.get(ck) ?? remember(ck, halfBlockLines(m, mk, maxCols, rows));
|
|
202
|
+
}
|
|
203
|
+
const legend = `${yellow(YOU.glyph)} you${mk.dest ? ` ${cyan(DEST.glyph)} destination` : ''}${mk.route.length ? dim(' ┄ route') : ''}`;
|
|
204
|
+
return [...art, legend];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `lines` with the map under them, as large as the detail pane has room for; `lines` alone when there is none. */
|
|
208
|
+
export function withWorldMap(app, lines, opts = {}) {
|
|
209
|
+
const L = app.layout?.();
|
|
210
|
+
if (!L || !(L.detailW > 2)) return lines;
|
|
211
|
+
const w = L.detailW - 2, used = lines.reduce((n, l) => n + Math.max(1, Math.ceil(vis(l) / w)), 0);
|
|
212
|
+
const map = worldMapLines(app, { ...opts, maxCols: w, maxRows: L.mainH - used - 1 });
|
|
213
|
+
return map ? [...lines, '', ...map] : lines;
|
|
214
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The Character tab as a sheet: you in your gear in the middle, the worn pieces' icons down both sides
|
|
2
|
+
// the way the client's paperdoll lays them out (head to wrists on the left, hands to trinkets on the right,
|
|
3
|
+
// the weapons and the bag bar underneath), each framed in its quality colour. Arrows move between the
|
|
4
|
+
// slots (Up and Down in order, Shift+arrows across the picture), the detail pane is the piece's tooltip, and Enter, x and r act on the slot as they do in the list.
|
|
5
|
+
// The rows are the gear view's own (a slot row carries `cell`); this file only places them. No colour, or a
|
|
6
|
+
// window too small for the sheet, and the view lists its rows as before.
|
|
7
|
+
import { canvas, drawCell, gridStep } from './iconGrid.mjs';
|
|
8
|
+
import { iconsOn } from './iconArt.mjs';
|
|
9
|
+
import { artWidth } from './modelArt.mjs';
|
|
10
|
+
import { bold, dim, green, inverse, fit, cut, vis, qualityPaint, qualityCodes, plural } from './text.mjs';
|
|
11
|
+
import { SLOT_LABEL } from '../domain/items/equipment.ts';
|
|
12
|
+
|
|
13
|
+
const LEFT = ['head', 'neck', 'shoulder', 'back', 'chest', 'shirt', 'wrist'];
|
|
14
|
+
const RIGHT = ['hands', 'waist', 'legs', 'feet', 'finger1', 'finger2', 'trinket1', 'trinket2'];
|
|
15
|
+
const WEAPONS = ['mainhand', 'offhand', 'ranged'];
|
|
16
|
+
const BAGS = ['bag1', 'bag2', 'bag3', 'bag4'];
|
|
17
|
+
const SIDE = RIGHT.length;
|
|
18
|
+
// What an empty slot says on its dark square (four columns at most).
|
|
19
|
+
const SHORT = {
|
|
20
|
+
head: 'Head', neck: 'Neck', shoulder: 'Shld', back: 'Back', chest: 'Chst', shirt: 'Shrt', wrist: 'Wrst',
|
|
21
|
+
hands: 'Hand', waist: 'Wst', legs: 'Legs', feet: 'Feet', finger1: 'Ring', finger2: 'Ring', trinket1: 'Trk', trinket2: 'Trk',
|
|
22
|
+
mainhand: 'MH', offhand: 'OH', ranged: 'Rng', bag1: 'Bag', bag2: 'Bag', bag3: 'Bag', bag4: 'Bag',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The icon size the sheet takes in a `w` x `h` list pane and how many of the `extras` rows (repair, ammo,
|
|
26
|
+
* outfits) fit under it, or null when it does not fit and the view should list instead. Big icons win over
|
|
27
|
+
* the upkeep rows: R and o reach those from the keys, and the view leaves undrawn ones out of its rows. */
|
|
28
|
+
function sheetSize(w, h, extras) {
|
|
29
|
+
for (const s of [6, 4]) {
|
|
30
|
+
for (const room of [extras, 0]) {
|
|
31
|
+
const need = 2 + SIDE * (s / 2) + 1 + s / 2 + (room ? 1 + room : 0);
|
|
32
|
+
if (h >= need && w >= 7 * (s + 2) + 6) return { s, extras: room };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Will the Character tab draw as the sheet right now? `extras` is how many rows follow the slots. */
|
|
39
|
+
export function paperdollPlan(app, extras) {
|
|
40
|
+
if (!iconsOn()) return null;
|
|
41
|
+
const L = app.layout();
|
|
42
|
+
return L.detailW ? sheetSize(L.listW, L.mainH, extras) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The sheet's lines for the gear view's `rows` in a `W` x `H` pane, with `body(cols, lines)` drawing the
|
|
47
|
+
* character (playerArt) and `title` heading it, plus where the arrows go; null when it does not fit. `hint`
|
|
48
|
+
* replaces the keys line (a companion's sheet has no repair or outfits).
|
|
49
|
+
*/
|
|
50
|
+
export function paperdollGrid(app, rows, W, H, body, title, hint) {
|
|
51
|
+
const extraRows = rows.map((r, i) => ({ r, i })).filter(({ r }) => !r.cell && !r.header);
|
|
52
|
+
const plan = iconsOn() ? sheetSize(W, H, extraRows.length) : null;
|
|
53
|
+
if (!plan) return null;
|
|
54
|
+
const { s } = plan, cw = s + 2, ch = s / 2;
|
|
55
|
+
const cur = app.top()?.cursor ?? 0;
|
|
56
|
+
const cv = canvas(W, H);
|
|
57
|
+
const pts = [];
|
|
58
|
+
const index = new Map(rows.map((r, i) => [r.cell?.slot, i]));
|
|
59
|
+
const top = 2, colH = SIDE * ch;
|
|
60
|
+
|
|
61
|
+
const ups = rows.filter((r) => r.cell?.up).length;
|
|
62
|
+
cv.text(1, 0, `${title ?? bold('Equipment')} ${ups ? green(`▲ ${plural(ups, 'upgrade')} in your bags u: put on all`) : dim(hint ?? `s: stats Shift+arrows: move Enter: change${plan.extras ? '' : ' R: repair all o: outfits'}`)}`);
|
|
63
|
+
|
|
64
|
+
// Labels take a share of the width beside each column; the body gets the middle.
|
|
65
|
+
const lw = Math.max(0, Math.min(22, Math.floor((W - 2 * cw) * 0.28)));
|
|
66
|
+
const midX = cw + lw + 1, midW = W - 2 * cw - 2 * lw - 2;
|
|
67
|
+
const art = midW >= 6 ? body(midW, colH) : null;
|
|
68
|
+
if (art?.length) cv.art(midX + Math.floor((midW - artWidth(art)) / 2), top + Math.max(0, Math.floor((colH - art.length) / 2)), art);
|
|
69
|
+
|
|
70
|
+
const cell = (slot, x, y, px, py, side) => {
|
|
71
|
+
const i = index.get(slot);
|
|
72
|
+
if (i == null) return;
|
|
73
|
+
const c = rows[i].cell, it = c.it;
|
|
74
|
+
const sel = i === cur;
|
|
75
|
+
drawCell(cv, x, y, s, {
|
|
76
|
+
icon: it?.icon, frame: it ? qualityCodes(it.quality) : '90', selected: sel, empty: SHORT[slot],
|
|
77
|
+
mark: c.up ? { text: '▲', codes: '92' } : null,
|
|
78
|
+
});
|
|
79
|
+
pts.push({ i, x: px, y: py });
|
|
80
|
+
if (!side || lw < 4) return;
|
|
81
|
+
const name = it ? qualityPaint(it.quality)(it.name) : dim(SLOT_LABEL[slot] ?? slot);
|
|
82
|
+
const lines = [sel ? bold(name) : name, ...(ch > 2 ? [c.sub ?? '', c.dur ?? ''] : [c.dur || c.sub || ''])].slice(0, ch);
|
|
83
|
+
lines.forEach((t, k) => {
|
|
84
|
+
const text = cut(t, lw - 1);
|
|
85
|
+
cv.text(side === 'left' ? x + cw + 1 : x - 1 - vis(text), y + k, text);
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
LEFT.forEach((slot, k) => cell(slot, 0, top + k * ch, 0, k, 'left'));
|
|
89
|
+
RIGHT.forEach((slot, k) => cell(slot, W - cw, top + k * ch, 4, k, 'right'));
|
|
90
|
+
|
|
91
|
+
// Under the columns: the weapons centred under the body, the bag bar at the right edge.
|
|
92
|
+
const by = top + colH + 1;
|
|
93
|
+
const bagsX = W - BAGS.length * cw;
|
|
94
|
+
const wx = Math.max(0, Math.min(Math.round(midX + midW / 2 - 1.5 * cw), bagsX - 3 * cw - 2));
|
|
95
|
+
WEAPONS.forEach((slot, k) => cell(slot, wx + k * cw, by, 1 + k, SIDE));
|
|
96
|
+
BAGS.forEach((slot, k) => cell(slot, bagsX + k * cw, by, 5 + k, SIDE));
|
|
97
|
+
|
|
98
|
+
// Upkeep rows (repair all, ammo, outfits, auto use) under the sheet when they fit.
|
|
99
|
+
const ey = by + ch + 1;
|
|
100
|
+
extraRows.slice(0, plan.extras).forEach(({ r, i }, k) => {
|
|
101
|
+
const sel = i === cur;
|
|
102
|
+
const text = ` ${sel ? '▶' : ' '} ${r.text}`;
|
|
103
|
+
cv.text(0, ey + k, sel ? inverse(fit(text, W - 1)) : text, W - 1);
|
|
104
|
+
pts.push({ i, x: 1, y: SIDE + 1 + k });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { lines: cv.lines(), nav: (dir, from) => gridStep(pts, from, dir, { dyW: 1, dxW: 10 }) };
|
|
108
|
+
}
|