azeroth-shell 0.1.2 → 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 +9 -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 +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 +32 -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 +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- 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 +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- 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 +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- 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
package/src/cli/tuiFight.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// Live combat and the animated autopilot for the windowed game. A fight is the engine's real-time
|
|
2
|
-
// session (Game.beginFight) ticked every 100 ms,
|
|
3
|
-
//
|
|
4
|
-
// bar
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// session (Game.beginFight) ticked every 100 ms. A duel (you, maybe your pet, the foes) is drawn on a stage
|
|
3
|
+
// (duelStage): the sprites face each other, each unit's plate sits under its feet, hits rise from the chest,
|
|
4
|
+
// your cast bar is centred under the floor and the keyed ability bar runs along the bottom. A party fight or
|
|
5
|
+
// a narrow terminal falls back to the list of unit frames (renderFrames). Auto-play runs the character's
|
|
6
|
+
// rotation; a number key presses an ability over it for one GCD.
|
|
7
|
+
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange } from './text.mjs';
|
|
8
|
+
import { className } from '../domain/core/ids.ts';
|
|
9
|
+
import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
|
|
10
|
+
import { partyStage } from './tuiPartyStage.mjs';
|
|
11
|
+
import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
|
|
12
|
+
import { formatCombatLog, combatLogPlainText, dropLogRepeats, POWER_MERGE_MS } from './combatLogFormat.ts';
|
|
7
13
|
import { impliedDest } from './combatLogFilter.ts';
|
|
8
14
|
import { moneyText } from '../domain/economy/money.ts';
|
|
9
15
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
@@ -12,11 +18,15 @@ import { pullOpts, sessionLogNames } from './squad.mjs';
|
|
|
12
18
|
import { slotContent, visibleBars } from './tuiBars.mjs';
|
|
13
19
|
import { autoLootOn, openLoot } from './tuiLoot.mjs';
|
|
14
20
|
import { createCombatMacroHost } from './macroHostCombat.ts';
|
|
15
|
-
import { ensureMeter, noteLoot, cycleFightPane, cycleMeterSort, toggleMeterSegment, fightPane } from './tuiFightPanes.mjs';
|
|
16
|
-
import { objectiveLines } from './tuiViews.mjs';
|
|
21
|
+
import { ensureMeter, noteLoot, cycleFightPane, cycleMeterSort, toggleMeterSegment, fightPane, nextPaneId } from './tuiFightPanes.mjs';
|
|
22
|
+
import { objectiveLines, playerArt } from './tuiViews.mjs';
|
|
23
|
+
import { runPanelLines, endCard, stopText, stepBody } from './tuiAutopilot.mjs';
|
|
24
|
+
import { creatureArt, artWidth } from './modelArt.mjs';
|
|
25
|
+
import { iconArt, iconsOn } from './iconArt.mjs';
|
|
17
26
|
|
|
18
27
|
export const TICK_MS = 100; // one engine tick per 100 ms of real time, like the web client
|
|
19
|
-
|
|
28
|
+
// A win holds the fight on screen this long (real time), plus a beat for each drop as it slides in.
|
|
29
|
+
const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MAX_MS = 3500;
|
|
20
30
|
|
|
21
31
|
/** Start a live fight. Returns `{ ok: true, ctl }` or `{ ok: false, reason }`. `onEnd(outcome)` runs after the
|
|
22
32
|
* fight resolves (outcome null when the player ran); `label` names who is driving (the autopilot). `begin` replaces
|
|
@@ -43,7 +53,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
43
53
|
auto: true, paused: false, speed: app.fightSpeed ?? 1, pressed: null, pressedTicks: 0, follow: [], focus: null, macros: null, timer: null, pend: null, ended: false,
|
|
44
54
|
frames: newFrameMemory(app), compact: false, squeeze: 0,
|
|
45
55
|
names: sessionLogNames(session, { playerName: app.ch.name, enemyName: b.enemyName, spellName: (id) => app.ds.spell(id)?.name }),
|
|
46
|
-
hint() { return `${this.plain ? (app.farmEntry === creatureEntry ? 'r: stop repeating ' : 'r: fight again after ') : ''}${this.auto ? '
|
|
56
|
+
hint() { if (this.holding) return `Enter, Esc: go on Tab: ${nextPaneId(app)} l: log`; return `${this.plain ? (app.farmEntry === creatureEntry ? 'r: stop repeating ' : 'r: fight again after ') : ''}1-0: your action bar a: ${this.auto ? 'manual play' : 'auto-play'}${session.alliesView().length ? ' t: orders' : ''} c: cancel a buff${app.g.state.instance ? ' p: pull +1' : ''} space: ${this.paused ? 'resume' : 'pause'} + -: speed ${this.speed}x Tab: ${nextPaneId(app)} l: log ${this.label === 'autopilot' ? 'w, Esc: back to the run panel' : 'Esc: run away'}`; },
|
|
47
57
|
schedule() {
|
|
48
58
|
if (this.timer) app.term.clearInterval(this.timer);
|
|
49
59
|
this.timer = app.term.setInterval(() => this.tick(), Math.max(10, TICK_MS / this.speed));
|
|
@@ -51,6 +61,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
51
61
|
stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; },
|
|
52
62
|
/** One engine tick (the check script calls this by hand). */
|
|
53
63
|
tick() {
|
|
64
|
+
if (this.holding && !this.paused) { if (++this.heldTicks >= this.holding.ticks) this.release(); app.renderSoon(); return; }
|
|
54
65
|
if (this.ended || this.paused) return;
|
|
55
66
|
let action;
|
|
56
67
|
// A macro's later steps go out one per tick once the step before has left the queue.
|
|
@@ -73,12 +84,28 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
73
84
|
if (session.ended) this.finish();
|
|
74
85
|
app.renderSoon();
|
|
75
86
|
},
|
|
87
|
+
/** A win holds the fight on screen a moment before it gives the screen back: the foe at 0 on its plate and
|
|
88
|
+
* what it dropped sliding in beside it (putLootToasts). The timer keeps ticking through it, so the hold is
|
|
89
|
+
* counted in ticks: the speed keys and the check script drive it like the fight. */
|
|
90
|
+
holding: null, heldTicks: 0,
|
|
91
|
+
/** The stage's clock: the fight's own, running on through the hold so the last hits finish rising. */
|
|
92
|
+
clock() { return session.t + this.heldTicks * TICK_MS; },
|
|
93
|
+
/** Real milliseconds into the hold (the toasts' clock). */
|
|
94
|
+
holdMs() { return this.heldTicks * (TICK_MS / this.speed); },
|
|
95
|
+
/** The drops in one line, for the frames list when the stage does not fit. Null outside a hold or with none. */
|
|
96
|
+
lootLine() {
|
|
97
|
+
const drops = this.holding ? lootToasts(app, this.holding) : [];
|
|
98
|
+
return drops.length ? ` ${cyan('Loot')} ${drops.map((d) => d.text).join(dim(' · '))}` : null;
|
|
99
|
+
},
|
|
100
|
+
/** At 4x and up under the autopilot nobody reads the ticks: the log speaks in fights (the Victory line). */
|
|
101
|
+
folded() { return this.label === 'autopilot' && this.speed >= 4; },
|
|
76
102
|
logEvents(events) {
|
|
77
103
|
app.meter?.ingest(events);
|
|
78
104
|
notePops(this.frames, events);
|
|
105
|
+
if (this.folded()) return;
|
|
79
106
|
const flush = () => { if (this.pend && this.pend.amount > 0) app.say(line(this.pend.t, this.pend.remake(this.pend.amount))); this.pend = null; };
|
|
80
|
-
const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${
|
|
81
|
-
for (const ev of events) {
|
|
107
|
+
const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(row)}`;
|
|
108
|
+
for (const ev of dropLogRepeats(events)) {
|
|
82
109
|
const row = formatCombatLog(ev, this.names);
|
|
83
110
|
if (!row) continue;
|
|
84
111
|
if (row.merge) {
|
|
@@ -93,14 +120,12 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
93
120
|
if (this.pend && session.t - this.pend.t0 >= POWER_MERGE_MS) flush();
|
|
94
121
|
},
|
|
95
122
|
finish() {
|
|
123
|
+
if (this.ended) return;
|
|
96
124
|
this.ended = true;
|
|
97
|
-
this.
|
|
98
|
-
if (this.pend) { if (this.pend.amount > 0) app.say(`${dim((this.pend.t / 1000).toFixed(1).padStart(5) + 's')} ${combatLogPlainText(this.pend.remake(this.pend.amount))}`); this.pend = null; }
|
|
125
|
+
if (this.pend) { if (this.pend.amount > 0) app.say(`${dim((this.pend.t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(this.pend.remake(this.pend.amount))}`); this.pend = null; }
|
|
99
126
|
// Auto-loot off: the corpse holds its drops and coin for the loot window. The autopilot always takes them.
|
|
100
127
|
const hold = !this.resolve && this.label !== 'autopilot' && !autoLootOn(app);
|
|
101
128
|
const r = this.resolve ? this.resolve(session) : app.g.resolveFight(session, hold ? { holdLoot: true } : undefined);
|
|
102
|
-
app.fight = null;
|
|
103
|
-
app.stack = this.savedStack;
|
|
104
129
|
this.after?.(r);
|
|
105
130
|
const tag = r.outcome === 'win' ? green(bold('Victory')) : r.outcome === 'defeat' ? red(bold('Defeat')) : yellow(bold('Stalemate'));
|
|
106
131
|
const bits = [`${(r.result.durationMs / 1000).toFixed(1)} s`, `HP ${app.hpText()}`];
|
|
@@ -114,6 +139,23 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
114
139
|
if (r.outcome === 'win') app.flashMsg(`${this.enemyName} dies.${r.xpGained ? ` +${r.xpGained} xp` : ''}`);
|
|
115
140
|
// The lines above already tell the story; the engine's activity rows for this fight would repeat it.
|
|
116
141
|
app.lastActivity = app.g.state.activity[0] ?? null;
|
|
142
|
+
this.done = { r, hold };
|
|
143
|
+
if (r.outcome !== 'win') { this.release(); return; }
|
|
144
|
+
// Real time: a second, a beat more for each drop as it slides in; at 4x and up it shortens with the speed.
|
|
145
|
+
const loot = hold ? [] : r.loot, copper = hold ? 0 : r.goldGained ?? 0;
|
|
146
|
+
const pace = Math.max(1, this.speed / 2), ms = Math.min(WIN_HOLD_MAX_MS, WIN_HOLD_MS + (loot.length + (copper ? 1 : 0)) * DROP_HOLD_MS) / pace;
|
|
147
|
+
this.holding = { loot, copper, until: ms, stagger: DROP_STAGGER_MS / pace, ticks: Math.max(1, Math.ceil(ms / (TICK_MS / this.speed))) };
|
|
148
|
+
app.renderSoon();
|
|
149
|
+
},
|
|
150
|
+
/** The fight is over and its hold too: the screen goes back to whoever opened the fight. */
|
|
151
|
+
release() {
|
|
152
|
+
if (!this.done || app.fight !== this) return;
|
|
153
|
+
const { r, hold } = this.done;
|
|
154
|
+
this.holding = null;
|
|
155
|
+
this.stop();
|
|
156
|
+
app.fight = null;
|
|
157
|
+
app.lastFight = this; // the meter and threat panes keep reading it between autopilot fights
|
|
158
|
+
app.stack = this.savedStack;
|
|
117
159
|
if (hold) openLoot(app);
|
|
118
160
|
app.afterAction();
|
|
119
161
|
this.onEnd?.(r);
|
|
@@ -123,6 +165,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
123
165
|
}
|
|
124
166
|
},
|
|
125
167
|
flee() {
|
|
168
|
+
if (this.holding) { this.release(); return; }
|
|
126
169
|
if (this.resolve) { session.forfeit('player'); for (let i = 0; i < 50 && !session.ended; i++) session.tick(); this.finish(); return; }
|
|
127
170
|
this.ended = true;
|
|
128
171
|
this.stop();
|
|
@@ -136,6 +179,11 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
136
179
|
this.onEnd?.(null);
|
|
137
180
|
},
|
|
138
181
|
key(k) {
|
|
182
|
+
// Over a win's hold Enter or Esc goes on at once; the panes, the log and the speed still answer.
|
|
183
|
+
if (this.holding) {
|
|
184
|
+
if (k === 'escape' || k === 'return') { this.release(); return; }
|
|
185
|
+
if (!['space', 'tab', 'shift-tab', 'l', 'pageup', 'pagedown', '+', '=', '-'].includes(k)) return;
|
|
186
|
+
}
|
|
139
187
|
if (k === 'escape') { this.flee(); return; }
|
|
140
188
|
if (k === 'space') { this.paused = !this.paused; app.render(); return; }
|
|
141
189
|
if (k === 'a') { this.auto = !this.auto; session.aiPlayer = this.auto; app.say(dim(this.auto ? 'Auto-play on: your rotation plays.' : 'Auto-play off: you press every ability.')); app.render(); return; }
|
|
@@ -233,22 +281,78 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
233
281
|
if (out.length) return out;
|
|
234
282
|
return abs.filter((x) => !x.spell.passive).slice(0, 9).map((ab, i) => ({ key: String(i + 1), spell: ab.spell, label: ab.spell.name, usable: ab.usable, reason: ab.reason, cooldownRemainingMs: ab.cooldownRemainingMs }));
|
|
235
283
|
},
|
|
236
|
-
|
|
237
|
-
|
|
284
|
+
/** The title line (who, how long, who drives) with why the fight is on its right, and the objective line under it. */
|
|
285
|
+
headLines(W, inline = false) {
|
|
238
286
|
const t = (session.t / 1000).toFixed(1);
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
// One badge says who is driving. Under the autopilot the rotation flag only shows when you took it over;
|
|
288
|
+
// inside the run panel the panel's title already says who drives, so the head keeps to the fight.
|
|
289
|
+
const run = this.label === 'autopilot' ? app.auto : null;
|
|
290
|
+
const mode = inline ? (this.auto ? '' : yellow('you play'))
|
|
291
|
+
: run ? `${cyan(bold('AUTOPILOT'))}${this.speed !== 1 ? cyan(` ${this.speed}x`) : ''}${this.auto ? '' : yellow(' you play')}${dim(` fight ${run.fights + 1} · step ${run.planner.step}${Number.isFinite(run.planner.steps) ? `/${run.planner.steps}` : ''}`)}`
|
|
292
|
+
: `${this.auto ? green('AUTO') : yellow('MANUAL')}${this.speed !== 1 ? dim(` ${this.speed}x`) : ''}${this.label ? cyan(` ${this.label}`) : ''}`;
|
|
293
|
+
let head = ` ${bold(`Fighting ${this.enemyName}`)} ${dim(`${t}s`)}${mode ? ` ${mode}` : ''}${this.paused ? red(' PAUSED') : ''}`;
|
|
241
294
|
// Why this fight, on the right of the title: the quest it serves, with its objective counters on the line below.
|
|
242
295
|
const why = this.why?.();
|
|
243
296
|
if (why?.right && W - vis(head) - 4 >= 12) { const r = cut(why.right, W - vis(head) - 4); head = fit(head, W - vis(r) - 1) + r + ' '; }
|
|
244
297
|
const below = why?.below ? cut(` ${why.below}`, W) : '';
|
|
298
|
+
return { head, below };
|
|
299
|
+
},
|
|
300
|
+
/** The fight drawn inside the autopilot's run panel, so a pull never swaps the screen: the head, the objective
|
|
301
|
+
* line, the enemies, then the party one line each, cut to `room` lines. `player` is the live player frame for
|
|
302
|
+
* the panel's own slot. w opens the full view (render) with the party frames and the ability bar. */
|
|
303
|
+
inlineLines(W, room) {
|
|
304
|
+
const now = this.clock();
|
|
305
|
+
// No head line: the panel's title names the quest and its counts, the enemy frame names the foe. What is
|
|
306
|
+
// left of the head (the clock, paused, you play) goes on the title's right as `status`.
|
|
307
|
+
const status = `${dim(`${(now / 1000).toFixed(1)}s`)}${this.auto ? '' : yellow(' you play')}${this.paused ? red(' PAUSED') : ''}`;
|
|
308
|
+
const enemies = session.enemiesView(), allies = session.alliesView(), pet = session.petView();
|
|
309
|
+
// `player` is the live frame for the panel's own slot (the run's header has no bars of its own).
|
|
310
|
+
const player = unitFrame(app, this.frames, session.playerView(), W, now, true, false, {});
|
|
311
|
+
const foes = (compact) => enemies.flatMap((e) => unitFrame(app, this.frames, e, W, now, false, e === session.enemyView(), { compact }));
|
|
312
|
+
const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
|
|
313
|
+
const party = [...(pet ? [pet] : []), ...allies].map((v) => {
|
|
314
|
+
const t = v !== pet && v.hp > 0 ? session.targetOf?.(v.side) : null;
|
|
315
|
+
return unitFrame(app, this.frames, v, W, now, false, false, { compact: true, party: v !== pet, role: v.groupRole, target: t ? nameOfSide(t) : null })[0];
|
|
316
|
+
});
|
|
317
|
+
let fight = [...foes(enemies.length > 1), ...party];
|
|
318
|
+
if (fight.length > room) {
|
|
319
|
+
const shown = Math.max(0, room - foes(true).length);
|
|
320
|
+
fight = [...foes(true), ...party.slice(0, shown), ...(party.length > shown ? [dim(` +${party.length - shown} more in the party`)] : [])];
|
|
321
|
+
}
|
|
322
|
+
return { player, status, fight: fight.slice(0, Math.max(1, room)) };
|
|
323
|
+
},
|
|
324
|
+
/** The fight on its stage when the scene fits, 90 columns and up: a fight without allies on the duel stage
|
|
325
|
+
* (duelStage), one with the party on the party stage (partyStage, every body small on one floor). Null when
|
|
326
|
+
* the rows or the columns leave the sprites no room, and the frames list draws instead. `inline` is the
|
|
327
|
+
* autopilot's run panel, where the number keys browse tabs, so the ability bar shows its keys dim. */
|
|
328
|
+
stage(W, H, { inline = false } = {}) {
|
|
329
|
+
if (W < 90) return null;
|
|
330
|
+
const lines = session.alliesView().length ? partyStage(app, this, W, H, { inline }) : duelStage(app, this, W, H, { inline });
|
|
331
|
+
if (lines && this.holding) putLootToasts(app, lines, this.holding, W, this.holdMs());
|
|
332
|
+
return lines;
|
|
333
|
+
},
|
|
334
|
+
/** The full fight view: the head line, then the stage; or the frames between the sprites (the foe down the
|
|
335
|
+
* right, you in your gear down the left, fightArt) when the stage does not fit. */
|
|
336
|
+
render(W, H) {
|
|
337
|
+
const stage = this.stage(W, H - 1);
|
|
338
|
+
if (stage) return [this.headLines(W).head, ...stage];
|
|
339
|
+
const { me, foe, innerW } = fightArt(app, this, W, H);
|
|
340
|
+
const frames = this.renderFrames(innerW, H), loot = this.lootLine();
|
|
341
|
+
if (loot) frames.splice(1, 0, loot);
|
|
342
|
+
return flankArt(frames.slice(0, H), me, foe, W, H);
|
|
343
|
+
},
|
|
344
|
+
renderFrames(W, H) {
|
|
345
|
+
const lines = [];
|
|
346
|
+
const { head, below } = this.headLines(W);
|
|
245
347
|
const pv = session.playerView(), now = session.t;
|
|
246
348
|
const pet = session.petView();
|
|
247
349
|
const allies = session.alliesView();
|
|
248
350
|
const enemies = session.enemiesView();
|
|
249
351
|
// Four companions and their pets do not fit two lines each: once the party outgrows the pane every
|
|
250
352
|
// ally drops to one line for the rest of the fight, so frames never jump between the two shapes.
|
|
251
|
-
|
|
353
|
+
// With icons a full frame is three lines (the aura icons and their times) and the ability bar two.
|
|
354
|
+
const fh = iconsOn() ? 3 : 2;
|
|
355
|
+
const need = 3 + fh * (1 + (pet ? 1 : 0) + allies.length + enemies.length) + (this.auto ? 0 : fh + 1);
|
|
252
356
|
if (need > H) this.compact = true;
|
|
253
357
|
const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
|
|
254
358
|
const party = [];
|
|
@@ -278,10 +382,44 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
278
382
|
lines.push(...foeLines, ...tail);
|
|
279
383
|
return lines.slice(0, H);
|
|
280
384
|
},
|
|
281
|
-
/**
|
|
282
|
-
|
|
385
|
+
/** The ability bar: numbered, dim when unusable. With icons each slot is `cols / 2` lines (a 4 or 6 pixel
|
|
386
|
+
* icon) of a fixed width, so nothing on the bar moves between ticks: the key, the icon and the name, and
|
|
387
|
+
* under the name only a cooldown (seconds), the cast in progress, or nothing. The slot of a spell just
|
|
388
|
+
* used flashes. `live` false (the autopilot's panel, where digits browse tabs) keeps the keys dim and says
|
|
389
|
+
* what takes them. Slots fold onto more rows when W is short. */
|
|
390
|
+
abilityRows(W, cols = 4, { live = true } = {}) {
|
|
283
391
|
const lines = [];
|
|
284
392
|
const abs = this.abilityBar();
|
|
393
|
+
if (iconsOn()) {
|
|
394
|
+
const now = this.clock(), mem = this.frames, casting = session.playerView().casting;
|
|
395
|
+
const rows = cols / 2, blank = Array.from({ length: rows }, () => ' '.repeat(cols)), gap = ' '.repeat(4);
|
|
396
|
+
const nameW = 14, slotW = 4 + cols + 1 + nameW;
|
|
397
|
+
const slot = (key, art, label, state) => [`${key} ${art[0]} ${fit(label, nameW)}`, `${gap}${art[1]} ${fit(state, nameW)}`, ...art.slice(2).map((l) => `${gap}${l}`)];
|
|
398
|
+
const cells = abs.map((ab) => {
|
|
399
|
+
const icon = ab.spell ? ab.spell.icon : ab.item != null ? app.ds.item(ab.item)?.icon : null;
|
|
400
|
+
const art = iconArt(icon, cols) ?? blank;
|
|
401
|
+
const id = ab.spell?.id;
|
|
402
|
+
const isCasting = id != null && casting?.spellId === id;
|
|
403
|
+
const flash = id != null && now - (mem.used?.get(id) ?? -Infinity) < USED_FLASH_MS;
|
|
404
|
+
const cd = ab.cooldownRemainingMs > 0 ? `${Math.ceil(ab.cooldownRemainingMs / 1000)}s` : '';
|
|
405
|
+
const state = isCasting ? yellow(`${(Math.max(0, casting.endsAt - now) / 1000).toFixed(1)}s`) : cd ? dim(`◔ ${cd}`) : '';
|
|
406
|
+
const name = cut(ab.label, nameW);
|
|
407
|
+
const label = flash || (this.pressed != null && this.pressed === id) ? inverse(name) : isCasting ? yellow(name) : ab.usable ? name : dim(name);
|
|
408
|
+
const key = live && ab.usable ? inverse(` ${ab.key} `) : dim(` ${ab.key} `);
|
|
409
|
+
return slot(key, art, label, state);
|
|
410
|
+
});
|
|
411
|
+
const items = session.items().filter((x) => x.usable);
|
|
412
|
+
if (items.length) cells.push(slot(live ? inverse(' 0 ') : dim(' 0 '), iconArt(items[0].item?.icon, cols) ?? blank, cut(itemText(items[0].item), nameW), dim(`x${items[0].count}`)));
|
|
413
|
+
if (!live) cells.push([dim('w: take the keys'), '', '']);
|
|
414
|
+
let row = Array.from({ length: rows }, () => ' ');
|
|
415
|
+
for (const cell of cells) {
|
|
416
|
+
const w = Math.max(slotW, ...cell.map(vis));
|
|
417
|
+
if (vis(row[0]) + w + 3 > W && row[0].trim()) { lines.push(...row); row = Array.from({ length: rows }, () => ' '); }
|
|
418
|
+
row = row.map((r, i) => `${r}${fit(cell[i] ?? '', w)} `);
|
|
419
|
+
}
|
|
420
|
+
lines.push(...row);
|
|
421
|
+
return lines;
|
|
422
|
+
}
|
|
285
423
|
let row = ' ';
|
|
286
424
|
for (let i = 0; i < abs.length; i++) {
|
|
287
425
|
const ab = abs[i];
|
|
@@ -301,8 +439,8 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
301
439
|
};
|
|
302
440
|
session.aiPlayer = true;
|
|
303
441
|
app.fight = ctl;
|
|
304
|
-
|
|
305
|
-
if (
|
|
442
|
+
// An autopilot fight draws inside the run panel (or under the run strip), so whatever you were reading stays up.
|
|
443
|
+
if (label !== 'autopilot') app.stack = [];
|
|
306
444
|
app.say(cyan(`You attack ${b.enemyName}.`) + (b.packAssist?.length ? yellow(` ${b.packAssist.map((p) => p.name).join(', ')} join${b.packAssist.length === 1 ? 's' : ''} the fight!`) : ''));
|
|
307
445
|
ctl.schedule();
|
|
308
446
|
app.render();
|
|
@@ -399,23 +537,41 @@ function partyOrders(app, ctl) {
|
|
|
399
537
|
});
|
|
400
538
|
}
|
|
401
539
|
|
|
540
|
+
/** A combat log row with its parts painted, like a classic client's log: you green, friends and pets cyan, foes
|
|
541
|
+
* yellow, spells cyan, damage red, healing green. The words between stay plain so the line still reads. */
|
|
542
|
+
function paintLog(row) {
|
|
543
|
+
return row.segs.map((s) => {
|
|
544
|
+
const c = s.cls ?? '';
|
|
545
|
+
if (c.includes('tw-clog-amt')) return c.includes('sch-heal') ? green(s.text) : red(s.text);
|
|
546
|
+
if (c.includes('tw-clog-u-mine')) return green(s.text);
|
|
547
|
+
if (c.includes('tw-clog-u-friend') || c.includes('tw-clog-u-pet')) return cyan(s.text);
|
|
548
|
+
if (c.includes('tw-clog-u-hostile')) return yellow(s.text);
|
|
549
|
+
if (c.includes('tw-clog-sp')) return cyan(s.text);
|
|
550
|
+
return s.text;
|
|
551
|
+
}).join('');
|
|
552
|
+
}
|
|
553
|
+
|
|
402
554
|
const CAST_W = 30; // the cast/control slot on a frame's second line, so auras never move when a cast starts
|
|
403
555
|
|
|
404
|
-
/** Per-fight memory that keeps frames steady:
|
|
405
|
-
*
|
|
556
|
+
/** Per-fight memory that keeps frames steady: the order auras first showed up in, so a refreshed buff keeps
|
|
557
|
+
* its place instead of jumping to the end. */
|
|
406
558
|
function newFrameMemory(app) {
|
|
407
|
-
// The player's own
|
|
559
|
+
// The player's own aura order carries across fights, so their frame never reshuffles.
|
|
408
560
|
app.playerAuraOrder ??= new Map();
|
|
409
|
-
return {
|
|
561
|
+
return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map() };
|
|
410
562
|
}
|
|
411
563
|
|
|
412
|
-
const
|
|
564
|
+
const POP_W = 18; // a hit or heal stays beside the bars for POP_MS (fight time), in a slot this wide
|
|
565
|
+
const USED_FLASH_MS = 500; // the ability bar slot of a spell just cast stays inverted this long
|
|
413
566
|
const MISS_WORD = { miss: 'Miss', dodge: 'Dodge', parry: 'Parry', immune: 'Immune', deflect: 'Deflect', block: 'Block' };
|
|
414
567
|
|
|
415
|
-
/** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars
|
|
568
|
+
/** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars; the
|
|
569
|
+
* player's casts, for the flash on the ability bar; and every melee swing, for the lunge. */
|
|
416
570
|
function notePops(mem, events) {
|
|
417
571
|
for (const ev of events) {
|
|
418
572
|
let side, text;
|
|
573
|
+
if (ev.actor === 'player' && ev.spellId != null && (ev.kind === 'cast_start' || ev.kind === 'cast_finish' || ev.kind === 'spell_hit' || ev.kind === 'cast_self')) mem.used?.set(ev.spellId, ev.t);
|
|
574
|
+
if (ev.kind === 'melee' && ev.actor) mem.lunge?.set(ev.actor, ev.t); // only a weapon swing lunges; spells, strikes and thorns do not
|
|
419
575
|
if (ev.kind === 'melee' || ev.kind === 'spell_hit' || ev.kind === 'dot_tick') {
|
|
420
576
|
side = ev.target ?? impliedDest(ev.actor);
|
|
421
577
|
if (ev.amount > 0) text = ev.result === 'crit' ? bold(red(`-${ev.amount}!`)) : red(`-${ev.amount}`);
|
|
@@ -439,12 +595,18 @@ function popText(mem, key, now) {
|
|
|
439
595
|
return out;
|
|
440
596
|
}
|
|
441
597
|
|
|
442
|
-
const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
|
|
443
|
-
|
|
444
598
|
/** Two lines: name/level/HP/power, then a fixed cast slot + auras. `now` is the clock `casting` runs on. A party
|
|
445
599
|
* frame carries its role letter and target; `compact` keeps it to the first line with the cast in it. */
|
|
446
|
-
function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = false, party = false, role, target } = {}) {
|
|
600
|
+
function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = false, party = false, role, target, bars = true } = {}) {
|
|
447
601
|
const dead = v.hp <= 0;
|
|
602
|
+
// No bars (the header already shows them): one line of name, cast slot, hits landing and auras.
|
|
603
|
+
if (!bars) {
|
|
604
|
+
const key = isPlayer ? 'player' : (v.side ?? v.name);
|
|
605
|
+
const name = `${(dead ? gray : isPlayer ? green : yellow)(bold(v.name))} ${dim(`L${v.level}`)}`;
|
|
606
|
+
const slot = castSlot(v, now);
|
|
607
|
+
const auras = auraText(app, mem, v, key);
|
|
608
|
+
return [cut(` ${fit(name, 20)} ${fit(slot, CAST_W)} ${fit(popText(mem, key, now), POP_W)} ${auras}`, W)];
|
|
609
|
+
}
|
|
448
610
|
const namePaint = dead ? gray : isPlayer ? green : focused ? red : party ? cyan : yellow;
|
|
449
611
|
const barW = Math.max(10, Math.min(30, Math.floor((W - 48 - POP_W) / 2)));
|
|
450
612
|
const hpBar = bar(v.hp, v.maxHp, barW, dead ? gray : v.hp / v.maxHp < 0.25 ? red : green);
|
|
@@ -455,50 +617,196 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
|
|
|
455
617
|
const key = isPlayer ? 'player' : (v.side ?? v.name);
|
|
456
618
|
const head = ` ${fit(`${tag}${namePaint(bold(v.name))} ${dim(`L${v.level}`)}${focused ? red(' ◀') : ''}`, 26)} ${hpBar} ${fit(`${v.hp}/${v.maxHp}`, 11)}${pwBar} ${fit(popText(mem, key, now), POP_W)}`;
|
|
457
619
|
if (compact) return [cut(`${head}${v.casting && !dead ? ` ${yellow(v.casting.name)}` : after}`, W)];
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
slot = `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
|
|
466
|
-
} else if (v.controlled) slot = red(v.controlled.control);
|
|
467
|
-
else if (v.stealthed) slot = dim('stealthed');
|
|
468
|
-
if (slot) {
|
|
469
|
-
mem.castSlot.add(key);
|
|
470
|
-
if (isPlayer) app.playerCastSlot = true; // kept across fights and the autopilot's between-fight frame
|
|
620
|
+
// The second line is always there, cast slot first (a cast bar, else crowd control, else stealth, blank when
|
|
621
|
+
// idle) and the auras after it, so gaining a buff or starting a cast never moves this frame or the ones below.
|
|
622
|
+
const slot = castSlot(v, now);
|
|
623
|
+
if (iconsOn()) {
|
|
624
|
+
// Icons: a strip of 4x4 icons beside the cast slot, each one's time (or stacks) under it on a third line.
|
|
625
|
+
const [top, bottom] = auraIcons(app, mem, v, key);
|
|
626
|
+
return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${top}`, W), cut(` ${' '.repeat(CAST_W)} ${bottom}`, W)];
|
|
471
627
|
}
|
|
628
|
+
const auras = auraText(app, mem, v, key);
|
|
629
|
+
return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${auras}`, W)];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The auras auraText lists, as two lines: icons (a 4-letter stub when one has no art) and their times. */
|
|
633
|
+
function auraIcons(app, mem, v, key) {
|
|
472
634
|
const order = mem.auraOrder.get(key) ?? new Map();
|
|
473
635
|
mem.auraOrder.set(key, order);
|
|
474
636
|
const placed = (list) => {
|
|
475
637
|
for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
|
|
476
638
|
return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
|
|
477
639
|
};
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
640
|
+
const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
|
|
641
|
+
const all = [...placed(short).slice(0, 6).map((a) => [a, green]), ...placed(v.debuffs).slice(0, 6).map((a) => [a, red])];
|
|
642
|
+
let top = '', bottom = '';
|
|
643
|
+
for (const [a, paint] of all) {
|
|
644
|
+
const sp = app.ds.spell(a.spellId);
|
|
645
|
+
const art = iconArt(sp?.icon, 4);
|
|
646
|
+
const name = sp?.name ?? String(a.spellId);
|
|
647
|
+
top += `${art ? art[0] : paint(fit(name, 4))} `;
|
|
648
|
+
bottom += `${art ? (a.stacks > 1 ? yellow(fit(`x${a.stacks}`, 4)) : paint(fit(a.remainingMs != null ? auraTime(a.remainingMs).trim() : '', 4))) : paint(fit(name.slice(4), 4))} `;
|
|
649
|
+
}
|
|
650
|
+
return [top, bottom];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Columns an art column gets in a pane W wide: none under 90 columns, up to 28. */
|
|
654
|
+
const artCols = (W) => (W >= 90 ? Math.min(28, Math.floor(W * 0.22)) : 0);
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The sprites round a fight pane W x H: the foe a controller faces down the right from 90 columns, you in
|
|
658
|
+
* your gear (playerArt) down the left from 130; `innerW` is what the frames keep. The full fight view and
|
|
659
|
+
* the autopilot's run panel both use it, so a run shows the same scene either way.
|
|
660
|
+
*/
|
|
661
|
+
export function fightArt(app, ctl, W, H) {
|
|
662
|
+
const cols = artCols(W);
|
|
663
|
+
const entry = ctl.session?.enemyView()?.creatureEntry ?? ctl.enemyEntry;
|
|
664
|
+
const foe = cols && entry != null ? creatureArt(app.ds.creature(entry)?.displayId, cols, H) : null;
|
|
665
|
+
const me = cols && W >= 130 ? playerArt(app, cols, H)?.lines ?? null : null;
|
|
666
|
+
const innerW = W - (foe ? artWidth(foe) + 2 : 0) - (me ? artWidth(me) + 2 : 0);
|
|
667
|
+
return { me, foe, innerW };
|
|
483
668
|
}
|
|
484
669
|
|
|
485
|
-
/**
|
|
486
|
-
function
|
|
487
|
-
|
|
488
|
-
|
|
670
|
+
/** `lines` (drawn to the width the art leaves) between `left` and `right` art, each centred on the pane's height; every line W wide. */
|
|
671
|
+
export function flankArt(lines, left, right, W, H) {
|
|
672
|
+
if (!left && !right) return lines;
|
|
673
|
+
const lw = artWidth(left), rw = artWidth(right);
|
|
674
|
+
const innerW = W - (left ? lw + 2 : 0) - (right ? rw + 2 : 0);
|
|
675
|
+
const out = lines.slice(0, H);
|
|
676
|
+
while (out.length < H) out.push('');
|
|
677
|
+
const lt = Math.max(0, Math.floor((H - (left?.length ?? 0)) / 2)), rt = Math.max(0, Math.floor((H - (right?.length ?? 0)) / 2));
|
|
678
|
+
return out.map((l, i) => `${left ? `${fit(left[i - lt] ?? '', lw)} ` : ''}${fit(l, innerW)}${right ? ` ${right[i - rt] ?? ''}` : ''}`);
|
|
489
679
|
}
|
|
490
680
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
681
|
+
// ---- the stage ----------------------------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
const PLATE_PAD = 12; // columns a plate needs past its bar: the numbers after it
|
|
684
|
+
const GAP = 2; // columns between the stage's edge and a column, and between columns
|
|
685
|
+
const LANE_MIN = 8; // the gap kept open between the two sides for the glyphs that cross it
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The duel on a stage W x H (the design page's §2): you down the left with your pet beside you, the foes
|
|
689
|
+
* down the right with the one you face outermost. Under each sprite sits its plate (name, level, class or
|
|
690
|
+
* rank; HP; power), its state (a cast bar, crowd control, stealth; a row kept even when empty, so the auras
|
|
691
|
+
* never move) and its aura strip. Hits rise from the chest, a spell crosses the gap as a glyph, the floor
|
|
692
|
+
* runs under the plates and the ability bar closes the scene. Returns H lines each W wide, or null when the rows leave
|
|
693
|
+
* the sprites no room (the frames list draws instead).
|
|
694
|
+
*/
|
|
695
|
+
function duelStage(app, ctl, W, H, { inline }) {
|
|
696
|
+
const s = ctl.session, now = ctl.clock(), mem = ctl.frames;
|
|
697
|
+
const pv = s.playerView(), pet = s.petView(), focus = s.enemyView();
|
|
698
|
+
const icons = iconsOn();
|
|
699
|
+
const barW = W >= 140 ? 30 : W >= 110 ? 24 : 18;
|
|
700
|
+
const plateW = barW + PLATE_PAD;
|
|
701
|
+
const auraH = icons ? 3 : 1;
|
|
702
|
+
// Rows the scene keeps whatever the sprites get: the plate (3), the state row, the auras and the floor.
|
|
703
|
+
// The state row is there idle or casting, so a cast never moves the auras under it. The ability bar goes
|
|
704
|
+
// when rows are short, and only under auto-play.
|
|
705
|
+
const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
|
|
706
|
+
let barRows = barLines.length;
|
|
707
|
+
const fixed = () => 3 + 1 + auraH + 1 + barRows;
|
|
708
|
+
if (H - fixed() < 4 && ctl.auto) barRows = 0;
|
|
709
|
+
const artRows = H - fixed();
|
|
710
|
+
if (artRows < 4) return null;
|
|
711
|
+
const rankOf = (v) => { const c = v.creatureEntry != null ? app.ds.creature(v.creatureEntry) : null; return c?.rank ?? c?.scaling?.rank ?? 0; };
|
|
712
|
+
const creatureLines = (v, c, r) => (v.creatureEntry != null ? creatureArt(app.ds.creature(v.creatureEntry)?.displayId, c, r) : null);
|
|
713
|
+
/** One column of the stage: the unit, its box, its art and its plate; placed by x later. */
|
|
714
|
+
const column = (v, key, size, artOf, o) => {
|
|
715
|
+
const box = SIZES[size];
|
|
716
|
+
const art = artOf(box[0], Math.min(box[1], artRows));
|
|
717
|
+
const pw = (o.barW ?? barW) + PLATE_PAD;
|
|
718
|
+
return { v, key, art, aw: artWidth(art), cw: Math.max(pw, art ? artWidth(art) : 0), plateW: pw, ...o, barW: o.barW ?? barW };
|
|
719
|
+
};
|
|
720
|
+
const cols = [];
|
|
721
|
+
const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r)?.lines ?? null, { isPlayer: true });
|
|
722
|
+
const margin = Math.max(GAP, Math.floor(W / 16)); // the actors stand a little in from the edges
|
|
723
|
+
you.x = margin; cols.push(you);
|
|
724
|
+
let left = you.x + you.cw + GAP, right = W - margin, petCol = null;
|
|
725
|
+
if (pet) {
|
|
726
|
+
const c = column(pet, pet.side ?? pet.name, 'xs', (cc, r) => creatureLines(pet, cc, r), { friend: true, small: true, barW: 10 });
|
|
727
|
+
if (left + c.cw + LANE_MIN + plateW <= right) { c.x = left; left += c.cw + GAP; cols.push(c); petCol = c; }
|
|
728
|
+
}
|
|
729
|
+
// The other foes stand at the far edge. You and the foe you face stand where you really are: the engine
|
|
730
|
+
// keeps every unit's position in yards, so the gap between you is the real one, scaled from melee contact
|
|
731
|
+
// to the widest it has been this fight. Whoever walked closes it on screen: a melee character runs up to
|
|
732
|
+
// the mob (it stands where it was pulled), a mob chases the caster that hit it. Nobody slides who did not move.
|
|
733
|
+
let hidden = 0;
|
|
734
|
+
for (const e of s.enemiesView().filter((x) => x !== focus)) {
|
|
735
|
+
const c = column(e, e.side ?? e.name, 's', (cc, r) => creatureLines(e, cc, r), { small: true, barW: 12, rank: rankOf(e) });
|
|
736
|
+
if (right - c.cw - LANE_MIN - plateW < left) { hidden++; continue; }
|
|
737
|
+
c.x = right - c.cw; right = c.x - GAP; cols.push(c);
|
|
738
|
+
}
|
|
739
|
+
const foe = focus ? column(focus, focus.side ?? focus.name, foeSize(W, rankOf(focus)), (c, r) => creatureLines(focus, c, r), { focused: true, rank: rankOf(focus) }) : null;
|
|
740
|
+
if (foe) {
|
|
741
|
+
const near = left + LANE_MIN, far = Math.max(near, right - foe.cw);
|
|
742
|
+
const pP = s.unitPosYd?.('player'), pF = s.unitPosYd?.(focus.side);
|
|
743
|
+
const yd = combatDistanceYd(pP, pF);
|
|
744
|
+
mem.openYd = Math.max(mem.openYd ?? DEFAULT_OPEN_YD, yd ?? 0);
|
|
745
|
+
mem.openPos ??= new Map();
|
|
746
|
+
if (pP && !mem.openPos.has('player')) mem.openPos.set('player', { ...pP });
|
|
747
|
+
if (pF && !mem.openPos.has(foe.key)) mem.openPos.set(foe.key, { ...pF });
|
|
748
|
+
const walkedP = combatDistanceYd(mem.openPos.get('player'), pP) ?? 0, walkedF = combatDistanceYd(mem.openPos.get(foe.key), pF) ?? 0;
|
|
749
|
+
const share = walkedP + walkedF > 0 ? walkedP / (walkedP + walkedF) : 1; // your share of the closing; yours when nobody has walked yet
|
|
750
|
+
const frac = yd == null ? 1 : Math.max(0, Math.min(1, (yd - MELEE_CONTACT_YD) / Math.max(1, mem.openYd - MELEE_CONTACT_YD)));
|
|
751
|
+
const closing = Math.round((1 - frac) * (far - near)), yours = Math.round(closing * share);
|
|
752
|
+
for (const col of cols) if (col.isPlayer || col.friend) col.x += yours; // your pet walks beside you
|
|
753
|
+
foe.x = far - (closing - yours); foe.yd = yd;
|
|
754
|
+
// The pet has its own yards too: it runs out from your side to the foe and back. Its distance to the
|
|
755
|
+
// foe, from your gap down to melee contact, slides it from its place beside you to just in front of
|
|
756
|
+
// the foe's plate (the lane is at least LANE_MIN wide, so the two never overlap).
|
|
757
|
+
if (petCol && yd != null) {
|
|
758
|
+
const dPet = combatDistanceYd(s.unitPosYd?.(petCol.key), pF);
|
|
759
|
+
if (dPet != null) {
|
|
760
|
+
const out = Math.max(0, Math.min(1, (yd - dPet) / Math.max(1, yd - MELEE_CONTACT_YD)));
|
|
761
|
+
petCol.x += Math.round(out * Math.max(0, foe.x - GAP - (petCol.x + petCol.cw)));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
cols.push(foe);
|
|
765
|
+
}
|
|
766
|
+
const zone = artRows, stateRow = zone + 3, auraTop = zone + 4, floorRow = auraTop + auraH, barTop = floorRow + 1;
|
|
767
|
+
const c = canvas(W, H);
|
|
768
|
+
for (const col of cols) {
|
|
769
|
+
const v = col.v, dead = v.hp <= 0;
|
|
770
|
+
// The sprite stands on the plate, centred over it; the plate hangs under its feet. A swing lunges it two
|
|
771
|
+
// columns toward the other side for a moment.
|
|
772
|
+
if (col.art) {
|
|
773
|
+
const lunge = now - (mem.lunge?.get(col.key) ?? -Infinity) < LUNGE_MS ? (col.isPlayer || col.friend ? 2 : -2) : 0;
|
|
774
|
+
col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length; putLines(c, col.ax, col.top, col.art);
|
|
775
|
+
}
|
|
776
|
+
putLines(c, col.x, zone, plateLines(app, v, { ...col, dead }));
|
|
777
|
+
// Your cast sits where the foe's does: under the power bar, in the state row every plate keeps.
|
|
778
|
+
const slot = castSlot(v, now);
|
|
779
|
+
if (slot && !dead) put(c, col.x, stateRow, cut(slot, col.plateW));
|
|
780
|
+
else if (col === foe && hidden) put(c, col.x, stateRow, dim(`+${hidden} more in the fight`));
|
|
781
|
+
if (!col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.plateW, icons));
|
|
782
|
+
// Hits and heals rise from the chest, the newest lowest; without art they sit beside the name.
|
|
783
|
+
const pops = (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS);
|
|
784
|
+
pops.forEach((p, i) => {
|
|
785
|
+
const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
|
|
786
|
+
// On the sprite's inner shoulder, spilling toward the middle of the stage, so the body stays readable.
|
|
787
|
+
if (col.art) put(c, col.isPlayer || col.friend ? col.ax + col.aw - 1 : col.ax + 1 - vis(text), col.top + Math.floor(col.art.length / 3) - Math.floor((now - p.t) / POP_RISE_MS) - i, text);
|
|
788
|
+
else put(c, col.x + col.plateW + 1 + i * (vis(text) + 1), zone, text);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
// The engine's missiles in flight cross the gap as glyphs (putMissiles), on a row through the sprites' chests.
|
|
792
|
+
putMissiles(c, s, cols, now, Math.max(0, zone - 1 - Math.floor(Math.min(artRows, 14) / 2)), (id) => app.ds.spell(id)?.name);
|
|
793
|
+
c[floorRow] = floorLine(W, gray);
|
|
794
|
+
if (barRows) putLines(c, 0, barTop, barLines);
|
|
795
|
+
const out = c.map((l) => fit(l, W));
|
|
796
|
+
// The open lane between your side's sprites and the faced foe's, for what is drawn over the stage later
|
|
797
|
+
// (the drops after a win, putLootToasts).
|
|
798
|
+
if (foe) out.lane = { left: Math.max(...cols.filter((x) => x.isPlayer || x.friend).map((x) => (x.art ? x.ax + x.aw : x.x))), right: foe.art ? foe.ax : foe.x, rows: zone };
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The player's frame between fights: one line of name, level and bars. No auras here, the fight shows those. */
|
|
803
|
+
export function idlePlayerFrame(app, mem, W) {
|
|
804
|
+
const ch = app.ch, ds = app.ds;
|
|
497
805
|
const v = {
|
|
498
806
|
name: ch.name, level: ch.level, hp: ch.health, maxHp: maxHealth(ch, ds), power: ch.power, maxPower: maxPower(ch, ds),
|
|
499
|
-
powerType: powerType(ch), casting: null, controlled: null, buffs, debuffs: [],
|
|
807
|
+
powerType: powerType(ch), casting: null, controlled: null, buffs: [], debuffs: [],
|
|
500
808
|
};
|
|
501
|
-
return unitFrame(app, mem, v, W, 0, true);
|
|
809
|
+
return unitFrame(app, mem, v, W, 0, true, false, { compact: true });
|
|
502
810
|
}
|
|
503
811
|
|
|
504
812
|
export const AUTOPILOT_STEP_MS = 350;
|
|
@@ -506,19 +814,32 @@ const REST_SECONDS_PER_STEP = 10; // a rest step folds this much regen in; the l
|
|
|
506
814
|
|
|
507
815
|
/** Watchable autopilot: the planner picks a step every few hundred ms; a fight step runs as a live session
|
|
508
816
|
* (the same frames and combat log as pressing Enter on a creature) and the planner waits for it to end.
|
|
509
|
-
* Esc stops the run; in a fight Esc runs away and stops it. `quest` pins the run to that quest (Do Quest).
|
|
510
|
-
|
|
817
|
+
* Esc stops the run; in a fight Esc runs away and stops it. `quest` pins the run to that quest (Do Quest).
|
|
818
|
+
* No step cap unless `steps` asks for one: the Stop after setting ends the run, in real minutes. */
|
|
819
|
+
export function startAutopilot(app, steps = Infinity, farmQuery, quest) {
|
|
511
820
|
if (app.fight || app.auto) return;
|
|
512
821
|
const what = farmQuery ? `farming ${farmQuery}` : quest != null ? `doing ${app.ds.quest(quest)?.title ?? `quest ${quest}`}` : 'questing';
|
|
513
822
|
const ctl = {
|
|
514
|
-
timer: null, fights: 0, kills: 0, xp: 0, restSteps: 0, status: '', browsing: false, frames: newFrameMemory(app),
|
|
823
|
+
timer: null, fights: 0, kills: 0, xp: 0, deaths: 0, items: 0, restSteps: 0, status: '', browsing: false, paused: false, frames: newFrameMemory(app),
|
|
824
|
+
// What the run strip, the run panel, the Run pane and the end card read (tuiAutopilot.mjs).
|
|
825
|
+
what, steps, farmQuery, quest, startedAt: Date.now(), gold0: app.ch.gold, level0: app.ch.level, ledger: [], turnedIn: [], ended: null,
|
|
826
|
+
get planner() { return ap; },
|
|
827
|
+
/** One row in the Run pane per planner step; a rest keeps updating its one row. */
|
|
828
|
+
note(kind, body, { replace = false } = {}) {
|
|
829
|
+
const last = this.ledger[this.ledger.length - 1];
|
|
830
|
+
if (replace && last && last.kind === kind) { last.n = ap.step; last.body = body; return last; }
|
|
831
|
+
const row = { n: ap.step, kind, body };
|
|
832
|
+
this.ledger.push(row);
|
|
833
|
+
if (this.ledger.length > 500) this.ledger.splice(0, this.ledger.length - 500);
|
|
834
|
+
return row;
|
|
835
|
+
},
|
|
515
836
|
/** One planner step (the check script calls this by hand). Returns false when the run is over. */
|
|
516
837
|
step() {
|
|
517
|
-
if (app.fight) return true; // the live fight calls back through onEnd
|
|
838
|
+
if (app.fight || this.paused) return true; // the live fight calls back through onEnd
|
|
518
839
|
let line;
|
|
519
840
|
try { line = ap.next(); }
|
|
520
841
|
catch (err) { app.say(red(`autopilot error: ${err.message}`)); line = null; }
|
|
521
|
-
if (line == null) { this.
|
|
842
|
+
if (line == null) { this.end(); return false; }
|
|
522
843
|
if (ap.lastKind === 'wait') {
|
|
523
844
|
// One rest line that keeps updating instead of a column of them.
|
|
524
845
|
this.restSteps++;
|
|
@@ -526,48 +847,64 @@ export function startAutopilot(app, steps = 200, farmQuery, quest) {
|
|
|
526
847
|
const text = `${dim(' resting')} ${dim(`${this.restSteps * REST_SECONDS_PER_STEP} s`)} HP ${app.hpText()} ${app.pwText()}`;
|
|
527
848
|
if (this.restSteps > 1 && app.log.length && app.log[app.log.length - 1].includes('resting')) app.log[app.log.length - 1] = text;
|
|
528
849
|
else app.say(text);
|
|
850
|
+
this.note('wait', `rest ${this.restSteps * REST_SECONDS_PER_STEP} s ${dim(`HP ${app.hpText()} ${app.pwText()}`)}`, { replace: true });
|
|
529
851
|
} else {
|
|
530
852
|
this.restSteps = 0;
|
|
531
|
-
if (line) {
|
|
853
|
+
if (line) {
|
|
854
|
+
app.say(line);
|
|
855
|
+
const body = stepBody(line), plain = strip(body).trim();
|
|
856
|
+
this.status = plain;
|
|
857
|
+
const kind = /^skipping /.test(plain) ? 'skip' : /^back to life/.test(plain) ? 'dead' : /^autopilot stopped/.test(plain) ? 'stop' : ap.lastKind ?? 'note';
|
|
858
|
+
this.note(kind, body);
|
|
859
|
+
if (kind === 'turnin' && /^turned in /.test(plain)) this.turnedIn.push(plain.slice('turned in '.length));
|
|
860
|
+
}
|
|
532
861
|
}
|
|
533
862
|
// The step line already tells the story; the engine's activity rows for it would repeat it.
|
|
534
863
|
app.lastActivity = app.g.state.activity[0] ?? null;
|
|
535
864
|
app.renderSoon();
|
|
536
865
|
return true;
|
|
537
866
|
},
|
|
538
|
-
stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; if (app.auto === this) app.auto = null; void app.persist(); app.render(); },
|
|
539
|
-
/**
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
dim(` ${cut(this.status || 'planning the next step', W - 2)}`),
|
|
548
|
-
];
|
|
549
|
-
return lines.slice(0, H);
|
|
867
|
+
stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; if (app.auto === this) { app.auto = null; app.lastRun = this; } void app.persist(); app.render(); },
|
|
868
|
+
/** The run is over (the planner stopped, or `why` says the player did): the log line, then the end card
|
|
869
|
+
* once there is something to sum up. */
|
|
870
|
+
end(why) {
|
|
871
|
+
this.ended = why ?? null;
|
|
872
|
+
this.stop();
|
|
873
|
+
app.say(`${cyan(why ? 'Autopilot stopped.' : 'Autopilot done.')} ${dim(stopText(app, this))} ${ap.summary()} ${dim(`${this.fights} fights, ${this.kills} kills, +${this.xp} xp`)}`);
|
|
874
|
+
if (!app.closed && (this.fights > 0 || ap.step > 3)) { app.stack = []; app.push(endCard(app, this)); }
|
|
875
|
+
app.afterAction();
|
|
550
876
|
},
|
|
877
|
+
/** Between fights: the run panel (tuiAutopilot.mjs) fills the middle. */
|
|
878
|
+
render(W, H) { return runPanelLines(app, this, W, H); },
|
|
551
879
|
};
|
|
552
880
|
const liveFight = (step, settle) => {
|
|
553
881
|
const c = app.ds.creature(step.creatureEntry);
|
|
882
|
+
const name = c?.name ?? String(step.creatureEntry);
|
|
554
883
|
const why = () => fightReason(app, step);
|
|
555
884
|
const reason = why();
|
|
556
|
-
ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${
|
|
885
|
+
ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${name}${reason && step.forQuest ? ` ${reason.short}` : ''}`;
|
|
557
886
|
app.say(`${dim(String(ap.step).padStart(3) + '.')} ${ctl.status}`);
|
|
887
|
+
const row = ctl.note('fight', `${fit(name, 24)} ${cyan('fighting')}${reason ? ` ${dim(reason.short)}` : ''}`);
|
|
558
888
|
const f = startFight(app, step.creatureEntry, {
|
|
559
889
|
label: 'autopilot', why,
|
|
560
890
|
onEnd: (r) => {
|
|
561
|
-
|
|
891
|
+
ctl.watchFight = false; // w watches one fight; the next one draws inside the panel again
|
|
892
|
+
if (!r) { row.body = `${fit(name, 24)} ${yellow('ran')}`; ctl.end('you ran'); return; }
|
|
893
|
+
const skippedBefore = ap.skipped.length;
|
|
562
894
|
settle(r);
|
|
563
|
-
ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${
|
|
895
|
+
ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${name}`;
|
|
564
896
|
ctl.fights++;
|
|
565
|
-
if (r.outcome === 'win') { ctl.kills += r.killCount ?? 1; ctl.xp += r.xpGained; }
|
|
897
|
+
if (r.outcome === 'win') { ctl.kills += r.killCount ?? 1; ctl.xp += r.xpGained; ctl.items += r.loot.reduce((n, d) => n + (d.count ?? 1), 0); }
|
|
898
|
+
if (r.outcome === 'defeat') ctl.deaths++;
|
|
899
|
+
const outcome = r.outcome === 'win' ? green('won') : r.outcome === 'defeat' ? red('lost') : yellow('stalemate');
|
|
900
|
+
const loot = r.loot.map((d) => `${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`).join(', ');
|
|
901
|
+
row.body = `${fit(name, 24)} ${outcome} ${dim(`${(r.result.durationMs / 1000).toFixed(1)} s`)}${r.xpGained ? ` ${yellow(`+${r.xpGained} xp`)}` : ''}${loot ? ` ${loot}` : ''}`;
|
|
902
|
+
for (const entry of ap.skipped.slice(skippedBefore)) ctl.note('skip', `skipped ${app.ds.quest(entry)?.title ?? `quest ${entry}`} ${dim(`died ${ap.questDefeats(entry)} times`)}`);
|
|
566
903
|
},
|
|
567
904
|
});
|
|
568
905
|
return f.ok ? f : { ok: false, reason: f.reason };
|
|
569
906
|
};
|
|
570
|
-
const ap = app.cmds.createAutopilot(
|
|
907
|
+
const ap = app.cmds.createAutopilot(farmQuery ? `farm ${farmQuery}` : '', { liveFight, quest, maxSteps: steps, clock: () => Date.now() - ctl.startedAt });
|
|
571
908
|
if (ap.error) { app.say(red(ap.error)); app.render(); return; }
|
|
572
909
|
app.say(cyan(`Autopilot: ${what}, fights play live. Esc stops it.`));
|
|
573
910
|
app.auto = ctl;
|