linkchat-extension-spire 0.1.7 → 0.1.9
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 +6 -6
- package/index.js +266 -244
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.1.
|
|
1
|
+
# Spire for LinkChat — 0.1.9
|
|
2
2
|
|
|
3
|
-
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2
|
|
3
|
+
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
|
|
4
4
|
|
|
5
|
-
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.
|
|
5
|
+
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.9.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
|
|
6
6
|
|
|
7
7
|
## 操作
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**战斗**:我方在左、敌人在右(默认 80×25 终端使用紧凑双栏,队友在左、敌人在右;有效宽度不足 68 列或高度不足 24 行时改为上下分区);每个信息块自上而下是意图、名称、格挡与血条、生命、增益、减益,多个意图字段会换行而不是被截断。手牌竖排并编号 1–9、0:数字键只把光标移到该位置,Enter 才是执行;每页最多十项,PgUp/PgDn 翻页,方向键跨页时页码同步。能量右侧是固定的 `[E 结束回合]` 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
|
|
10
10
|
|
|
11
11
|
**选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
|
|
14
14
|
|
|
15
15
|
**商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
**宝箱**:单人时点选遗物即为一次明确的领取,客户端会在服务端确认后接着领取同一件遗物(按 reward ID 精确匹配,结果不明时不会猜);多人保留投票、分配、领取三个阶段,投票按钮本身就是确认。
|
|
20
20
|
|
|
21
|
-
**其他**:Tab
|
|
21
|
+
**其他**:Tab 依次查看行动、牌组、遗物、药水、状态、日志,标签随窗口宽度换行;抽牌堆、消耗堆和弃牌堆仅在战斗中显示于能量栏下一行,依次位于左、中、右,点击或按 A / S / D 查看(药水面板的 D 仍为丢弃药水),Esc 返回;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
|
|
22
22
|
|
|
23
23
|
**鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部面板名切换面板;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
|
|
24
24
|
|
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
const BASE = '/api/games/spire/rooms';
|
|
3
3
|
const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
|
|
4
4
|
export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
|
|
5
|
-
const tabs = ['action', 'deck', '
|
|
5
|
+
const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
|
|
6
6
|
const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
|
|
7
7
|
const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
|
|
8
8
|
Object.assign(powerNames, {juggling:'杂耍',aggression:'侵略',barricade:'壁垒',corruption:'腐化',colossus:'巨像',crimsonMantle:'猩红披风',cruelty:'残酷',inferno:'炼狱',juggernaut:'势不可挡',pyre:'柴堆',plating:'覆甲',unmovable:'坚如磐石',freeAttack:'免费攻击',vicious:'凶狠',tank:'坦克',guarded:'被守护',covered:'被掩护',intercept:'拦截',oneTwoPunch:'连环拳',stampede:'奔踏',calamity:'灾祸',tagTeam:'接力',knockdown:'击倒',beaconOfHope:'希望灯塔',fasten:'紧固',prepTime:'准备时间',retainHand:'保留手牌',nostalgia:'怀旧',automation:'自动化',panache:'华丽',bomb:'炸弹',rollingBoulder:'滚石',theGambit:'赌局',coordinate:'协同',blockNextTurn:'下回合格挡',setupStrike:'临时力量',vigor:'活力',artifact:'人工制品',poison:'中毒',strangle:'绞杀',freeSkill:'免费技能',drawNextTurn:'下回合抽牌',focus:'集中',temporaryFocus:'临时集中',freePower:'免费能力',energyNextTurn:'下回合能量',temporaryStrengthDown:'临时力量降低',doom:'灾厄',hang:'绞刑',debilitate:'衰弱',sicEm:'咬住',veilpiercer:'虚无免费'});
|
|
@@ -82,8 +82,6 @@ export const internalIntentKeys = {
|
|
|
82
82
|
};
|
|
83
83
|
const signed = value => `${value > 0 ? '+' : ''}${value}`;
|
|
84
84
|
// --- act map (§D) -----------------------------------------------------------
|
|
85
|
-
const MAP_CELL = 10;
|
|
86
|
-
const FLIGHT_COLOUR = '\x1b[95m'; // a move only a WingedBoots charge allows
|
|
87
85
|
function mapData(view) {
|
|
88
86
|
const nodes = (view?.nodes ?? []).filter(node => node && typeof node.id === 'string');
|
|
89
87
|
const byId = new Map(nodes.map(node => [node.id, node]));
|
|
@@ -122,202 +120,131 @@ const joinGlyph = directions => {
|
|
|
122
120
|
const key = `${set.has('u') ? 'u' : ''}${set.has('d') ? 'd' : ''}${set.has('l') ? 'l' : ''}${set.has('r') ? 'r' : ''}`;
|
|
123
121
|
return JOIN[key] ?? '─';
|
|
124
122
|
};
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
const mapCode = node => `${String(node.row).padStart(2, '0')}-${String.fromCharCode(65 + node.col)}`;
|
|
124
|
+
function mapLabel(view, data, node, options) {
|
|
127
125
|
const route = mapRouteIndex(node.id, options);
|
|
128
|
-
|
|
129
|
-
return `${mark}${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
126
|
+
return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
130
127
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const colour = node.id === view.current ? '\x1b[1;96m' : data.reachable.has(node.id) ? '\x1b[92m' : '\x1b[90m';
|
|
128
|
+
function mapPaint(view, data, node, label, focus) {
|
|
129
|
+
const child = data.byId.get(focus)?.children?.includes(node.id);
|
|
130
|
+
const colour = node.id === focus || child ? '\x1b[1;93m' : node.id === view.current ? '\x1b[1;96m' : data.reachable.has(node.id) ? '\x1b[92m' : '\x1b[90m';
|
|
135
131
|
return `${colour}${label}\x1b[0m`;
|
|
136
132
|
}
|
|
137
|
-
// A vote is a coloured digit after the room, so a party sees the tally on the map.
|
|
138
133
|
function mapVotes(node, players) {
|
|
139
134
|
const seats = (players ?? []).filter(player => player.vote === node.id).length;
|
|
140
135
|
return seats ? { text: `\x1b[33m${seats}\x1b[0m`, width: 1 } : { text: '', width: 0 };
|
|
141
136
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
for (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const id of pending) order.push(id);
|
|
178
|
-
const rank = new Map(order.map((id, at) => [id, at]));
|
|
179
|
-
const placed = [];
|
|
180
|
-
for (const plan of [...plans].sort((left, right) => rank.get(left.id) - rank.get(right.id))) {
|
|
181
|
-
let column = Math.max(-1, ...[...before.get(plan.id)].map(id => placed.find(entry => entry.id === id)?.lane ?? -1)) + 1;
|
|
182
|
-
while (placed.some(entry => entry.lane === column && !(entry.bottom + 1 < plan.top || plan.bottom + 1 < entry.top))) column += 1;
|
|
183
|
-
plan.lane = column;
|
|
184
|
-
placed.push(plan);
|
|
185
|
-
}
|
|
186
|
-
return { plans, lanes: placed.length ? Math.max(...placed.map(entry => entry.lane)) + 1 : 0 };
|
|
187
|
-
}
|
|
188
|
-
function drawMap(view, width, session) {
|
|
189
|
-
const data = mapData(view);
|
|
190
|
-
const layers = [...data.rows.keys()].sort((left, right) => left - right);
|
|
191
|
-
const options = session?.options ?? [];
|
|
192
|
-
const full = session?.full ?? false;
|
|
193
|
-
const labels = new Map(data.nodes.map(node => [node.id, mapLabel(view, data, node, full, options)]));
|
|
194
|
-
const votes = new Map(data.nodes.map(node => [node.id, mapVotes(node, session?.players)]));
|
|
195
|
-
const side = new Map(data.nodes.map(node => [node.id, displayWidth(labels.get(node.id)) + votes.get(node.id).width]));
|
|
196
|
-
const widest = full ? Math.max(2, ...side.values()) : Math.max(6, ...side.values());
|
|
197
|
-
// §D: while a WingedBoots has charges the server accepts any room in the next
|
|
198
|
-
// layer, so those moves are drawn too — a plan must not miss a legal route.
|
|
199
|
-
const extra = new Map();
|
|
200
|
-
if (data.current) {
|
|
201
|
-
const here = data.current.row;
|
|
202
|
-
const flights = (view.reachable ?? []).filter(id => !(data.current.children ?? []).includes(id)
|
|
203
|
-
&& data.byId.get(id)?.row === here + 1);
|
|
204
|
-
if (flights.length) extra.set(data.current.id, flights);
|
|
205
|
-
}
|
|
206
|
-
const gaps = [];
|
|
207
|
-
for (let index = 0; index + 1 < layers.length; index++) gaps.push(mapLanes(data, layers, index, extra));
|
|
208
|
-
const xs = [0];
|
|
209
|
-
// One column is kept for the rail itself: a room whose label is the widest
|
|
210
|
-
// still needs a dash between it and its lane, or the link starts in mid-air.
|
|
211
|
-
for (let index = 0; index + 1 < layers.length; index++) xs.push(xs[index] + widest + gaps[index].lanes + 2);
|
|
212
|
-
const roomWidth = index => Math.max(0, ...(data.rows.get(layers[index]) ?? []).map(node => side.get(node.id)));
|
|
213
|
-
const anchor = data.byId.get(session?.anchor) ?? data.current;
|
|
214
|
-
const home = Math.max(0, layers.indexOf(anchor ? anchor.row : layers[0]));
|
|
215
|
-
// Only the layers on screen take width: the hidden ones must not push the map right.
|
|
216
|
-
const fits = (start, span) => start + span - 1 < layers.length
|
|
217
|
-
&& xs[start + span - 1] - xs[start] + roomWidth(start + span - 1) <= width;
|
|
218
|
-
const spanFrom = start => { let span = 1; while (fits(start, span + 1)) span += 1; return span; };
|
|
219
|
-
const widestSpan = Math.max(...layers.map((_, start) => spanFrom(start)));
|
|
220
|
-
const scroll = session?.scroll;
|
|
221
|
-
const scrolled = scroll !== undefined && scroll !== null;
|
|
222
|
-
let from = Math.max(0, Math.min(Math.max(0, layers.length - widestSpan),
|
|
223
|
-
scrolled ? scroll : Math.max(0, home - Math.floor(widestSpan / 2))));
|
|
224
|
-
let span = spanFrom(from);
|
|
225
|
-
// Following the position, it must stay in view: shift the window back when the
|
|
226
|
-
// widest span did not fit where it was centred. A deliberate scroll may leave it.
|
|
227
|
-
if (!scrolled) {
|
|
228
|
-
while (from > 0 && from + span <= home) { from -= 1; span = spanFrom(from); }
|
|
229
|
-
if (from > home) { from = home; span = spanFrom(from); }
|
|
230
|
-
}
|
|
231
|
-
const shown = index => index >= from && index < from + span;
|
|
232
|
-
const origin = xs[from];
|
|
233
|
-
const at = index => xs[index] - origin;
|
|
234
|
-
const slot = new Map(data.nodes.map(node => [node.id, node.col]));
|
|
235
|
-
const height = Math.max(0, ...slot.values()) + 1;
|
|
236
|
-
// A sparse canvas: a name, a lane and a rail may share a column, never a cell.
|
|
237
|
-
const node = new Map();
|
|
238
|
-
const rail = new Map();
|
|
239
|
-
const cellOf = (line, col) => {
|
|
240
|
-
const key = `${line}:${col}`;
|
|
241
|
-
let part = rail.get(key);
|
|
242
|
-
if (!part) { part = { line, col, v: null, vd: new Set(), h: new Map(), kid: new Map(), flight: false }; rail.set(key, part); }
|
|
243
|
-
return part;
|
|
244
|
-
};
|
|
245
|
-
const add = (line, col, owner, directions, kid, flight) => {
|
|
246
|
-
if (col < 0 || col >= width) return; // the window cuts the drawing
|
|
247
|
-
const part = cellOf(line, col);
|
|
248
|
-
for (const direction of directions) {
|
|
249
|
-
if (direction === 'u' || direction === 'd') { if (part.v === null) part.v = owner; part.vd.add(direction); }
|
|
250
|
-
else part.h.set(owner, (part.h.get(owner) ?? new Set()).add(direction));
|
|
251
|
-
}
|
|
252
|
-
if (kid !== undefined) part.kid.set(owner, kid);
|
|
253
|
-
if (flight) part.flight = true;
|
|
254
|
-
};
|
|
255
|
-
for (let index = 0; index < layers.length; index++) {
|
|
256
|
-
if (!shown(index)) continue;
|
|
257
|
-
for (const room of data.rows.get(layers[index]) ?? []) {
|
|
258
|
-
const label = `${mapPaint(view, data, room, labels.get(room.id))}${votes.get(room.id).text}`;
|
|
259
|
-
node.set(`${slot.get(room.id)}:${at(index)}`, { room, label, plain: labels.get(room.id) });
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
const targets = new Map(); // parent -> the rooms it links to
|
|
263
|
-
const kidLines = new Map(); // parent -> the lines those rooms sit on
|
|
264
|
-
for (let index = 0; index + 1 < layers.length; index++) {
|
|
265
|
-
if (!shown(index) || !shown(index + 1)) continue;
|
|
266
|
-
for (const plan of gaps[index].plans) {
|
|
267
|
-
targets.set(plan.id, plan.owns);
|
|
268
|
-
kidLines.set(plan.id, new Set(plan.kids));
|
|
269
|
-
const lane = at(index) + widest + 1 + plan.lane;
|
|
270
|
-
const begin = at(index) + side.get(plan.id);
|
|
271
|
-
for (let x = begin; x < lane; x++) add(plan.line, x, plan.id, 'lr');
|
|
272
|
-
for (let line = plan.top; line <= plan.bottom; line++) {
|
|
273
|
-
const directions = [];
|
|
274
|
-
if (line > plan.top) directions.push('u');
|
|
275
|
-
if (line < plan.bottom) directions.push('d');
|
|
276
|
-
if (line === plan.line) directions.push('l');
|
|
277
|
-
if (plan.kids.includes(line)) directions.push('r');
|
|
278
|
-
if (directions.length) add(line, lane, plan.id, directions);
|
|
137
|
+
function drawMap(view, width, session = {}) {
|
|
138
|
+
const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
|
|
139
|
+
const focus = data.byId.has(session.focus) ? session.focus : view.current;
|
|
140
|
+
const codeLabel = n => `${n.id===focus?'›':' '} ${mapCode(n)}`;
|
|
141
|
+
const labels = new Map(data.nodes.map(n => [n.id, mapLabel(view, data, n, session.options)]));
|
|
142
|
+
const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
|
|
143
|
+
// An 80-column terminal gives the extension 75 columns after host margins.
|
|
144
|
+
// Reserve only measured text plus a separator before falling back to a table.
|
|
145
|
+
const needed = Math.max(8, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 1));
|
|
146
|
+
const slot = Math.max(needed, width >= cols * 12 ? 12 : 6);
|
|
147
|
+
const unsafeGap = layers.some((layer,i)=>{
|
|
148
|
+
const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
|
|
149
|
+
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
150
|
+
return edges.some(([a,b])=>b.row!==layers[i+1] || (Math.abs(a.col-b.col)>1 && rooms.length>1 && next.length>1)) ||
|
|
151
|
+
edges.some(([a,b])=>edges.some(([c,d])=>(a.col-c.col)*(b.col-d.col)<0));
|
|
152
|
+
});
|
|
153
|
+
const compact = session.full || cols * slot > width || (session.height??24)<7 || unsafeGap;
|
|
154
|
+
const blocks = [];
|
|
155
|
+
const cell = (n, col, text) => ({id:n.id, row:0, col, width:displayWidth(text), height:1});
|
|
156
|
+
const plainRow = text => ({text, cells:[], tone:'muted'});
|
|
157
|
+
for (let i=0; i<layers.length; i++) {
|
|
158
|
+
const rooms = data.rows.get(layers[i]), block = [];
|
|
159
|
+
if (compact) {
|
|
160
|
+
for (const n of rooms) {
|
|
161
|
+
const label = `${codeLabel(n)} ${labels.get(n.id)}`;
|
|
162
|
+
const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
|
|
163
|
+
const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
|
|
164
|
+
let suffix='';
|
|
165
|
+
const emit=()=>block.push({text:mapPaint(view,data,n,label,focus)+mapVotes(n,session.players).text+suffix,cells:[cell(n,0,label)],tone:'text'});
|
|
166
|
+
for(const target of targets){
|
|
167
|
+
const next=`${suffix?' ·':' →'} ${target}`;
|
|
168
|
+
if(suffix && displayWidth(label+suffix+next)+mapVotes(n,session.players).width>width){emit();suffix='';}
|
|
169
|
+
suffix+=`${suffix?' ·':' →'} ${target}`;
|
|
170
|
+
}
|
|
171
|
+
emit();
|
|
279
172
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
173
|
+
} else {
|
|
174
|
+
for (const codes of [true,false]) {
|
|
175
|
+
const pieces = rooms.map(n => {
|
|
176
|
+
const text = codes ? codeLabel(n) : labels.get(n.id);
|
|
177
|
+
return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
|
|
178
|
+
});
|
|
179
|
+
const placed = place(pieces,width);
|
|
180
|
+
block.push({text:placed.text,tone:'text',cells:placed.placed.map(e=>cell(e.node,e.from,e.plain))});
|
|
284
181
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
182
|
+
if (i+1<layers.length) {
|
|
183
|
+
const ink = Array.from({length:3},()=>new Map());
|
|
184
|
+
const put=(y,x,dirs,owner)=>{
|
|
185
|
+
let c=ink[y].get(x); if(!c){c={dirs:new Set(),owners:new Set()};ink[y].set(x,c);}
|
|
186
|
+
for(const d of dirs)c.dirs.add(d);c.owners.add(owner);
|
|
187
|
+
};
|
|
188
|
+
const horizontal=(y,a,b,owner)=>{
|
|
189
|
+
for(let x=Math.min(a,b);x<=Math.max(a,b);x++)put(y,x,`${x>Math.min(a,b)?'l':''}${x<Math.max(a,b)?'r':''}`,owner);
|
|
190
|
+
};
|
|
191
|
+
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
192
|
+
// Original noncrossing edges use separate midpoint tracks. Branches
|
|
193
|
+
// share the source row; merges share only the destination row.
|
|
194
|
+
for(const [n,kid] of edges){
|
|
195
|
+
const a=n.col*slot+2,b=kid.col*slot+2,m=Math.round((a+b)/2);
|
|
196
|
+
horizontal(0,a,m,n.id);put(0,a,'u',n.id);put(0,m,'d',n.id);
|
|
197
|
+
put(1,m,'ud',n.id);
|
|
198
|
+
horizontal(2,m,b,n.id);put(2,m,'u',n.id);put(2,b,'d',n.id);
|
|
199
|
+
}
|
|
200
|
+
for(const row of ink){
|
|
201
|
+
const pieces=[...row].map(([at,c])=>({at,text:`${c.owners.has(focus)?'\x1b[1;93m':'\x1b[90m'}${joinGlyph(c.dirs)}\x1b[0m`}));
|
|
202
|
+
block.push(plainRow(place(pieces,width).text));
|
|
203
|
+
}
|
|
303
204
|
}
|
|
304
|
-
if (!directions.size) continue;
|
|
305
|
-
const glyph = joinGlyph(directions);
|
|
306
|
-
row.push({ at: part.col, text: part.flight ? `${FLIGHT_COLOUR}${glyph}\x1b[0m` : glyph });
|
|
307
205
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
206
|
+
blocks.push(block);
|
|
207
|
+
}
|
|
208
|
+
const budget=Math.max(3,session.height??24);
|
|
209
|
+
const anchor=data.byId.get(session.anchor)??data.current;
|
|
210
|
+
const home=Math.max(0,layers.indexOf(anchor?.row));
|
|
211
|
+
let from=Math.max(0,Math.min(blocks.length-1,session.scroll??Math.max(0,home-1)));
|
|
212
|
+
let lines=[],span=0;
|
|
213
|
+
// Scroll offsets count from the bottom (the beginning of the act).
|
|
214
|
+
// Reverse layers, but keep same-layer rooms and wrapped targets in reading order.
|
|
215
|
+
if(compact){
|
|
216
|
+
const all=[...blocks].reverse().flat();
|
|
217
|
+
const focusId=session.anchor??anchor?.id;
|
|
218
|
+
const focusEnd=all.findLastIndex(line=>line.cells.some(c=>c.id===focusId));
|
|
219
|
+
const offset=Math.max(0,Math.min(Math.max(0,all.length-budget),session.scroll??(focusEnd>=0?all.length-focusEnd-1:0)));
|
|
220
|
+
const start=Math.max(0,all.length-budget-offset);
|
|
221
|
+
lines=all.slice(start,start+budget);
|
|
222
|
+
return {lines,from:offset,span:lines.length,total:all.length,height:lines.length,compact:true};
|
|
223
|
+
}
|
|
224
|
+
// Show the focused layer even if the preceding layer uses the whole budget.
|
|
225
|
+
if(session.scroll===undefined && from<home && blocks[from].length+2>budget)from=home;
|
|
226
|
+
for(let i=from;i<blocks.length;i++){
|
|
227
|
+
const last=i===blocks.length-1;
|
|
228
|
+
if(lines.length+blocks[i].length>budget){
|
|
229
|
+
if(lines.length+2<=budget){lines.push(...blocks[i].slice(0,2));span++;}
|
|
230
|
+
break;
|
|
311
231
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
232
|
+
lines.push(...blocks[i]);span++;
|
|
233
|
+
if(last)break;
|
|
234
|
+
}
|
|
235
|
+
// Remove dangling connectors at the viewport boundary.
|
|
236
|
+
while(lines.length && !lines.at(-1).cells.length)lines.pop();
|
|
237
|
+
// Reflect the geometry vertically, including each connector's directions.
|
|
238
|
+
// Labels remain code-above-type; neither text nor ANSI escapes are mirrored.
|
|
239
|
+
const mirror={'┌':'└','└':'┌','┐':'┘','┘':'┐','┬':'┴','┴':'┬'};
|
|
240
|
+
lines.reverse();
|
|
241
|
+
for(let i=0;i<lines.length;i++){
|
|
242
|
+
if(lines[i].cells.length){
|
|
243
|
+
[lines[i],lines[i+1]]=[lines[i+1],lines[i]];
|
|
244
|
+
i++;
|
|
245
|
+
}else lines[i]={...lines[i],text:lines[i].text.replace(/[┌└┐┘┬┴]/g,ch=>mirror[ch])};
|
|
318
246
|
}
|
|
319
|
-
return {
|
|
320
|
-
flight: (extra.get(data.current?.id) ?? []).length > 0 };
|
|
247
|
+
return {lines,from,span,total:layers.length,height:lines.length,compact:false};
|
|
321
248
|
}
|
|
322
249
|
|
|
323
250
|
// The package ships without dependencies, so the tab strip is measured here.
|
|
@@ -356,10 +283,11 @@ export function createScreen(ctx) {
|
|
|
356
283
|
let enemyPage = 0; // the enemy block page the battlefield is showing
|
|
357
284
|
let listPage = 0; // the 1-9/0 page of the item list
|
|
358
285
|
let pageSize = 10; // how many items a page holds at the current size
|
|
286
|
+
let mapBudget = 10;
|
|
359
287
|
let mapScroll; // the map row the view starts at (undefined follows the anchor)
|
|
360
288
|
let mapAnchor; // the room the viewport is parked on (a click or 回到当前位置)
|
|
361
289
|
let mapFocus; // the node being looked at on the map, never a submitted route
|
|
362
|
-
let mapFull = false; //
|
|
290
|
+
let mapFull = false; // compact adjacency overview, with room types retained
|
|
363
291
|
let chain; // §G: the claim that follows this player's own treasure pick
|
|
364
292
|
let rewardLog = new Map(); // §F reward id -> claimed / skipped; display only, never a receipt
|
|
365
293
|
let rewardOpen; // §F the card reward whose candidates are expanded
|
|
@@ -370,7 +298,11 @@ export function createScreen(ctx) {
|
|
|
370
298
|
const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
|
|
371
299
|
const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
|
|
372
300
|
const me = () => state?.players.find(p => p.id === ctx.user.id);
|
|
373
|
-
|
|
301
|
+
let characterConfirmed=false, mapOpen, mapReturnTab=0;
|
|
302
|
+
const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
|
|
303
|
+
let pileView;
|
|
304
|
+
const activeTab = () => pileView ?? tabs[tab];
|
|
305
|
+
function openPile(key) { if(state?.phase!=='combat' || modal)return; pileView=key; index=0;selected=undefined;listPage=0;changed(); }
|
|
374
306
|
const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
|
|
375
307
|
// A phase this build cannot render stays read only, so nothing is submitted.
|
|
376
308
|
const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
|
|
@@ -379,6 +311,7 @@ export function createScreen(ctx) {
|
|
|
379
311
|
if (modal) return modal.items;
|
|
380
312
|
if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:'单人攀登 · 开始/继续'}, ...rooms.filter(r=>r.mode!=='solo').map(r => ({ id: r.id, room: r, label: `${r.players.some(p => p.id === ctx.user.id) ? '继续' : '加入'} · ${r.players.map(p => p.name).join(' / ')} · ${r.players.length}/4 · ${phases[r.phase] ?? r.phase}${r.paused ? ' · 已暂停' : ''}${r.compatible === false ? ' · 版本不兼容' : ''}` }))];
|
|
381
313
|
if (!state) return [];
|
|
314
|
+
if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
|
|
382
315
|
if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
|
|
383
316
|
if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
|
|
384
317
|
if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
|
|
@@ -427,14 +360,7 @@ export function createScreen(ctx) {
|
|
|
427
360
|
: { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
|
|
428
361
|
return rows;
|
|
429
362
|
}
|
|
430
|
-
if
|
|
431
|
-
// §D: the map only highlights; going there is this explicit row (or Enter
|
|
432
|
-
// on the route itself).
|
|
433
|
-
const rows = [...state.options];
|
|
434
|
-
const route = rows.find(row => row.action?.type === 'vote' && (row.id === selected || row.id === mapFocus));
|
|
435
|
-
rows.push({ id: 'mapConfirm', label: route ? `确认前往:${route.label}` : '确认前往(先选择一条路线)', action: route?.action });
|
|
436
|
-
return rows;
|
|
437
|
-
}
|
|
363
|
+
if(state.phase==='map' && !state.paused && state.mapView?.nodes?.length)return [{id:'openMap',label:'打开地图 M'}];
|
|
438
364
|
return state.options;
|
|
439
365
|
}
|
|
440
366
|
function anchor() {
|
|
@@ -459,8 +385,11 @@ export function createScreen(ctx) {
|
|
|
459
385
|
choiceReturn=undefined;
|
|
460
386
|
}
|
|
461
387
|
}
|
|
388
|
+
if (state?.mapView?.current !== s.mapView?.current || state?.act?.index !== s.act?.index) {
|
|
389
|
+
mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
|
|
390
|
+
}
|
|
462
391
|
if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
|
|
463
|
-
modal = undefined; selected = undefined; index = 0; tab = 0;
|
|
392
|
+
modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
|
|
464
393
|
rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
|
|
465
394
|
}
|
|
466
395
|
if (s.leaseId) { leaseId = s.leaseId; synced = true; }
|
|
@@ -494,7 +423,7 @@ export function createScreen(ctx) {
|
|
|
494
423
|
serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
|
|
495
424
|
}
|
|
496
425
|
function connect(id) {
|
|
497
|
-
connection?.close(); roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
426
|
+
connection?.close(); characterConfirmed=false;mapOpen=undefined; roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
498
427
|
connection = ctx.connect(`/ws/games/spire/rooms/${id}`, accept, connected => {
|
|
499
428
|
online = connected; synced = false;
|
|
500
429
|
if (!connected) note = '连接中断,正在重连;操作已暂停。';
|
|
@@ -509,6 +438,7 @@ export function createScreen(ctx) {
|
|
|
509
438
|
const envelope = { ...pending, leaseId };
|
|
510
439
|
try {
|
|
511
440
|
const response = await ctx.request(`${BASE}/${roomId}/actions`, 'POST', envelope);
|
|
441
|
+
if(envelope.action.type==='character'){characterConfirmed=true;mapOpen=undefined;}
|
|
512
442
|
pending = undefined; note = ''; modal = undefined; accept(response);
|
|
513
443
|
} catch (e) {
|
|
514
444
|
// Unknown transport outcomes retain the operation id; R retries exactly
|
|
@@ -542,7 +472,7 @@ export function createScreen(ctx) {
|
|
|
542
472
|
}
|
|
543
473
|
// The keyboard branches and clicks move the same state through these, so the
|
|
544
474
|
// two input paths cannot drift apart.
|
|
545
|
-
function switchTab(next) { tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
475
|
+
function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
546
476
|
function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; changed(); }
|
|
547
477
|
function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
|
|
548
478
|
function clickRow(id) {
|
|
@@ -713,7 +643,7 @@ export function createScreen(ctx) {
|
|
|
713
643
|
}
|
|
714
644
|
function mapHeader(width, view) {
|
|
715
645
|
const left = `路线选择 · ${state?.act?.name ?? view.act?.name ?? ''}`.trim();
|
|
716
|
-
const buttons = ['[回到当前位置 C]', '[
|
|
646
|
+
const buttons = width<70 ? ['[当前位置 C]', '[总览 F]'] : ['[回到当前位置 C]', '[紧凑总览 F]'];
|
|
717
647
|
const right = buttons.join(' ');
|
|
718
648
|
const head = cut(left, Math.max(8, width - displayWidth(right) - 1));
|
|
719
649
|
const out = pad(head, Math.max(8, width - displayWidth(right))) + right;
|
|
@@ -725,9 +655,10 @@ function mapHeader(width, view) {
|
|
|
725
655
|
const node = data.byId.get(mapFocus ?? view.current);
|
|
726
656
|
if (!node) return '悬停或点击节点查看房间类型与真实后继。';
|
|
727
657
|
const successors = (node.children ?? []).map(id => data.byId.get(id)).filter(Boolean)
|
|
728
|
-
.map(next =>
|
|
658
|
+
.map(next => `${mapCode(next)}[${next.type}]`).join(' ') || '无';
|
|
729
659
|
const standing = node.id === view.current ? '当前位置' : data.reachable.has(node.id) ? '可前往' : data.visited.has(node.id) ? '已走过' : '暂不可达 · 仅查看';
|
|
730
|
-
|
|
660
|
+
const extra = node.id === view.current ? (view.reachable ?? []).filter(id => !(node.children ?? []).includes(id)).map(id => data.byId.get(id)).filter(Boolean).map(n => `${mapCode(n)}[${n.type}]`).join(' ') : '';
|
|
661
|
+
return `选中:${node.type} · ${standing} · ${mapCode(node)} · 后继:${successors}${extra ? ` · 飞靴额外可达:${extra}` : ''}`;
|
|
731
662
|
}
|
|
732
663
|
// --- shop (§E) ---------------------------------------------------------------
|
|
733
664
|
const SHOP_TABS = [['card', '卡牌'], ['relic', '遗物'], ['potion', '药水'], ['removal', '删牌服务']];
|
|
@@ -942,6 +873,8 @@ function mapHeader(width, view) {
|
|
|
942
873
|
}); return;
|
|
943
874
|
}
|
|
944
875
|
if (!canAct()) return;
|
|
876
|
+
if(row.id==='keepCharacter'){characterConfirmed=true;mapOpen=undefined;selected=undefined;index=0;changed();return;}
|
|
877
|
+
if(row.id==='openMap'){mapOpen=true;mapReturnTab=tab;tab=0;changed();return;}
|
|
945
878
|
if (tab === 0 && row.pick) {
|
|
946
879
|
const limit = state.pendingChoice?.maximum ?? state.pendingDecision?.maximum ?? 1;
|
|
947
880
|
if (picked.has(row.pick)) picked.delete(row.pick);
|
|
@@ -949,7 +882,7 @@ function mapHeader(width, view) {
|
|
|
949
882
|
else note=`最多选择 ${limit} 张牌。`;
|
|
950
883
|
changed();return;
|
|
951
884
|
}
|
|
952
|
-
if (tab === 0 && row.card) { cardAction(row.card); return; }
|
|
885
|
+
if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
|
|
953
886
|
if (activeTab() === 'potions' && row.potion) {
|
|
954
887
|
if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
|
|
955
888
|
if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
|
|
@@ -1076,17 +1009,38 @@ function mapHeader(width, view) {
|
|
|
1076
1009
|
}
|
|
1077
1010
|
// The drawing reads the view state and the party; this supplies both.
|
|
1078
1011
|
const mapLines = (view, width) => drawMap(view, width,
|
|
1079
|
-
{ full: mapFull, scroll: mapScroll, anchor: mapAnchor, players: state?.players, options: state?.options });
|
|
1012
|
+
{ full: mapFull, scroll: mapScroll, anchor: mapAnchor, focus:mapFocus, height:mapBudget, players: state?.players, options: state?.options });
|
|
1013
|
+
const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
1080
1014
|
const screen = {
|
|
1081
1015
|
render(width, rows) {
|
|
1082
1016
|
columns = width; height = rows; hits = [];
|
|
1083
1017
|
const frame = [], add = (text, tone = 'text') => frame.push({ text, tone });
|
|
1084
1018
|
const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
|
|
1085
|
-
if (width < 40 || rows < 20) { add(
|
|
1019
|
+
if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
|
|
1086
1020
|
const own = me();
|
|
1087
|
-
const heading = state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
1021
|
+
const heading = needsCharacterChoice() ? '选择初始角色' : state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
1088
1022
|
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
|
|
1089
1023
|
add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
|
|
1024
|
+
if(mapNavigation()) {
|
|
1025
|
+
const view=state.mapView, head=mapHeader(width,view), headerRow=frame.length;
|
|
1026
|
+
add(head.text,'muted');
|
|
1027
|
+
for(const button of head.buttons) hits.push({kind:button.id,id:button.id,row:headerRow,col:button.at,width:button.width,height:1});
|
|
1028
|
+
mapBudget=Math.max(3,rows-frame.length-5);
|
|
1029
|
+
const drawn=mapLines(view,width);
|
|
1030
|
+
for(const line of drawn.lines){
|
|
1031
|
+
const row=frame.length;add(line.text,line.tone);
|
|
1032
|
+
for(const cell of line.cells)hits.push({kind:'node',id:cell.id,row,col:cell.col,width:cell.width,height:1});
|
|
1033
|
+
}
|
|
1034
|
+
while(frame.length<rows-5)add('');
|
|
1035
|
+
add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
|
|
1036
|
+
add(cut(mapDetailText(view,mapData(view)),width),'muted');
|
|
1037
|
+
add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
|
|
1038
|
+
add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
|
|
1039
|
+
mark({kind:'tab',advance:true});
|
|
1040
|
+
add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
|
|
1041
|
+
hits=hits.filter(hit=>hit.row<rows);
|
|
1042
|
+
return frame.slice(0,rows);
|
|
1043
|
+
}
|
|
1090
1044
|
if (unsupported) add(`此扩展版本不认识该阶段「${unsupported}」,请在更新扩展后继续(当前只读)。`, 'error');
|
|
1091
1045
|
if (targeting) add(cut(`正在使用:${targeting.label} · 点击目标或 ←/→ 选择 · Enter 确认 · Esc 取消`, width), 'selected');
|
|
1092
1046
|
if (state) {
|
|
@@ -1098,7 +1052,41 @@ function mapHeader(width, view) {
|
|
|
1098
1052
|
const partyBlocks = party.map(p => allyBlock(p));
|
|
1099
1053
|
const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
|
|
1100
1054
|
const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
|
|
1101
|
-
if
|
|
1055
|
+
if(width>=68 && rows>=24 && !wideBattlefield()) {
|
|
1056
|
+
// Medium terminals keep the two teams side by side. Party members
|
|
1057
|
+
// stack in the left column; enemies page independently on the right.
|
|
1058
|
+
const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
|
|
1059
|
+
const budget=Math.max(4,rows-frame.length-10);
|
|
1060
|
+
const room=Math.max(2,Math.min(5,Math.floor(budget/party.length)));
|
|
1061
|
+
const perPage=Math.max(1,Math.floor(budget/room));
|
|
1062
|
+
const pages=Math.max(1,Math.ceil(alive.length/perPage));
|
|
1063
|
+
if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
|
|
1064
|
+
enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
|
|
1065
|
+
const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
|
|
1066
|
+
const compactBlock=(creature,arena,span)=>{
|
|
1067
|
+
const name=`${arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
|
|
1068
|
+
const health=`${hpText(creature)} HP · 格挡 ${creature.block}`;
|
|
1069
|
+
const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
|
|
1070
|
+
const powers=powerEntries(creature).map(p=>p.text).join(' · ');
|
|
1071
|
+
const body=[name,health];
|
|
1072
|
+
if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
|
|
1073
|
+
if(room>=3)body.push(intent||powers||'无额外状态');
|
|
1074
|
+
if(room>=5)body.push(intent?powers:'');
|
|
1075
|
+
return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
|
|
1076
|
+
};
|
|
1077
|
+
const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
|
|
1078
|
+
shown.map(e=>({id:e.id,lines:compactBlock(e,'enemy',rightWidth),legal:legalEnemy(e)}))];
|
|
1079
|
+
const startRow=frame.length,rendered=[[],[]];
|
|
1080
|
+
columns.forEach((entries,side)=>entries.forEach(entry=>{
|
|
1081
|
+
const top=rendered[side].length;
|
|
1082
|
+
const body=entry.legal?tint(entry.lines,targetTint(entry,targeting.arena),aimed(entry)):entry.lines;
|
|
1083
|
+
rendered[side].push(...body);
|
|
1084
|
+
while(rendered[side].length<top+room)rendered[side].push('');
|
|
1085
|
+
hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
|
|
1086
|
+
}));
|
|
1087
|
+
for(let i=0;i<Math.max(...rendered.map(c=>c.length));i++)add(pad(rendered[0][i]??'',leftWidth)+' │ '+(rendered[1][i]??''));
|
|
1088
|
+
if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · ←/→ 或点击翻页`,'muted');}
|
|
1089
|
+
} else if (wideBattlefield()) {
|
|
1102
1090
|
// The party column is as wide as what it prints (a fixed third used to
|
|
1103
1091
|
// cut 增益/减益 short); the enemies share what is left.
|
|
1104
1092
|
const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
|
|
@@ -1164,6 +1152,13 @@ function mapHeader(width, view) {
|
|
|
1164
1152
|
const buttonAt = Math.max(8, width - displayWidth(button) - 1);
|
|
1165
1153
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
1166
1154
|
add(pad(energy, buttonAt) + button, 'title');
|
|
1155
|
+
const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
|
|
1156
|
+
['exhaust','S','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
|
|
1157
|
+
['discard','D','弃牌堆',state.self.discard.length]];
|
|
1158
|
+
const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
|
|
1159
|
+
const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
|
|
1160
|
+
if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
|
|
1161
|
+
add(place(pileLabels.map((text,i)=>({at:positions[i],text})),width).text,'muted');
|
|
1167
1162
|
} else {
|
|
1168
1163
|
add(`${state.event ?? ''} 金币 ${own?.gold ?? 0}`, 'muted');
|
|
1169
1164
|
// §2: outside combat there is no battlefield, so the party, its health
|
|
@@ -1174,33 +1169,28 @@ function mapHeader(width, view) {
|
|
|
1174
1169
|
add(cut(`${player.id === ctx.user.id ? '› ' : ' '}${player.name}${player.characterName ? ` · ${player.characterName}` : ''} ${hpText(player)} HP · 金币 ${player.gold ?? 0}${flag ? ` · ${flag}` : ''}${player.vote ? ` · 已投票 ${player.vote}` : ''}`, width), player.id === ctx.user.id ? 'selected' : 'text');
|
|
1175
1170
|
}
|
|
1176
1171
|
if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
|
|
1177
|
-
|
|
1178
|
-
// §D: the real act map, drawn from the backend's nodes and edges only.
|
|
1179
|
-
const head = mapHeader(width, state.mapView);
|
|
1180
|
-
const headRow = frame.length;
|
|
1181
|
-
add(head.text, 'muted');
|
|
1182
|
-
for (const button of head.buttons) hits.push({ kind: button.id, id: button.id, row: headRow, col: button.at, width: button.width, height: 1 });
|
|
1183
|
-
const drawn = mapLines(state.mapView, width);
|
|
1184
|
-
for (const line of drawn.lines) {
|
|
1185
|
-
const top = frame.length;
|
|
1186
|
-
add(line.text, line.tone);
|
|
1187
|
-
for (const cell of line.cells) hits.push({ kind: 'node', id: cell.id, row: top, col: cell.col, width: cell.width, height: 1 });
|
|
1188
|
-
}
|
|
1189
|
-
if (drawn.total > drawn.span) add(`本幕 ${drawn.total} 层 · 显示第 ${drawn.from + 1}–${Math.min(drawn.total, drawn.from + drawn.span)} 层 · 滚轮查看`, 'muted');
|
|
1190
|
-
// A cramped frame spends its rows on the map itself: the legend and
|
|
1191
|
-
// the hint are the first things to go, never the rooms.
|
|
1192
|
-
add(mapDetailText(state.mapView, mapData(state.mapView)), 'muted');
|
|
1193
|
-
if (rows >= 24) add(`图例:数字 可前往 · ● 当前位置 · ✓ 已走过 · 黄色数字 已投票人数 · 点击可前往的房间即投票${drawn.flight ? ' · 洋红连线 额外可达(下一层任意房间)' : ''}`, 'muted');
|
|
1194
|
-
}
|
|
1172
|
+
|
|
1195
1173
|
}
|
|
1196
1174
|
const labels = tabs.map((key, i) => {
|
|
1197
1175
|
const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
|
|
1198
1176
|
const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
|
|
1199
|
-
return i === tab ? `[${label}]` : label;
|
|
1177
|
+
return !pileView && i === tab ? `[${label}]` : label;
|
|
1200
1178
|
});
|
|
1201
1179
|
const decision = state.pendingChoice ?? state.pendingDecision;
|
|
1202
|
-
if
|
|
1203
|
-
|
|
1180
|
+
if(modal || (tab===0 && decision)) {
|
|
1181
|
+
add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
|
|
1182
|
+
}else {
|
|
1183
|
+
let text='',spans=[];
|
|
1184
|
+
const flush=()=>{mark({kind:'tab',spans});add(text,'title');text='';spans=[];};
|
|
1185
|
+
for(let i=0;i<labels.length;i++){
|
|
1186
|
+
const label=labels[i];
|
|
1187
|
+
if(text && displayWidth(text+' · '+label)>width)flush();
|
|
1188
|
+
const from=displayWidth(text)+(text?3:0);
|
|
1189
|
+
text+=(text?' · ':'')+label;spans.push({tab:i,from,to:from+displayWidth(label)});
|
|
1190
|
+
}
|
|
1191
|
+
if(text)flush();
|
|
1192
|
+
if(pileView)add(`${tabNames[pileView]} · Esc 返回 · Tab 切换面板`,'title');
|
|
1193
|
+
}
|
|
1204
1194
|
if (modal?.detail) {
|
|
1205
1195
|
const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
|
|
1206
1196
|
const count = Math.max(1, Math.min(6, rows - frame.length - 9));
|
|
@@ -1211,14 +1201,15 @@ function mapHeader(width, view) {
|
|
|
1211
1201
|
}
|
|
1212
1202
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1213
1203
|
if (loading) add('正在加载…', 'muted');
|
|
1214
|
-
const
|
|
1215
|
-
|
|
1216
|
-
|
|
1204
|
+
const footerRows=activeTab()==='potions' && !modal ? 2 : 1;
|
|
1205
|
+
const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - footerRows - 3)) : 0;
|
|
1206
|
+
if (state && state.phase!=='combat') add(''); // breathing room above the list
|
|
1207
|
+
anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1 - (logLines ? logLines + 1 : 0));
|
|
1217
1208
|
pageSize = available;
|
|
1218
1209
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
1219
1210
|
listPage = Math.max(0, Math.min(maxPage, listPage));
|
|
1220
1211
|
const start = listPage * available;
|
|
1221
|
-
if (!list.length && !loading) add(state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1212
|
+
if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1222
1213
|
// §E: a wide frame puts the shop's detail beside the goods; a narrow one
|
|
1223
1214
|
// expands it below the list instead.
|
|
1224
1215
|
const pane = state && state.phase === 'shop' && !modal && list[index]?.shop
|
|
@@ -1255,15 +1246,14 @@ function mapHeader(width, view) {
|
|
|
1255
1246
|
}
|
|
1256
1247
|
}
|
|
1257
1248
|
if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
|
|
1258
|
-
while (frame.length < rows -
|
|
1249
|
+
while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
|
|
1259
1250
|
if (logLines) {
|
|
1260
1251
|
add('战斗日志', 'muted');
|
|
1261
1252
|
const recent = logItems().slice(-logLines);
|
|
1262
1253
|
for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
|
|
1263
1254
|
}
|
|
1264
1255
|
add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
|
|
1265
|
-
|
|
1266
|
-
add('Esc 返回 / 暂停退出 · ? 说明 · R 重试 · B 房间 · D 丢弃药水', 'muted');
|
|
1256
|
+
if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
|
|
1267
1257
|
const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
|
|
1268
1258
|
return frame.slice(0, rows);
|
|
1269
1259
|
},
|
|
@@ -1286,10 +1276,41 @@ function mapHeader(width, view) {
|
|
|
1286
1276
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1287
1277
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1288
1278
|
}
|
|
1289
|
-
if
|
|
1279
|
+
if(value==='m' && state?.mapView?.nodes?.length && !modal && !targeting && !state.paused && !needsCharacterChoice() && !state.pendingChoice && !state.pendingDecision){
|
|
1280
|
+
if(mapNavigation()){mapOpen=false;tab=mapReturnTab;}
|
|
1281
|
+
else{mapReturnTab=tab;tab=0;mapOpen=true;}
|
|
1282
|
+
changed();return;
|
|
1283
|
+
}
|
|
1284
|
+
if (mapNavigation()) {
|
|
1290
1285
|
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1291
|
-
if (value === 'f') { mapFull = !mapFull; changed(); return; }
|
|
1286
|
+
if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
|
|
1287
|
+
if(tab===0 && ['left','right','up','down'].includes(name)) {
|
|
1288
|
+
const data=mapData(state.mapView), here=data.byId.get(mapFocus)??data.current??data.nodes[0];
|
|
1289
|
+
const candidates=data.nodes.filter(n=> name==='left'? n.row===here.row&&n.col<here.col : name==='right'? n.row===here.row&&n.col>here.col : name==='up'?n.row>here.row:n.row<here.row);
|
|
1290
|
+
candidates.sort((a,b)=>Math.abs(a.row-here.row)-Math.abs(b.row-here.row)||Math.abs(a.col-here.col)-Math.abs(b.col-here.col));
|
|
1291
|
+
if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}
|
|
1292
|
+
changed();return;
|
|
1293
|
+
}
|
|
1294
|
+
if(['pageup','pagedown','home','end'].includes(name)){
|
|
1295
|
+
const drawn=mapLines(state.mapView,columns),max=Math.max(0,drawn.total-drawn.span);
|
|
1296
|
+
mapScroll=name==='home'?max:name==='end'?0:Math.max(0,Math.min(max,(mapScroll??drawn.from)+(name==='pageup'?1:-1)*Math.max(1,drawn.span-1)));
|
|
1297
|
+
changed();return;
|
|
1298
|
+
}
|
|
1299
|
+
if(/^[0-9]$/.test(value??'')){
|
|
1300
|
+
const routes=state.options.filter(row=>row.action?.type==='vote'),at=value==='0'?9:Number(value)-1;
|
|
1301
|
+
if(routes[at]){mapFocus=routes[at].action.node;mapAnchor=mapFocus;mapScroll=undefined;changed();}
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if(['return','enter'].includes(name)){
|
|
1305
|
+
const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
|
|
1306
|
+
if(row)void submit({...row.action,phaseId:state.phaseId});
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1292
1309
|
}
|
|
1310
|
+
if(mapNavigation() && ['a','s','d','e',' '].includes(value))return;
|
|
1311
|
+
if(!modal && !targeting && !pileView && tab===0 && state?.phase==='combat' && ['left','right'].includes(name)){enemyPage=Math.max(0,enemyPage+(name==='right'?1:-1));changed();return;}
|
|
1312
|
+
if(!modal && !targeting && state?.phase==='combat' && ['a','s','d'].includes(value) && !(value==='d' && activeTab()==='potions')){openPile({a:'draw',s:'exhaust',d:'discard'}[value]);return;}
|
|
1313
|
+
if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
|
|
1293
1314
|
if (value === ' ' && !modal && !targeting) {
|
|
1294
1315
|
const row = items()[index];
|
|
1295
1316
|
if (row?.pick) {
|
|
@@ -1305,14 +1326,14 @@ function mapHeader(width, view) {
|
|
|
1305
1326
|
// never play a card by itself. Enter, or a click, is what acts.
|
|
1306
1327
|
if (!modal && !targeting && /^[0-9]$/.test(value ?? '')) {
|
|
1307
1328
|
const list = items(), at = listPage * pageSize + (value === '0' ? 9 : Number(value) - 1);
|
|
1308
|
-
if (at < list.length) moveTo(at);
|
|
1329
|
+
if (at < list.length) { moveTo(at); if(state?.phase==='map' && tab===0){mapFocus=list[at].action?.node;mapAnchor=mapFocus;mapScroll=undefined;} }
|
|
1309
1330
|
return;
|
|
1310
1331
|
}
|
|
1311
1332
|
if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
|
|
1312
1333
|
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; modal = undefined; anchor(); changed(); }
|
|
1313
1334
|
else ctx.closeScreen(); return;
|
|
1314
1335
|
}
|
|
1315
|
-
if (columns <
|
|
1336
|
+
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1316
1337
|
if (value === '?' && !modal) {
|
|
1317
1338
|
const row = items()[index];
|
|
1318
1339
|
const lines = ctx.wrap(row?.label ?? '方向键选择,Enter 确认;全部玩家结束回合后敌人行动。退出或掉线会暂停整局。', Math.max(1, columns - 4));
|
|
@@ -1327,7 +1348,6 @@ function mapHeader(width, view) {
|
|
|
1327
1348
|
void guard(async () => { connection?.close(); connection = undefined; roomId = undefined; state = undefined; modal = undefined; pending = undefined; online = synced = false; selected = 'create'; index = 0; await loadRooms(); }); return;
|
|
1328
1349
|
}
|
|
1329
1350
|
if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
|
|
1330
|
-
if (value === 's' && !modal && state) { switchTab(tabs.indexOf('status')); return; }
|
|
1331
1351
|
// The key and the button share one entry point.
|
|
1332
1352
|
if (value === 'e' && !modal && !targeting) { endTurn(); return; }
|
|
1333
1353
|
if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
|
|
@@ -1351,7 +1371,7 @@ function mapHeader(width, view) {
|
|
|
1351
1371
|
if (name === 'return' || name === 'enter') confirm();
|
|
1352
1372
|
},
|
|
1353
1373
|
mouse(event) {
|
|
1354
|
-
if (columns <
|
|
1374
|
+
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1355
1375
|
if (event.action === 'move') {
|
|
1356
1376
|
// Hover lights a row, or shows which target the pointer is on.
|
|
1357
1377
|
const pointed = hitAt(event.x, event.y);
|
|
@@ -1374,11 +1394,13 @@ function mapHeader(width, view) {
|
|
|
1374
1394
|
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1375
1395
|
// room is only looked at, and parks the view there.
|
|
1376
1396
|
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
1377
|
-
const row =
|
|
1397
|
+
const row = state.phase==='map' && state.options.find(row => row.action?.type === 'vote' && row.action.node === hit.id);
|
|
1378
1398
|
if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
|
|
1379
1399
|
changed();
|
|
1380
1400
|
return;
|
|
1381
1401
|
}
|
|
1402
|
+
if(hit?.kind==='enemyPage' && !targeting && !modal){enemyPage=(enemyPage+1)%hit.pages;changed();return;}
|
|
1403
|
+
if (hit?.kind === 'pile') {openPile(hit.id);return;}
|
|
1382
1404
|
if (hit?.kind === 'shopBuy') {
|
|
1383
1405
|
const row = items()[index];
|
|
1384
1406
|
const price = row?.shop?.price ?? 0;
|
|
@@ -1402,10 +1424,10 @@ function mapHeader(width, view) {
|
|
|
1402
1424
|
// An open detail pane pages first, exactly like PgUp/PgDn do.
|
|
1403
1425
|
if (modal?.detail) { pageDetail(step * 3); return; }
|
|
1404
1426
|
// §D: on the map the wheel looks around the act instead of moving the cursor.
|
|
1405
|
-
if (
|
|
1427
|
+
if (mapNavigation()) {
|
|
1406
1428
|
const drawn = mapLines(state.mapView, columns);
|
|
1407
|
-
mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from)
|
|
1408
|
-
|
|
1429
|
+
mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from) - step));
|
|
1430
|
+
changed(); return;
|
|
1409
1431
|
}
|
|
1410
1432
|
moveTo(index + step);
|
|
1411
1433
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkchat-extension-spire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Optional cooperative card-climbing TUI for LinkChat",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"directory": "extensions/spire"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
|
-
"access": "public"
|
|
26
|
-
"tag": "beta"
|
|
25
|
+
"access": "public"
|
|
27
26
|
}
|
|
28
27
|
}
|