azeroth-shell 0.1.1 → 0.2.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 +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +19 -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/fileRepository.mjs +46 -1
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +36 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +252 -98
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +371 -42
- package/src/cli/tuiCheck.mjs +415 -28
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +425 -88
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +2 -2
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +22 -3
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +296 -115
- package/src/cli/tuiWorld.mjs +4 -5
- 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.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- 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/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 +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Sixel sprites: real pixels for the creatures on the fight stage, where the terminal can draw them
|
|
2
|
+
// (Windows Terminal 1.22+, WezTerm, foot, mlterm, xterm -ti vt340). The half-block art (modelArt.mjs) is a
|
|
3
|
+
// pixel per half cell; a sixel image is the terminal's own pixels, about ten per cell across and twenty down.
|
|
4
|
+
//
|
|
5
|
+
// A screen is lines of text, so an image travels through them as a placeholder: a block of blank cells
|
|
6
|
+
// whose rows each start with a marker, an SGR-shaped code (`ESC[9999;id;rowm`) the text helpers count as
|
|
7
|
+
// zero width. RealTerm.draw takes the markers out before it writes, then sends the image at the block's
|
|
8
|
+
// top-left cell after the text (term.mjs). The terminal says whether it speaks sixel in its DA1 reply.
|
|
9
|
+
//
|
|
10
|
+
// The pixels come from model-art-hd.bin.br (scripts/bake-model-art.mjs --hd): the same sprites as the
|
|
11
|
+
// half-block pack at the renders' own size, up to 128 px wide; icons from icon-art-hd.bin.br. Neither ships in
|
|
12
|
+
// the package: hdArt.mjs finds them (downloaded, or a checkout's bake). No pack, no sprite, or no sixel: null,
|
|
13
|
+
// and the caller draws the half-block art. AZSH_SIXEL=0 turns it off, AZSH_SIXEL=1 on without asking.
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { brotliDecompressSync } from 'node:zlib';
|
|
16
|
+
import { useColor } from './text.mjs';
|
|
17
|
+
import { hdArtPath } from './hdArt.mjs';
|
|
18
|
+
|
|
19
|
+
const MAGIC = 9999;
|
|
20
|
+
export const MARK_RE = /\x1b\[9999;(\d+);(\d+)m/g;
|
|
21
|
+
|
|
22
|
+
let on = process.env.AZSH_SIXEL === '1';
|
|
23
|
+
// The terminal's cell in pixels. Windows Terminal answers CSI 16 t with zeros and scales a sixel as if a
|
|
24
|
+
// cell were 10 x 20 (the VT340's), whatever the font; that is the fallback for any terminal that does not say.
|
|
25
|
+
let cellW = 10, cellH = 20;
|
|
26
|
+
|
|
27
|
+
/** Is sixel art on? Never without colour or under AZSH_SIXEL=0. */
|
|
28
|
+
export const sixelOn = () => on && useColor && process.env.AZSH_SIXEL !== '0';
|
|
29
|
+
/** The DA1 reply's parameters (`?62;4;22`): 4 is sixel graphics. True when this turned sixel on. */
|
|
30
|
+
export function noteDeviceAttributes(params) {
|
|
31
|
+
if (on || process.env.AZSH_SIXEL === '0') return false;
|
|
32
|
+
on = params.replace('?', '').split(';').includes('4');
|
|
33
|
+
return on;
|
|
34
|
+
}
|
|
35
|
+
/** The CSI 16 t reply: the cell's height and width in pixels; zeros (Windows Terminal) keep the fallback. */
|
|
36
|
+
export function noteCellSize(h, w) {
|
|
37
|
+
if (h > 0 && w > 0) { cellH = h; cellW = w; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- the source images ------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
let index = null; // displayId -> byte offset of the sprite in `pack` (the half-block pack's layout, AZART v1)
|
|
43
|
+
let pack = null;
|
|
44
|
+
|
|
45
|
+
function load() {
|
|
46
|
+
if (index) return index;
|
|
47
|
+
index = new Map();
|
|
48
|
+
try {
|
|
49
|
+
pack = brotliDecompressSync(readFileSync(hdArtPath('model-art-hd.bin.br')));
|
|
50
|
+
if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return index;
|
|
51
|
+
const count = pack.readUInt32LE(6);
|
|
52
|
+
let off = 10;
|
|
53
|
+
for (let i = 0; i < count && off + 7 <= pack.length; i++) {
|
|
54
|
+
index.set(pack.readUInt32LE(off), off);
|
|
55
|
+
off += 7 + pack[off + 6] * 3 + pack[off + 4] * pack[off + 5];
|
|
56
|
+
}
|
|
57
|
+
} catch { pack = null; }
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Forget the packs (and what was missing) so the next draw reads them again: after a download. */
|
|
62
|
+
export function forgetSixelPacks() { index = pack = iconIndex = iconPack = null; images.clear(); byKey.clear(); }
|
|
63
|
+
|
|
64
|
+
/** The sprite for a display id as { w, h, rgba } (already cropped to its opaque box), or null. */
|
|
65
|
+
function sourceOf(displayId) {
|
|
66
|
+
const off = load().get(displayId);
|
|
67
|
+
if (off == null) return null;
|
|
68
|
+
const w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6], pal = off + 7, idx = pal + palLen * 3;
|
|
69
|
+
const rgba = new Uint8Array(w * h * 4);
|
|
70
|
+
for (let i = 0; i < w * h; i++) {
|
|
71
|
+
const p = pack[idx + i];
|
|
72
|
+
if (!p) continue;
|
|
73
|
+
const c = pal + (p - 1) * 3;
|
|
74
|
+
rgba[i * 4] = pack[c]; rgba[i * 4 + 1] = pack[c + 1]; rgba[i * 4 + 2] = pack[c + 2]; rgba[i * 4 + 3] = 255;
|
|
75
|
+
}
|
|
76
|
+
return { w, h, rgba };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- scaling and encoding -----------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** The image scaled to tw x th, placed bottom-centre on a cw x ch canvas: area-sampled when it shrinks,
|
|
82
|
+
* bilinear when it grows. Colour is weighted by alpha; a pixel shows where more than half of it is covered. */
|
|
83
|
+
function scaleOnto(img, tw, th, cw, ch) {
|
|
84
|
+
const out = new Uint8Array(cw * ch * 4);
|
|
85
|
+
const ox = Math.floor((cw - tw) / 2), oy = ch - th;
|
|
86
|
+
const sx = img.w / tw, sy = img.h / th;
|
|
87
|
+
if (sx < 1) {
|
|
88
|
+
const at = (x, y, c) => img.rgba[(Math.min(img.h - 1, Math.max(0, y)) * img.w + Math.min(img.w - 1, Math.max(0, x))) * 4 + c];
|
|
89
|
+
for (let ty = 0; ty < th; ty++) {
|
|
90
|
+
const fy = (ty + 0.5) * sy - 0.5, y0 = Math.floor(fy), wy = fy - y0;
|
|
91
|
+
for (let tx = 0; tx < tw; tx++) {
|
|
92
|
+
const fx = (tx + 0.5) * sx - 0.5, x0 = Math.floor(fx), wx = fx - x0;
|
|
93
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
94
|
+
for (const [x, y, wt] of [[x0, y0, (1 - wx) * (1 - wy)], [x0 + 1, y0, wx * (1 - wy)], [x0, y0 + 1, (1 - wx) * wy], [x0 + 1, y0 + 1, wx * wy]]) {
|
|
95
|
+
const al = at(x, y, 3) * wt;
|
|
96
|
+
r += at(x, y, 0) * al; g += at(x, y, 1) * al; b += at(x, y, 2) * al; a += al;
|
|
97
|
+
}
|
|
98
|
+
if (a < 128) continue;
|
|
99
|
+
const d = ((oy + ty) * cw + ox + tx) * 4;
|
|
100
|
+
out[d] = r / a; out[d + 1] = g / a; out[d + 2] = b / a; out[d + 3] = 255;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
for (let ty = 0; ty < th; ty++) {
|
|
106
|
+
const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
|
|
107
|
+
for (let tx = 0; tx < tw; tx++) {
|
|
108
|
+
const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
|
|
109
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
|
110
|
+
for (let y = y0; y < y1 && y < img.h; y++) for (let x = x0; x < x1 && x < img.w; x++) {
|
|
111
|
+
const k = (y * img.w + x) * 4, al = img.rgba[k + 3];
|
|
112
|
+
r += img.rgba[k] * al; g += img.rgba[k + 1] * al; b += img.rgba[k + 2] * al; a += al; n++;
|
|
113
|
+
}
|
|
114
|
+
if (!n || a / n < 128) continue;
|
|
115
|
+
const d = ((oy + ty) * cw + ox + tx) * 4;
|
|
116
|
+
out[d] = r / a; out[d + 1] = g / a; out[d + 2] = b / a; out[d + 3] = 255;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Up to 255 colours: the most used 15-bit buckets (each the mean of its pixels), the rest mapped to the
|
|
123
|
+
* nearest kept one. Returns the palette and a colour index per pixel (-1 where transparent). */
|
|
124
|
+
function quantize(rgba, n) {
|
|
125
|
+
const count = new Uint32Array(32768), sum = new Float64Array(32768 * 3), bucket = new Int32Array(n).fill(-1);
|
|
126
|
+
for (let i = 0; i < n; i++) {
|
|
127
|
+
if (!rgba[i * 4 + 3]) continue;
|
|
128
|
+
const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2], k = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
|
|
129
|
+
bucket[i] = k; count[k]++; sum[k * 3] += r; sum[k * 3 + 1] += g; sum[k * 3 + 2] += b;
|
|
130
|
+
}
|
|
131
|
+
const used = [];
|
|
132
|
+
for (let k = 0; k < 32768; k++) if (count[k]) used.push(k);
|
|
133
|
+
used.sort((a, b) => count[b] - count[a]);
|
|
134
|
+
const kept = used.slice(0, 255);
|
|
135
|
+
const pal = kept.map((k) => [sum[k * 3] / count[k], sum[k * 3 + 1] / count[k], sum[k * 3 + 2] / count[k]]);
|
|
136
|
+
const slot = new Int32Array(32768).fill(-1);
|
|
137
|
+
kept.forEach((k, i) => { slot[k] = i; });
|
|
138
|
+
for (const k of used.slice(255)) {
|
|
139
|
+
const r = ((k >> 10) & 31) * 8 + 4, g = ((k >> 5) & 31) * 8 + 4, b = (k & 31) * 8 + 4;
|
|
140
|
+
let best = 0, bd = Infinity;
|
|
141
|
+
pal.forEach(([pr, pg, pb], i) => { const d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2; if (d < bd) { bd = d; best = i; } });
|
|
142
|
+
slot[k] = best;
|
|
143
|
+
}
|
|
144
|
+
const idx = new Int16Array(n).fill(-1);
|
|
145
|
+
for (let i = 0; i < n; i++) if (bucket[i] >= 0) idx[i] = slot[bucket[i]];
|
|
146
|
+
return { pal, idx };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** An RGBA image as a sixel string: 1:1 pixels, unset pixels transparent (P2=1), run-length coded. */
|
|
150
|
+
function encode(rgba, w, h) {
|
|
151
|
+
const { pal, idx } = quantize(rgba, w * h);
|
|
152
|
+
let out = `\x1bP0;1;0q"1;1;${w};${h}`;
|
|
153
|
+
pal.forEach(([r, g, b], i) => { out += `#${i};2;${Math.round((r * 100) / 255)};${Math.round((g * 100) / 255)};${Math.round((b * 100) / 255)}`; });
|
|
154
|
+
const rle = (ch, n) => (n > 3 ? `!${n}${ch}` : ch.repeat(n));
|
|
155
|
+
for (let y0 = 0; y0 < h; y0 += 6) {
|
|
156
|
+
// Each colour in the band: a six-bit column per x, the rows it covers.
|
|
157
|
+
const bands = new Map();
|
|
158
|
+
for (let dy = 0; dy < 6 && y0 + dy < h; dy++) for (let x = 0; x < w; x++) {
|
|
159
|
+
const c = idx[(y0 + dy) * w + x];
|
|
160
|
+
if (c < 0) continue;
|
|
161
|
+
let cols = bands.get(c);
|
|
162
|
+
if (!cols) bands.set(c, (cols = new Uint8Array(w)));
|
|
163
|
+
cols[x] |= 1 << dy;
|
|
164
|
+
}
|
|
165
|
+
let first = true;
|
|
166
|
+
for (const [c, cols] of bands) {
|
|
167
|
+
let last = w - 1;
|
|
168
|
+
while (last >= 0 && !cols[last]) last--;
|
|
169
|
+
let s = `#${c}`, run = 0, prev = -1;
|
|
170
|
+
for (let x = 0; x <= last; x++) {
|
|
171
|
+
if (cols[x] === prev) { run++; continue; }
|
|
172
|
+
if (run) s += rle(String.fromCharCode(63 + prev), run);
|
|
173
|
+
prev = cols[x]; run = 1;
|
|
174
|
+
}
|
|
175
|
+
if (run) s += rle(String.fromCharCode(63 + prev), run);
|
|
176
|
+
out += (first ? '' : '$') + s;
|
|
177
|
+
first = false;
|
|
178
|
+
}
|
|
179
|
+
out += '-';
|
|
180
|
+
}
|
|
181
|
+
return `${out}\x1b\\`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- placements ----------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
// Encoded images by id, the oldest dropped past IMAGES_MAX (a marker for a dropped id is ignored, and the
|
|
187
|
+
// next draw that wants the picture encodes it again under a new id).
|
|
188
|
+
const IMAGES_MAX = 480;
|
|
189
|
+
const images = new Map(); // id -> { cols, rows, sixel, holes }
|
|
190
|
+
const byKey = new Map(); // what was drawn and at which cell size -> id
|
|
191
|
+
let nextId = 0;
|
|
192
|
+
|
|
193
|
+
/** The sixel string, size in cells and transparent `holes` of a placed image, by the id its markers carry. */
|
|
194
|
+
export const imageOf = (id) => images.get(id) ?? null;
|
|
195
|
+
|
|
196
|
+
/** The id of the image under `key`, built by `make()` -> { cols, rows, rgba, holes? } (cols x rows cells of
|
|
197
|
+
* pixels, `holes` the cells left transparent) the first time; null when `make` has nothing. */
|
|
198
|
+
function imageId(key, make) {
|
|
199
|
+
key = `${key}|${cellW}x${cellH}`;
|
|
200
|
+
const hit = byKey.get(key);
|
|
201
|
+
if (hit != null && images.has(hit)) return hit;
|
|
202
|
+
const img = make();
|
|
203
|
+
if (!img) return null;
|
|
204
|
+
const id = nextId++;
|
|
205
|
+
images.set(id, { cols: img.cols, rows: img.rows, holes: img.holes ?? [], sixel: encode(img.rgba, img.cols * cellW, img.rows * cellH) });
|
|
206
|
+
byKey.set(key, id);
|
|
207
|
+
if (images.size > IMAGES_MAX) {
|
|
208
|
+
const old = images.keys().next().value;
|
|
209
|
+
images.delete(old);
|
|
210
|
+
for (const [k, v] of byKey) if (v === old) { byKey.delete(k); break; }
|
|
211
|
+
}
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The placeholder for image `id`: one array of cells per row, the row's marker riding on its first cell. */
|
|
216
|
+
function placeholderCells(id) {
|
|
217
|
+
const { cols, rows } = images.get(id);
|
|
218
|
+
return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, x) => (x ? ' ' : `[${MAGIC};${id};${r}m `)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A creature as a sixel placeholder that fits in `maxCols` x `maxRows` cells (bottom-centred, keeping its
|
|
223
|
+
* proportions): `rows` lines of `cols` blank cells, each line starting with its marker. Null when sixel is
|
|
224
|
+
* off or the pack has no sprite for the display id.
|
|
225
|
+
*/
|
|
226
|
+
export function sixelCreature(displayId, maxCols, maxRows) {
|
|
227
|
+
if (!sixelOn() || displayId == null || maxCols < 2 || maxRows < 2) return null;
|
|
228
|
+
return sixelImage(`c${displayId}`, () => sourceOf(displayId), maxCols, maxRows);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Any image (`source()` -> { w, h, rgba }, cropped) fitted to `maxCols` x `maxRows` cells as placeholder lines,
|
|
232
|
+
* cached under `key`; null when sixel is off or there is no image. */
|
|
233
|
+
export function sixelImage(key, source, maxCols, maxRows) {
|
|
234
|
+
if (!sixelOn()) return null;
|
|
235
|
+
const id = imageId(`${key}|${maxCols}x${maxRows}`, () => {
|
|
236
|
+
const img = source();
|
|
237
|
+
if (!img) return null;
|
|
238
|
+
const scale = Math.min((maxCols * cellW) / img.w, (maxRows * cellH) / img.h);
|
|
239
|
+
const tw = Math.max(1, Math.round(img.w * scale)), th = Math.max(1, Math.round(img.h * scale));
|
|
240
|
+
const cols = Math.ceil(tw / cellW), rows = Math.ceil(th / cellH);
|
|
241
|
+
return { cols, rows, rgba: scaleOnto(img, tw, th, cols * cellW, rows * cellH) };
|
|
242
|
+
});
|
|
243
|
+
return id == null ? null : placeholderCells(id).map((r) => r.join(''));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- icons ----------------------------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
let iconIndex = null; // name -> byte offset in `iconPack`
|
|
249
|
+
let iconPack = null;
|
|
250
|
+
|
|
251
|
+
function loadIcons() {
|
|
252
|
+
if (iconIndex) return iconIndex;
|
|
253
|
+
iconIndex = new Map();
|
|
254
|
+
try {
|
|
255
|
+
iconPack = brotliDecompressSync(readFileSync(hdArtPath('icon-art-hd.bin.br')));
|
|
256
|
+
if (iconPack.toString('ascii', 0, 5) !== 'AZICH' || iconPack[5] !== 1) return iconIndex;
|
|
257
|
+
const count = iconPack.readUInt32LE(6);
|
|
258
|
+
let off = 10;
|
|
259
|
+
for (let i = 0; i < count && off < iconPack.length; i++) {
|
|
260
|
+
const n = iconPack[off];
|
|
261
|
+
iconIndex.set(iconPack.toString('ascii', off + 1, off + 1 + n), off + 1 + n);
|
|
262
|
+
off += 1 + n;
|
|
263
|
+
off += 2 + iconPack[off + 1] * 3 + iconPack[off] * iconPack[off];
|
|
264
|
+
}
|
|
265
|
+
} catch { iconPack = null; }
|
|
266
|
+
return iconIndex;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** An icon's picture as { w, h, rgba }; `tone` 'gray' greys and darkens it (a talent out of reach). */
|
|
270
|
+
function iconSource(name, tone) {
|
|
271
|
+
const off = loadIcons().get(name);
|
|
272
|
+
if (off == null) return null;
|
|
273
|
+
const side = iconPack[off], palLen = iconPack[off + 1], pal = off + 2, idx = pal + palLen * 3;
|
|
274
|
+
const rgba = new Uint8Array(side * side * 4);
|
|
275
|
+
for (let i = 0; i < side * side; i++) {
|
|
276
|
+
const c = pal + iconPack[idx + i] * 3;
|
|
277
|
+
let r = iconPack[c], g = iconPack[c + 1], b = iconPack[c + 2];
|
|
278
|
+
if (tone === 'gray') r = g = b = Math.round((0.3 * r + 0.59 * g + 0.11 * b) * 0.6);
|
|
279
|
+
rgba[i * 4] = r; rgba[i * 4 + 1] = g; rgba[i * 4 + 2] = b; rgba[i * 4 + 3] = 255;
|
|
280
|
+
}
|
|
281
|
+
return { w: side, h: side, rgba };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Is there a full-size icon for `name` to draw as sixel? */
|
|
285
|
+
export const sixelIconOn = (name) => sixelOn() && !!name && loadIcons().has(String(name).toLowerCase());
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The icon `name` filling `cols` x `cols / 2` cells, as rows of cells like iconCells (iconArt.mjs) makes,
|
|
289
|
+
* or null. `holes` ([{ x, y, w }] in cells) stay transparent so text drawn there (a rank, a count) shows
|
|
290
|
+
* through the picture.
|
|
291
|
+
*/
|
|
292
|
+
export function sixelIconCells(name, cols, tone = '', holes = []) {
|
|
293
|
+
if (!sixelIconOn(name)) return null;
|
|
294
|
+
name = String(name).toLowerCase();
|
|
295
|
+
const rows = cols / 2;
|
|
296
|
+
const id = imageId(`i${name}|${cols}|${tone}|${holes.map((h) => `${h.x},${h.y},${h.w}`).join(';')}`, () => {
|
|
297
|
+
const img = iconSource(name, tone);
|
|
298
|
+
if (!img) return null;
|
|
299
|
+
const pw = cols * cellW, ph = rows * cellH;
|
|
300
|
+
const rgba = scaleOnto(img, pw, ph, pw, ph);
|
|
301
|
+
for (const h of holes) for (let y = h.y * cellH; y < (h.y + 1) * cellH; y++) {
|
|
302
|
+
for (let x = h.x * cellW; x < (h.x + h.w) * cellW; x++) rgba[(y * pw + x) * 4 + 3] = 0;
|
|
303
|
+
}
|
|
304
|
+
return { cols, rows, rgba, holes };
|
|
305
|
+
});
|
|
306
|
+
return id == null ? null : placeholderCells(id);
|
|
307
|
+
}
|
package/src/cli/term.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// the Esc key and is reported at once.
|
|
8
8
|
import { stdin, stdout } from 'node:process';
|
|
9
9
|
import { fit } from './text.mjs';
|
|
10
|
+
import { splitCells } from './iconGrid.mjs';
|
|
11
|
+
import { MARK_RE, imageOf, noteDeviceAttributes, noteCellSize } from './sixel.mjs';
|
|
10
12
|
|
|
11
13
|
const ESC = '\x1b[';
|
|
12
14
|
const MIN_COLS = 60, MIN_ROWS = 20;
|
|
@@ -62,6 +64,7 @@ export class RealTerm {
|
|
|
62
64
|
constructor() {
|
|
63
65
|
this.onKey = () => {};
|
|
64
66
|
this.onResize = () => {};
|
|
67
|
+
this.onSixel = () => {}; // the terminal said it draws sixel
|
|
65
68
|
this.isTTY = stdout.isTTY === true && stdin.isTTY === true;
|
|
66
69
|
this.last = [];
|
|
67
70
|
}
|
|
@@ -75,9 +78,19 @@ export class RealTerm {
|
|
|
75
78
|
stdin.resume();
|
|
76
79
|
// Autowrap off: a line a terminal measures wider than we do (an ambiguous-width glyph) is clipped
|
|
77
80
|
// instead of wrapping and scrolling the whole frame out of step with `last`.
|
|
78
|
-
|
|
81
|
+
// Then ask what the terminal is (DA1: does it draw sixel) and its cell size in pixels (CSI 16 t); each
|
|
82
|
+
// image keeps its own colour registers (?1070h).
|
|
83
|
+
stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?7l${ESC}H${ESC}2J${ESC}?1070h${ESC}c${ESC}16t`);
|
|
79
84
|
// Ctrl-L forgets what is on screen and repaints everything, to recover from a terminal that moved it.
|
|
80
|
-
|
|
85
|
+
// The replies to the questions above arrive as input; they are taken out before the keys are read.
|
|
86
|
+
this._data = (chunk) => {
|
|
87
|
+
let s = String(chunk), repaint = false;
|
|
88
|
+
s = s.replace(/\x1b\[(\?[0-9;]*)c/g, (_, p) => { if (noteDeviceAttributes(p)) { repaint = true; this.onSixel(); } return ''; });
|
|
89
|
+
s = s.replace(/\x1b\[6;(\d+);(\d+)t/g, (_, h, w) => { noteCellSize(+h, +w); repaint = true; return ''; });
|
|
90
|
+
if (repaint) this.refresh();
|
|
91
|
+
if (!s) return;
|
|
92
|
+
for (const k of decodeKeys(s)) { if (k === 'ctrl-l') this.refresh(); else this.onKey(k); }
|
|
93
|
+
};
|
|
81
94
|
this._resize = () => this.refresh();
|
|
82
95
|
stdin.on('data', this._data);
|
|
83
96
|
stdout.on('resize', this._resize);
|
|
@@ -89,22 +102,54 @@ export class RealTerm {
|
|
|
89
102
|
if (stdin.isTTY) stdin.setRawMode(false);
|
|
90
103
|
stdin.pause();
|
|
91
104
|
}
|
|
92
|
-
refresh() { this.last = []; this.onResize(); }
|
|
93
|
-
/** Paint a whole frame. Lines are fitted to the width;
|
|
94
|
-
* changed (a new screen), which repaints it all so a stale row cannot
|
|
95
|
-
* column stays blank so a terminal without deferred wrap never scrolls
|
|
105
|
+
refresh() { this.last = []; this.lastImages = []; this.onResize(); }
|
|
106
|
+
/** Paint a whole frame. Lines are fitted to the width; of a changed line only the cells that changed are
|
|
107
|
+
* written, unless most of the frame changed (a new screen), which repaints it all so a stale row cannot
|
|
108
|
+
* outlive a screen change. The last column stays blank so a terminal without deferred wrap never scrolls
|
|
109
|
+
* when the bottom row is painted. Sixel placeholders (sixel.mjs) are taken out of the lines and their images
|
|
110
|
+
* sent after the text: an image that moved or went has its old rows erased, and one goes out again only when
|
|
111
|
+
* a cell written this frame lies on its pixels (text in a hole, a count on an icon, leaves it be). The frame
|
|
112
|
+
* is one synchronized update (mode 2026), so a terminal that knows it never shows the half-drawn state; one
|
|
113
|
+
* that does not ignores it. */
|
|
96
114
|
draw(lines) {
|
|
97
115
|
if (this.tooSmall) { this.drawTooSmall(); return; }
|
|
98
116
|
const w = this.cols - 1, h = this.rows;
|
|
99
|
-
const next = [];
|
|
100
|
-
for (let i = 0; i < h; i++) next.push(fit(lines[i] ?? '', w));
|
|
101
|
-
|
|
117
|
+
const next = [], placed = new Map(); // `id@x,y` -> { id, x, y, bottom }: the same picture can stand twice
|
|
118
|
+
for (let i = 0; i < h; i++) next.push(takeMarks(fit(lines[i] ?? '', w), i, placed));
|
|
119
|
+
// An image goes out only when its bottom row made it to the screen: a pane that cut it short (a detail
|
|
120
|
+
// pane's last rows) would otherwise have it hang over what is below. Text drawn over its upper rows (a
|
|
121
|
+
// hit rising from the chest) can take a marker; the bottom row says where the image is all the same.
|
|
122
|
+
const images = [...placed.values()].filter((im) => im.bottom);
|
|
123
|
+
const same = (a, b) => a.id === b.id && a.x === b.x && a.y === b.y;
|
|
124
|
+
const erase = new Set();
|
|
125
|
+
for (const p of this.lastImages ?? []) {
|
|
126
|
+
if (images.some((im) => same(im, p))) continue;
|
|
127
|
+
for (let r = 0; r < (imageOf(p.id)?.rows ?? 0); r++) erase.add(p.y + r);
|
|
128
|
+
}
|
|
129
|
+
const changed = next.filter((line, i) => this.last[i] !== line || erase.has(i)).length;
|
|
102
130
|
const full = !this.last.length || changed * 2 > h;
|
|
103
131
|
// Every row is padded to the width, so only a frame with nothing known on screen needs a clear first.
|
|
104
132
|
let out = this.last.length ? '' : `${ESC}0m${ESC}H${ESC}2J`;
|
|
105
|
-
|
|
133
|
+
const wrote = new Map(); // row -> [from, to) column spans written
|
|
134
|
+
next.forEach((line, i) => {
|
|
135
|
+
if (full || erase.has(i)) {
|
|
136
|
+
out += `${ESC}${i + 1};1H${ESC}2K${ESC}0m${line}`;
|
|
137
|
+
wrote.set(i, [[0, w]]);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (this.last[i] === line) return;
|
|
141
|
+
const spans = changedSpans(this.last[i], line);
|
|
142
|
+
for (const [from, to, text] of spans) out += `${ESC}${i + 1};${from + 1}H${ESC}0m${text}`;
|
|
143
|
+
wrote.set(i, spans);
|
|
144
|
+
});
|
|
145
|
+
for (const im of images) {
|
|
146
|
+
const img = imageOf(im.id);
|
|
147
|
+
const known = (this.lastImages ?? []).some((p) => same(im, p));
|
|
148
|
+
if (!known || covers(img, im, wrote)) out += `${ESC}${im.y + 1};${im.x + 1}H${img.sixel}`;
|
|
149
|
+
}
|
|
106
150
|
this.last = next;
|
|
107
|
-
|
|
151
|
+
this.lastImages = images;
|
|
152
|
+
if (out) stdout.write(`${ESC}?2026h${out}${ESC}?2026l`);
|
|
108
153
|
}
|
|
109
154
|
/** The screens need MIN_COLS x MIN_ROWS; a smaller terminal gets a notice instead of a frame drawn off its edge. */
|
|
110
155
|
drawTooSmall() {
|
|
@@ -121,6 +166,55 @@ export class RealTerm {
|
|
|
121
166
|
clearInterval(h) { clearInterval(h); }
|
|
122
167
|
}
|
|
123
168
|
|
|
169
|
+
/** The runs of cells that differ between two painted lines of one width, as [from, to, text]: `text` paints
|
|
170
|
+
* columns from..to-1 of `line`, each cell carrying its own paint. */
|
|
171
|
+
export function changedSpans(prev, line) {
|
|
172
|
+
const a = splitCells(prev ?? ''), b = splitCells(line);
|
|
173
|
+
const spans = [];
|
|
174
|
+
for (let x = 0; x < b.length; x++) {
|
|
175
|
+
if (a[x] === b[x]) continue;
|
|
176
|
+
let end = x + 1;
|
|
177
|
+
while (end < b.length && a[end] !== b[end]) end++;
|
|
178
|
+
spans.push([x, end, b.slice(x, end).join('')]);
|
|
179
|
+
x = end;
|
|
180
|
+
}
|
|
181
|
+
return spans;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Does a written span lie on the pixels of image `img` placed at `im` (a hole's cells are no pixels)? */
|
|
185
|
+
function covers(img, im, wrote) {
|
|
186
|
+
for (let r = 0; r < img.rows; r++) {
|
|
187
|
+
for (const [from, to] of wrote.get(im.y + r) ?? []) {
|
|
188
|
+
for (let x = Math.max(from, im.x); x < Math.min(to, im.x + img.cols); x++) {
|
|
189
|
+
const cx = x - im.x;
|
|
190
|
+
if (!img.holes.some((h) => h.y === r && cx >= h.x && cx < h.x + h.w)) return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `line` without its sixel markers; each marker found notes a placement's top-left cell in `placed` (a row's
|
|
198
|
+
* marker sits at the image's left edge, and its row number says how far down the image the line is). */
|
|
199
|
+
export function takeMarks(line, y, placed) {
|
|
200
|
+
if (!line.includes('\x1b[9999;')) return line;
|
|
201
|
+
let out = '', col = 0, i = 0;
|
|
202
|
+
for (const m of line.matchAll(MARK_RE)) {
|
|
203
|
+
const before = line.slice(i, m.index);
|
|
204
|
+
out += before;
|
|
205
|
+
col += before.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
206
|
+
const id = +m[1], row = +m[2], img = imageOf(id);
|
|
207
|
+
if (img) {
|
|
208
|
+
const at = `${id}@${col},${y - row}`;
|
|
209
|
+
const p = placed.get(at) ?? { id, x: col, y: y - row, bottom: false };
|
|
210
|
+
if (row === img.rows - 1) p.bottom = true;
|
|
211
|
+
placed.set(at, p);
|
|
212
|
+
}
|
|
213
|
+
i = m.index + m[0].length;
|
|
214
|
+
}
|
|
215
|
+
return out + line.slice(i);
|
|
216
|
+
}
|
|
217
|
+
|
|
124
218
|
export class HeadlessTerm {
|
|
125
219
|
constructor(cols = 100, rows = 32) {
|
|
126
220
|
this.cols = cols; this.rows = rows;
|
|
@@ -132,7 +226,7 @@ export class HeadlessTerm {
|
|
|
132
226
|
}
|
|
133
227
|
open() {}
|
|
134
228
|
close() {}
|
|
135
|
-
draw(lines) { this.frame = lines.slice(0, this.rows).map((l) => fit(l ?? '', this.cols)); this.frames++; }
|
|
229
|
+
draw(lines) { this.frame = lines.slice(0, this.rows).map((l, i) => takeMarks(fit(l ?? '', this.cols), i, new Map())); this.frames++; }
|
|
136
230
|
/** Feed keys one at a time, as `decodeKeys` would produce them. */
|
|
137
231
|
press(...keys) { for (const k of keys) this.onKey(k); }
|
|
138
232
|
/** Type a string as individual character keys, then Enter. */
|
package/src/cli/text.mjs
CHANGED
|
@@ -16,8 +16,10 @@ export const orange = sgr('38;5;208');
|
|
|
16
16
|
export const QUALITY = ['poor', 'common', 'uncommon', 'rare', 'epic', 'legendary', 'artifact'];
|
|
17
17
|
// The client's item quality colours (24-bit): grey poor, white common, green uncommon, blue rare,
|
|
18
18
|
// purple epic, orange legendary, light gold artifact.
|
|
19
|
-
const
|
|
20
|
-
const QUALITY_PAINT =
|
|
19
|
+
const QUALITY_RGB = [[157, 157, 157], [255, 255, 255], [30, 255, 0], [0, 112, 221], [163, 53, 238], [255, 128, 0], [230, 204, 128]];
|
|
20
|
+
const QUALITY_PAINT = QUALITY_RGB.map(([r, g, b]) => sgr(`38;2;${r};${g};${b}`));
|
|
21
|
+
/** The quality colour as a bare SGR code, for a painter that builds its own escapes (an icon frame). */
|
|
22
|
+
export function qualityCodes(quality) { const [r, g, b] = QUALITY_RGB[quality ?? 1] ?? QUALITY_RGB[1]; return `38;2;${r};${g};${b}`; }
|
|
21
23
|
/** An item name in its 1.12 quality colour. */
|
|
22
24
|
export function qualityPaint(quality) { return QUALITY_PAINT[quality ?? 1] ?? white; }
|
|
23
25
|
/** An item record's name in its quality colour; `fallback` when the record is missing. */
|
|
@@ -51,7 +53,7 @@ export function wrapLines(text, width) {
|
|
|
51
53
|
let open = '';
|
|
52
54
|
return out.map((l) => {
|
|
53
55
|
const line = open + l;
|
|
54
|
-
for (const m of l.match(SGR_RE) ?? []) open = m === '\x1b[0m' || m === '\x1b[m' ? '' : open + m;
|
|
56
|
+
for (const m of l.match(SGR_RE) ?? []) if (!m.startsWith('\x1b[9999;')) open = m === '\x1b[0m' || m === '\x1b[m' ? '' : open + m; // a sixel marker is no paint
|
|
55
57
|
return open ? `${line}\x1b[0m` : line;
|
|
56
58
|
});
|
|
57
59
|
}
|