linkchat-extension-spire 0.1.0 → 0.1.7
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 +4 -4
- package/index.js +348 -144
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.1.
|
|
1
|
+
# Spire for LinkChat — 0.1.7
|
|
2
2
|
|
|
3
3
|
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘,见下),以及管理员安装并启用 `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.7.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
|
|
6
6
|
|
|
7
7
|
## 操作
|
|
8
8
|
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
**其他**:Tab 依次查看牌组、抽牌堆、弃牌堆、消耗堆、遗物、药水、状态、日志;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
|
|
22
22
|
|
|
23
|
-
**鼠标**(需要客户端宿主 0.7.0
|
|
23
|
+
**鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部面板名切换面板;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
|
|
24
24
|
|
|
25
25
|
## 内容
|
|
26
26
|
|
|
27
|
-
五位角色:铁甲战士(80 生命)、静默猎手(70)、故障机器人(75
|
|
27
|
+
五位角色:铁甲战士(80 生命)、静默猎手(70)、故障机器人(75)、储君(75)、亡灵契约师(66)。三幕路线:首幕在蔓生之地与地下码头之间抽取,随后进入巢穴与荣光,每幕 13–15 层,各有自己的事件池与古神。
|
|
28
28
|
|
|
29
29
|
规则参考 v0.111.0 并与之一致:596 张牌(攻击 198、技能 246、能力 113,另有状态 17、诅咒 18、任务 4)、298 件遗物、64 种药水、80 组遭遇、108 种敌人、57 个事件。中文文本自行编写;分支地图与内容抽取为精选内容适配,不兼容原作种子、存档及联机。无原游戏程序、美术、音频或反编译源码。
|
|
30
30
|
|
package/index.js
CHANGED
|
@@ -81,6 +81,272 @@ export const internalIntentKeys = {
|
|
|
81
81
|
sleeping: '引擎以 asleep 力量表示沉睡,本字段是数据标记',
|
|
82
82
|
};
|
|
83
83
|
const signed = value => `${value > 0 ? '+' : ''}${value}`;
|
|
84
|
+
// --- act map (§D) -----------------------------------------------------------
|
|
85
|
+
const MAP_CELL = 10;
|
|
86
|
+
const FLIGHT_COLOUR = '\x1b[95m'; // a move only a WingedBoots charge allows
|
|
87
|
+
function mapData(view) {
|
|
88
|
+
const nodes = (view?.nodes ?? []).filter(node => node && typeof node.id === 'string');
|
|
89
|
+
const byId = new Map(nodes.map(node => [node.id, node]));
|
|
90
|
+
const rows = new Map();
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (!rows.has(node.row)) rows.set(node.row, []);
|
|
93
|
+
rows.get(node.row).push(node);
|
|
94
|
+
}
|
|
95
|
+
for (const list of rows.values()) list.sort((left, right) => left.col - right.col);
|
|
96
|
+
return { nodes, byId, rows, keys: [...rows.keys()].sort((left, right) => right - left),
|
|
97
|
+
current: byId.get(view.current), reachable: new Set(view.reachable ?? []), visited: new Set(view.path ?? []) };
|
|
98
|
+
}
|
|
99
|
+
// Places cells at display columns, so the frame stays aligned whatever the
|
|
100
|
+
// terminal's character widths are.
|
|
101
|
+
function place(cells, width) {
|
|
102
|
+
let out = '', at = 0;
|
|
103
|
+
const placed = [];
|
|
104
|
+
for (const cell of [...cells].sort((left, right) => left.at - right.at)) {
|
|
105
|
+
if (cell.at < at) continue;
|
|
106
|
+
out += ' '.repeat(cell.at - at) + cell.text;
|
|
107
|
+
placed.push({ ...cell, from: cell.at, width: displayWidth(cell.text) });
|
|
108
|
+
at = cell.at + displayWidth(cell.text);
|
|
109
|
+
}
|
|
110
|
+
return { text: cut(out, width), placed };
|
|
111
|
+
}
|
|
112
|
+
function mapRouteIndex(id, options) {
|
|
113
|
+
return (options ?? []).findIndex(row => row.action?.type === 'vote' && row.action.node === id);
|
|
114
|
+
}
|
|
115
|
+
// One glyph per set of directions, so a corner always joins the lines that meet.
|
|
116
|
+
const JOIN = {
|
|
117
|
+
u: '│', d: '│', l: '─', r: '─', ud: '│', ul: '┘', ur: '└', dl: '┐', dr: '┌', lr: '─',
|
|
118
|
+
udl: '┤', udr: '├', ulr: '┴', dlr: '┬', udlr: '┼',
|
|
119
|
+
};
|
|
120
|
+
const joinGlyph = directions => {
|
|
121
|
+
const set = new Set(directions);
|
|
122
|
+
const key = `${set.has('u') ? 'u' : ''}${set.has('d') ? 'd' : ''}${set.has('l') ? 'l' : ''}${set.has('r') ? 'r' : ''}`;
|
|
123
|
+
return JOIN[key] ?? '─';
|
|
124
|
+
};
|
|
125
|
+
function mapLabel(view, data, node, full, options) {
|
|
126
|
+
if (full) return node.id === view.current ? '●' : data.visited.has(node.id) ? '✓' : data.reachable.has(node.id) ? '◆' : '·';
|
|
127
|
+
const route = mapRouteIndex(node.id, options);
|
|
128
|
+
const mark = node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : '';
|
|
129
|
+
return `${mark}${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
130
|
+
}
|
|
131
|
+
// Where can I go is the first question a map answers, so the state is a colour:
|
|
132
|
+
// the position bright cyan, a room you can enter bright green, the rest grey.
|
|
133
|
+
function mapPaint(view, data, node, label) {
|
|
134
|
+
const colour = node.id === view.current ? '\x1b[1;96m' : data.reachable.has(node.id) ? '\x1b[92m' : '\x1b[90m';
|
|
135
|
+
return `${colour}${label}\x1b[0m`;
|
|
136
|
+
}
|
|
137
|
+
// A vote is a coloured digit after the room, so a party sees the tally on the map.
|
|
138
|
+
function mapVotes(node, players) {
|
|
139
|
+
const seats = (players ?? []).filter(player => player.vote === node.id).length;
|
|
140
|
+
return seats ? { text: `\x1b[33m${seats}\x1b[0m`, width: 1 } : { text: '', width: 0 };
|
|
141
|
+
}
|
|
142
|
+
// One lane column per room that links forward. Two rooms whose spans overlap
|
|
143
|
+
// cannot share a column; rooms whose spans are separated by a blank line can, so
|
|
144
|
+
// a gap is only as wide as its busiest stretch.
|
|
145
|
+
function mapLanes(data, layers, index, extra) {
|
|
146
|
+
const plans = [];
|
|
147
|
+
for (const parent of data.rows.get(layers[index]) ?? []) {
|
|
148
|
+
const kids = (data.rows.get(layers[index + 1]) ?? []).filter(child => (parent.children ?? []).includes(child.id));
|
|
149
|
+
const flights = (extra?.get(parent.id) ?? []).filter(id => !kids.some(child => child.id === id));
|
|
150
|
+
if (!kids.length && !flights.length) continue;
|
|
151
|
+
const targets = [...kids.map(kid => kid.col), ...flights.map(id => data.byId.get(id).col)].sort((left, right) => left - right);
|
|
152
|
+
plans.push({ id: parent.id, line: parent.col, kids: targets,
|
|
153
|
+
flight: new Set(flights),
|
|
154
|
+
owns: new Set([...kids.map(kid => kid.id), ...flights]),
|
|
155
|
+
top: Math.min(parent.col, ...targets), bottom: Math.max(parent.col, ...targets), lane: 0 });
|
|
156
|
+
}
|
|
157
|
+
// A room at line L draws a rail along line L. A visitor whose kid lands on L
|
|
158
|
+
// must therefore sit to its right, or the visitor's rail would cross the
|
|
159
|
+
// resident's lane -- and a crossing there reads as a move that does not exist.
|
|
160
|
+
const resident = new Map(plans.map(plan => [plan.line, plan.id]));
|
|
161
|
+
const before = new Map(plans.map(plan => [plan.id, new Set()]));
|
|
162
|
+
for (const plan of plans) {
|
|
163
|
+
for (const line of plan.kids) {
|
|
164
|
+
const host = resident.get(line);
|
|
165
|
+
if (host !== undefined && host !== plan.id) before.get(plan.id).add(host);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const order = [];
|
|
169
|
+
const pending = new Set(plans.map(plan => plan.id));
|
|
170
|
+
while (pending.size) {
|
|
171
|
+
const next = [...pending].filter(id => before.get(id).size === 0).sort()[0];
|
|
172
|
+
if (next === undefined) break; // a cycle: no order removes it
|
|
173
|
+
order.push(next);
|
|
174
|
+
pending.delete(next);
|
|
175
|
+
for (const id of pending) before.get(id).delete(next);
|
|
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);
|
|
279
|
+
}
|
|
280
|
+
for (const line of plan.kids) {
|
|
281
|
+
const kid = (data.rows.get(layers[index + 1]) ?? []).find(room => room.col === line);
|
|
282
|
+
const flight = kid && plan.flight.has(kid.id);
|
|
283
|
+
for (let x = lane + 1; x < at(index + 1); x++) add(line, x, plan.id, 'lr', kid?.id, flight);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const lines = [];
|
|
288
|
+
for (let line = 0; line < height; line++) {
|
|
289
|
+
const row = [];
|
|
290
|
+
for (const [key, part] of rail) {
|
|
291
|
+
if (part.line !== line) continue;
|
|
292
|
+
const directions = new Set();
|
|
293
|
+
if (part.v !== null) {
|
|
294
|
+
// A lane joins its own taps. A foreign rail crossing it is cut -- unless
|
|
295
|
+
// both lead to the same room, where the join is simply true.
|
|
296
|
+
for (const direction of part.vd) directions.add(direction);
|
|
297
|
+
// A rail crossing a lane joins it: whatever the player follows, the run
|
|
298
|
+
// stays on the room's own line, and every line on that line leads to the
|
|
299
|
+
// one room this line reaches.
|
|
300
|
+
for (const set of part.h.values()) for (const direction of set) directions.add(direction);
|
|
301
|
+
} else {
|
|
302
|
+
for (const set of part.h.values()) for (const direction of set) directions.add(direction);
|
|
303
|
+
}
|
|
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
|
+
}
|
|
308
|
+
for (const [key, room] of node) {
|
|
309
|
+
const [on, col] = key.split(':').map(Number);
|
|
310
|
+
if (on === line) row.push({ at: col, text: room.label, node: room.room, plain: room.plain });
|
|
311
|
+
}
|
|
312
|
+
const placed = place(row.sort((left, right) => left.at - right.at), width);
|
|
313
|
+
const here = placed.placed.filter(entry => entry.node);
|
|
314
|
+
const tone = here.some(entry => entry.node.id === view.current) ? 'selected'
|
|
315
|
+
: here.some(entry => data.reachable.has(entry.node.id)) ? 'text' : 'muted';
|
|
316
|
+
lines.push({ text: placed.text, tone,
|
|
317
|
+
cells: here.map(entry => ({ id: entry.node.id, row: 0, col: entry.from, width: displayWidth(entry.plain ?? entry.text), height: 1 })) });
|
|
318
|
+
}
|
|
319
|
+
return { lines, from, span, total: layers.length, height,
|
|
320
|
+
flight: (extra.get(data.current?.id) ?? []).length > 0 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The package ships without dependencies, so the tab strip is measured here.
|
|
324
|
+
// Its labels are tabNames (CJK), pile counts in fullwidth parens, brackets.
|
|
325
|
+
const wideChar = character => { const code = character.codePointAt(0); return code >= 0x1100 && (code <= 0x115f || (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)); };
|
|
326
|
+
// A coloured digit is zero columns wide: measuring and cutting step over the
|
|
327
|
+
// escape sequences instead of counting their characters.
|
|
328
|
+
const ESCAPE_END = /[a-zA-Z]/;
|
|
329
|
+
const displayWidth = text => {
|
|
330
|
+
let width = 0, escape = false;
|
|
331
|
+
for (const character of String(text)) {
|
|
332
|
+
if (escape) { if (ESCAPE_END.test(character)) escape = false; continue; }
|
|
333
|
+
if (character === '\x1b') { escape = true; continue; }
|
|
334
|
+
width += wideChar(character) ? 2 : 1;
|
|
335
|
+
}
|
|
336
|
+
return width;
|
|
337
|
+
};
|
|
338
|
+
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
339
|
+
function cut(text, width) {
|
|
340
|
+
let out = '', used = 0, escape = false, trimmed = false;
|
|
341
|
+
for (const character of text) {
|
|
342
|
+
if (escape) { out += character; if (ESCAPE_END.test(character)) escape = false; continue; }
|
|
343
|
+
if (character === '\x1b') { out += character; escape = true; continue; }
|
|
344
|
+
const size = wideChar(character) ? 2 : 1;
|
|
345
|
+
if (used + size > width) { trimmed = true; break; }
|
|
346
|
+
out += character; used += size;
|
|
347
|
+
}
|
|
348
|
+
return trimmed && out.includes('\x1b') ? `${out}\x1b[0m` : out;
|
|
349
|
+
}
|
|
84
350
|
export function createScreen(ctx) {
|
|
85
351
|
let state, roomId, leaseId, connection, online = false, synced = false, rooms = [], loading = true, busy = false, closed = false;
|
|
86
352
|
let tab = 0, selected = 'create', index = 0, modal, note = '', pending, columns = 80, height = 24, serverProtocol = 1, unsupported;
|
|
@@ -90,7 +356,8 @@ export function createScreen(ctx) {
|
|
|
90
356
|
let enemyPage = 0; // the enemy block page the battlefield is showing
|
|
91
357
|
let listPage = 0; // the 1-9/0 page of the item list
|
|
92
358
|
let pageSize = 10; // how many items a page holds at the current size
|
|
93
|
-
let mapScroll; // the map row the view starts at (undefined follows the
|
|
359
|
+
let mapScroll; // the map row the view starts at (undefined follows the anchor)
|
|
360
|
+
let mapAnchor; // the room the viewport is parked on (a click or 回到当前位置)
|
|
94
361
|
let mapFocus; // the node being looked at on the map, never a submitted route
|
|
95
362
|
let mapFull = false; // §D 查看全图: every row of the act, markers only
|
|
96
363
|
let chain; // §G: the claim that follows this player's own treasure pick
|
|
@@ -99,7 +366,6 @@ export function createScreen(ctx) {
|
|
|
99
366
|
let rewardPick = new Map(); // §F the candidate chosen in each card reward
|
|
100
367
|
let skipQueue = []; // §F the server skip rows still to run, in reward order
|
|
101
368
|
let skipKnown = new Set(); // §F the rows that existed when the skip was confirmed
|
|
102
|
-
let shopTab = 'card'; // §E the shop category the goods list shows
|
|
103
369
|
const picked = new Set(); let choiceReturn;
|
|
104
370
|
const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
|
|
105
371
|
const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
|
|
@@ -152,11 +418,10 @@ export function createScreen(ctx) {
|
|
|
152
418
|
const open = rewardOpen === group.key;
|
|
153
419
|
rows.push({ id: group.key, label: `${open ? '▾' : '▸'} ${group.title} ${status}`, group: group.key, open });
|
|
154
420
|
if (!open) continue;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
421
|
+
// Picking the card is the claim: no separate 领取 row, and the way past a
|
|
422
|
+
// card reward is 跳过剩余奖励并离开 at the end of the list.
|
|
423
|
+
for (const row of group.candidates) rows.push({ ...row, label: ` ${row.label.replace(/^奖励 \d+:/, '')}`, candidate: true, group: group.key });
|
|
158
424
|
for (const row of group.others) rows.push({ ...row, label: ` ${row.label}`, group: group.key });
|
|
159
|
-
rows.push({ id: `back:${group.key}`, label: ' [返回奖励清单]' });
|
|
160
425
|
}
|
|
161
426
|
rows.push(rows.length ? { id: 'skipAll', label: '跳过剩余奖励并离开' }
|
|
162
427
|
: { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
|
|
@@ -349,10 +614,6 @@ export function createScreen(ctx) {
|
|
|
349
614
|
const at = items().findIndex(row => row.id === id);
|
|
350
615
|
if (at >= 0) moveTo(at);
|
|
351
616
|
}
|
|
352
|
-
// The package ships without dependencies, so the tab strip is measured here.
|
|
353
|
-
// Its labels are tabNames (CJK), pile counts in fullwidth parens, brackets.
|
|
354
|
-
const wideChar = character => { const code = character.codePointAt(0); return code >= 0x1100 && (code <= 0x115f || (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)); };
|
|
355
|
-
const displayWidth = text => [...text].reduce((total, character) => total + (wideChar(character) ? 2 : 1), 0);
|
|
356
617
|
function tabSpans(labels) { let from = 0; return labels.map((label, index) => { const span = { tab: index, from, to: from + displayWidth(label) }; from = span.to + 3; return span; }); }
|
|
357
618
|
// --- battlefield ------------------------------------------------------------
|
|
358
619
|
const BAR_CELLS = 10;
|
|
@@ -376,16 +637,6 @@ export function createScreen(ctx) {
|
|
|
376
637
|
}
|
|
377
638
|
return [buffs, debuffs];
|
|
378
639
|
}
|
|
379
|
-
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
380
|
-
function cut(text, width) {
|
|
381
|
-
let out = '', used = 0;
|
|
382
|
-
for (const character of text) {
|
|
383
|
-
const size = wideChar(character) ? 2 : 1;
|
|
384
|
-
if (used + size > width) break;
|
|
385
|
-
out += character; used += size;
|
|
386
|
-
}
|
|
387
|
-
return out;
|
|
388
|
-
}
|
|
389
640
|
function powerEntries(creature) {
|
|
390
641
|
const monologues = creature.monologueInstances ?? [];
|
|
391
642
|
return Object.entries(creature.powers ?? {}).filter(([, value]) => value !== 0).flatMap(([key, value]) =>
|
|
@@ -460,84 +711,8 @@ export function createScreen(ctx) {
|
|
|
460
711
|
return [`${player.id === ctx.user.id ? '› ' : ''}${player.name}${player.characterName ? ` · ${player.characterName}` : ''}${player.done ? ' · 已结束' : ''}`,
|
|
461
712
|
...statLines(player, resources ? [resources] : [], width)];
|
|
462
713
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
function mapData(view) {
|
|
466
|
-
const nodes = (view?.nodes ?? []).filter(node => node && typeof node.id === 'string');
|
|
467
|
-
const byId = new Map(nodes.map(node => [node.id, node]));
|
|
468
|
-
const rows = new Map();
|
|
469
|
-
for (const node of nodes) {
|
|
470
|
-
if (!rows.has(node.row)) rows.set(node.row, []);
|
|
471
|
-
rows.get(node.row).push(node);
|
|
472
|
-
}
|
|
473
|
-
for (const list of rows.values()) list.sort((left, right) => left.col - right.col);
|
|
474
|
-
return { byId, rows, keys: [...rows.keys()].sort((left, right) => right - left),
|
|
475
|
-
current: byId.get(view.current), reachable: new Set(view.reachable ?? []), visited: new Set(view.path ?? []) };
|
|
476
|
-
}
|
|
477
|
-
// Places cells at display columns, so the frame stays aligned whatever the
|
|
478
|
-
// terminal's character widths are.
|
|
479
|
-
function place(cells, width) {
|
|
480
|
-
let out = '', at = 0;
|
|
481
|
-
const placed = [];
|
|
482
|
-
for (const cell of [...cells].sort((left, right) => left.at - right.at)) {
|
|
483
|
-
if (cell.at < at) continue;
|
|
484
|
-
out += ' '.repeat(cell.at - at) + cell.text;
|
|
485
|
-
placed.push({ ...cell, from: cell.at, width: displayWidth(cell.text) });
|
|
486
|
-
at = cell.at + displayWidth(cell.text);
|
|
487
|
-
}
|
|
488
|
-
return { text: cut(out, width), placed };
|
|
489
|
-
}
|
|
490
|
-
function mapRouteIndex(id) {
|
|
491
|
-
return (state.options ?? []).findIndex(row => row.action?.type === 'vote' && row.action.node === id);
|
|
492
|
-
}
|
|
493
|
-
function mapLines(view, width, budget) {
|
|
494
|
-
const data = mapData(view);
|
|
495
|
-
const cell = Math.max(7, Math.min(MAP_CELL, Math.floor((width - 2) / 7)));
|
|
496
|
-
const span = Math.max(1, Math.min(7, Math.floor((width - 2) / cell)));
|
|
497
|
-
const follow = data.byId.get(mapFocus) ?? data.current;
|
|
498
|
-
const center = follow ? follow.col : Math.floor((span - 1) / 2);
|
|
499
|
-
const from = Math.max(0, Math.min(7 - span, center - Math.floor(span / 2)));
|
|
500
|
-
const at = col => 1 + (col - from) * cell;
|
|
501
|
-
const shown = col => col >= from && col < from + span;
|
|
502
|
-
const body = mapFull ? data.keys.length : Math.max(1, Math.floor((budget - 2) / 2));
|
|
503
|
-
const followAt = data.keys.indexOf(follow ? follow.row : data.keys[0]);
|
|
504
|
-
const top = Math.max(0, Math.min(data.keys.length - body, mapScroll ?? (followAt < 0 ? 0 : followAt - 2)));
|
|
505
|
-
const lines = [];
|
|
506
|
-
for (let index = top; index < Math.min(data.keys.length, top + body); index++) {
|
|
507
|
-
const key = data.keys[index];
|
|
508
|
-
const above = mapFull ? [] : data.rows.get(data.keys[index - 1]) ?? [];
|
|
509
|
-
const cells = [];
|
|
510
|
-
for (const node of data.rows.get(key) ?? []) {
|
|
511
|
-
if (!shown(node.col)) continue;
|
|
512
|
-
const route = mapRouteIndex(node.id);
|
|
513
|
-
const label = mapFull ? (node.id === view.current ? '●' : data.visited.has(node.id) ? '✓' : data.reachable.has(node.id) ? '◆' : '·')
|
|
514
|
-
: `${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
515
|
-
const inset = mapFull ? 0 : Math.max(0, Math.floor((cell - displayWidth(label)) / 2));
|
|
516
|
-
const text = ' '.repeat(inset) + label;
|
|
517
|
-
cells.push({ text, at: at(node.col), node,
|
|
518
|
-
tone: node.id === view.current ? 'selected' : data.reachable.has(node.id) ? 'text' : 'muted' });
|
|
519
|
-
}
|
|
520
|
-
const links = [];
|
|
521
|
-
if (above.length) for (const parent of data.rows.get(key) ?? []) {
|
|
522
|
-
for (const child of above) {
|
|
523
|
-
if (!(parent.children ?? []).includes(child.id) || !shown(child.col) || !shown(parent.col)) continue;
|
|
524
|
-
links.push(child.col === parent.col
|
|
525
|
-
? { text: '│', at: at(child.col) + Math.floor((mapFull ? 1 : cell) / 2) }
|
|
526
|
-
: { text: child.col < parent.col ? '╲' : '╱', at: Math.floor((at(child.col) + at(parent.col) + (mapFull ? 1 : cell)) / 2) });
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
if (links.length) lines.push({ text: place(links, width).text, tone: 'muted', cells: [] });
|
|
530
|
-
// The frame carries one tone per row, so the row reads as the state of its
|
|
531
|
-
// nodes: the position wins, then a reachable neighbour, then grey.
|
|
532
|
-
const tone = cells.some(item => item.node.id === view.current) ? 'selected'
|
|
533
|
-
: cells.some(item => data.reachable.has(item.node.id)) ? 'text' : 'muted';
|
|
534
|
-
const drawn = place(cells, width);
|
|
535
|
-
lines.push({ text: drawn.text, tone, cells: drawn.placed });
|
|
536
|
-
}
|
|
537
|
-
return { lines, top, body, total: data.keys.length };
|
|
538
|
-
}
|
|
539
|
-
function mapHeader(width, view) {
|
|
540
|
-
const left = `路线选择 · ${view.act?.name ?? ''}`.trim();
|
|
714
|
+
function mapHeader(width, view) {
|
|
715
|
+
const left = `路线选择 · ${state?.act?.name ?? view.act?.name ?? ''}`.trim();
|
|
541
716
|
const buttons = ['[回到当前位置 C]', '[查看全图 F]'];
|
|
542
717
|
const right = buttons.join(' ');
|
|
543
718
|
const head = cut(left, Math.max(8, width - displayWidth(right) - 1));
|
|
@@ -564,11 +739,19 @@ export function createScreen(ctx) {
|
|
|
564
739
|
function shopRows() {
|
|
565
740
|
const rows = state.options ?? [];
|
|
566
741
|
const leave = rows.filter(row => row.action?.type === 'continue');
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
742
|
+
const sections = [];
|
|
743
|
+
for (const [kind, name] of shopCategories()) {
|
|
744
|
+
const goods = rows.filter(row => (row.action?.type === 'buy' && (row.shop?.kind ?? 'card') === kind)
|
|
745
|
+
|| (kind === 'removal' && row.action?.type === 'removeCard'));
|
|
746
|
+
if (!goods.length) continue;
|
|
747
|
+
sections.push({ id: `section:${kind}`, label: `--${name}--`, separator: true });
|
|
748
|
+
sections.push(...goods);
|
|
749
|
+
}
|
|
750
|
+
if (!sections.length) return rows;
|
|
751
|
+
const listed = new Set(sections.map(row => row.id));
|
|
752
|
+
// Anything the shop cannot place — a nested reward, a forced choice — keeps
|
|
753
|
+
// the generic list instead of disappearing.
|
|
754
|
+
return [...sections, ...rows.filter(row => !listed.has(row.id) && !leave.includes(row)), ...leave];
|
|
572
755
|
}
|
|
573
756
|
// The pane is the same block every time: what it is, what it does, what it
|
|
574
757
|
// costs, and the one button that pays for it.
|
|
@@ -681,6 +864,21 @@ export function createScreen(ctx) {
|
|
|
681
864
|
if (enemy) choose(`${enemy.name} · Esc 返回`, [intentLines(enemy.intent, enemy, state).join(' · ') || '(本回合无行动)', `${hpText(enemy)} HP · 格挡 ${enemy.block} · ${powersText(enemy.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
|
|
682
865
|
else if (player) choose(`${player.name} · Esc 返回`, [`${hpText(player)} HP · 格挡 ${player.block} · ${powersText(player.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
|
|
683
866
|
}
|
|
867
|
+
// §C: a legal target is marked by colour — enemies red, allies blue — and the
|
|
868
|
+
// one being aimed at is the brighter shade.
|
|
869
|
+
const TINTS = { enemy: 31, enemyHot: 91, ally: 34, allyHot: 94 };
|
|
870
|
+
const targetTint = (entry, arena) => `\x1b[${entry.id === targeting?.id ? TINTS[`${arena}Hot`] : TINTS[arena]}m`;
|
|
871
|
+
// Colour is invisible on a host that strips it, so the aimed block also says so
|
|
872
|
+
// in plain text, on its first line that has something to show.
|
|
873
|
+
const aimed = entry => entry.id === targeting?.id ? '▶' : '';
|
|
874
|
+
const tint = (lines, colour, mark) => {
|
|
875
|
+
let marked = false;
|
|
876
|
+
return lines.map(line => {
|
|
877
|
+
const lead = !marked && mark && line.trim() ? mark : '';
|
|
878
|
+
if (lead) marked = true;
|
|
879
|
+
return `${colour}${lead}${line}\x1b[0m`;
|
|
880
|
+
});
|
|
881
|
+
};
|
|
684
882
|
function framed(lines, width, style) {
|
|
685
883
|
const [left, right] = style === 'current' ? ['║', '║'] : ['│', '│'];
|
|
686
884
|
const [topLeft, topRight, bottomLeft, bottomRight, bar] = style === 'current' ? ['╔', '╗', '╚', '╝', '═'] : ['┌', '┐', '└', '┘', '─'];
|
|
@@ -763,7 +961,13 @@ export function createScreen(ctx) {
|
|
|
763
961
|
else void submit(action); return;
|
|
764
962
|
}
|
|
765
963
|
if (tab === 0 && row.group && row.id === row.group) { if (row.open) rewardPick.delete(row.group); rewardOpen = row.open ? undefined : row.group; changed(); return; }
|
|
766
|
-
if (tab === 0 && row.candidate
|
|
964
|
+
if (tab === 0 && row.candidate && row.action) {
|
|
965
|
+
// Picking a card reward's card is the deliberate claim.
|
|
966
|
+
const action = { ...row.action, phaseId: state.phaseId };
|
|
967
|
+
rewardLog.set(row.group, '已领取');
|
|
968
|
+
void submit(action);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
767
971
|
if (tab === 0 && row.group && row.id.startsWith('claim:')) {
|
|
768
972
|
// §F: the deliberate claim: a card reward claims the chosen candidate, any
|
|
769
973
|
// other reward claims by its own row.
|
|
@@ -829,7 +1033,6 @@ export function createScreen(ctx) {
|
|
|
829
1033
|
void submit(action);
|
|
830
1034
|
}
|
|
831
1035
|
else if (row.group) {
|
|
832
|
-
if (row.id.startsWith('back:')) { rewardOpen = undefined; changed(); return; }
|
|
833
1036
|
if (row.skipped) rewardLog.set(row.group, '已跳过'); else if (row.action) rewardLog.set(row.group, '已领取');
|
|
834
1037
|
if (row.action) void submit({ ...row.action, phaseId: state.phaseId });
|
|
835
1038
|
else changed();
|
|
@@ -871,6 +1074,9 @@ export function createScreen(ctx) {
|
|
|
871
1074
|
else void submit(row.action);
|
|
872
1075
|
}
|
|
873
1076
|
}
|
|
1077
|
+
// The drawing reads the view state and the party; this supplies both.
|
|
1078
|
+
const mapLines = (view, width) => drawMap(view, width,
|
|
1079
|
+
{ full: mapFull, scroll: mapScroll, anchor: mapAnchor, players: state?.players, options: state?.options });
|
|
874
1080
|
const screen = {
|
|
875
1081
|
render(width, rows) {
|
|
876
1082
|
columns = width; height = rows; hits = [];
|
|
@@ -914,7 +1120,7 @@ export function createScreen(ctx) {
|
|
|
914
1120
|
...party.map(player => ({ id: String(player?.id), lines: ['', ...allyBlock(player, allyWidth - 2)], legal: legalAlly(String(player?.id)) })),
|
|
915
1121
|
...shown.map(enemy => ({ id: enemy.id, lines: enemyBlock(enemy, foeWidth - 2), legal: legalEnemy(enemy) })),
|
|
916
1122
|
];
|
|
917
|
-
const body = blocks.map(
|
|
1123
|
+
const body = blocks.map(entry => entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines);
|
|
918
1124
|
const tall = Math.max(...body.map(lines => lines.length));
|
|
919
1125
|
const startRow = frame.length;
|
|
920
1126
|
for (let line = 0; line < tall; line++) add(body.map((lines, index) => pad(cut(lines[line] ?? '', widths[index] - 2), widths[index])).join(' '), 'text');
|
|
@@ -942,8 +1148,8 @@ export function createScreen(ctx) {
|
|
|
942
1148
|
];
|
|
943
1149
|
for (const entry of section) {
|
|
944
1150
|
const top = frame.length;
|
|
945
|
-
const shownLines = entry.legal ?
|
|
946
|
-
for (const line of shownLines) add(line,
|
|
1151
|
+
const shownLines = entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines;
|
|
1152
|
+
for (const line of shownLines) add(line, 'text');
|
|
947
1153
|
hits.push({ kind: 'entity', id: entry.id, row: top, col: 0, width, height: shownLines.length });
|
|
948
1154
|
}
|
|
949
1155
|
if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · PgUp/PgDn 翻页`, 'muted');
|
|
@@ -952,7 +1158,9 @@ export function createScreen(ctx) {
|
|
|
952
1158
|
const capacity = own?.energyCapacity ?? usable;
|
|
953
1159
|
const orbs = '●'.repeat(Math.max(0, usable)) + '○'.repeat(Math.max(0, capacity - usable));
|
|
954
1160
|
const button = (own?.hp ?? 0) <= 0 ? '[已倒下]' : own?.done ? '[已结束回合]' : (!canAct() || state.paused) ? '[等待队友]' : '[E 结束回合]';
|
|
955
|
-
|
|
1161
|
+
// §2: the Regent's stars are a resource of the same row as the energy.
|
|
1162
|
+
const stars = own?.character === 'regent' || (own?.stars ?? 0) > 0 ? `星星 ${own?.stars ?? 0}` : '';
|
|
1163
|
+
const energy = `能量 ${orbs ? `${orbs} ` : ''}${usable}/${capacity}${stars ? ` ${stars}` : ''}`;
|
|
956
1164
|
const buttonAt = Math.max(8, width - displayWidth(button) - 1);
|
|
957
1165
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
958
1166
|
add(pad(energy, buttonAt) + button, 'title');
|
|
@@ -965,16 +1173,6 @@ export function createScreen(ctx) {
|
|
|
965
1173
|
const flag = !here ? '离线' : state.paused || state.phase === 'lobby' ? (player.ready ? '已准备' : '') : player.done ? '已完成' : '';
|
|
966
1174
|
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');
|
|
967
1175
|
}
|
|
968
|
-
if (state.phase === 'shop') {
|
|
969
|
-
// §E: categories, then the goods list, then the detail pane.
|
|
970
|
-
const tabs = shopCategories();
|
|
971
|
-
if (!tabs.some(([kind]) => kind === shopTab)) shopTab = tabs[0]?.[0] ?? 'card';
|
|
972
|
-
const labels = tabs.map(([kind, name]) => kind === shopTab ? `[${name}]` : name);
|
|
973
|
-
const head = frame.length;
|
|
974
|
-
add(labels.join(' '), 'muted');
|
|
975
|
-
hits.push({ kind: 'shoptab', id: 'shoptab', row: head, col: 0, width, height: 1,
|
|
976
|
-
spans: tabSpans(labels).map((span, index) => ({ ...span, category: tabs[index][0] })) });
|
|
977
|
-
}
|
|
978
1176
|
if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
|
|
979
1177
|
if (state.phase === 'map' && Array.isArray(state.mapView?.nodes) && state.mapView.nodes.length) {
|
|
980
1178
|
// §D: the real act map, drawn from the backend's nodes and edges only.
|
|
@@ -982,17 +1180,17 @@ export function createScreen(ctx) {
|
|
|
982
1180
|
const headRow = frame.length;
|
|
983
1181
|
add(head.text, 'muted');
|
|
984
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 });
|
|
985
|
-
const drawn = mapLines(state.mapView, width
|
|
1183
|
+
const drawn = mapLines(state.mapView, width);
|
|
986
1184
|
for (const line of drawn.lines) {
|
|
987
1185
|
const top = frame.length;
|
|
988
1186
|
add(line.text, line.tone);
|
|
989
|
-
for (const cell of line.cells) hits.push({ kind: 'node', id: cell.
|
|
1187
|
+
for (const cell of line.cells) hits.push({ kind: 'node', id: cell.id, row: top, col: cell.col, width: cell.width, height: 1 });
|
|
990
1188
|
}
|
|
991
|
-
if (drawn.total > drawn.
|
|
1189
|
+
if (drawn.total > drawn.span) add(`本幕 ${drawn.total} 层 · 显示第 ${drawn.from + 1}–${Math.min(drawn.total, drawn.from + drawn.span)} 层 · 滚轮查看`, 'muted');
|
|
992
1190
|
// A cramped frame spends its rows on the map itself: the legend and
|
|
993
|
-
// the hint are the first things to go, never the
|
|
1191
|
+
// the hint are the first things to go, never the rooms.
|
|
994
1192
|
add(mapDetailText(state.mapView, mapData(state.mapView)), 'muted');
|
|
995
|
-
if (rows >=
|
|
1193
|
+
if (rows >= 24) add(`图例:数字 可前往 · ● 当前位置 · ✓ 已走过 · 黄色数字 已投票人数 · 点击可前往的房间即投票${drawn.flight ? ' · 洋红连线 额外可达(下一层任意房间)' : ''}`, 'muted');
|
|
996
1194
|
}
|
|
997
1195
|
}
|
|
998
1196
|
const labels = tabs.map((key, i) => {
|
|
@@ -1014,6 +1212,7 @@ export function createScreen(ctx) {
|
|
|
1014
1212
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1015
1213
|
if (loading) add('正在加载…', 'muted');
|
|
1016
1214
|
const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - 8)) : 0;
|
|
1215
|
+
if (state) add(''); // breathing room above the list
|
|
1017
1216
|
anchor(); const list = items(), available = Math.max(1, rows - frame.length - 3 - (logLines ? logLines + 1 : 0));
|
|
1018
1217
|
pageSize = available;
|
|
1019
1218
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
@@ -1026,6 +1225,17 @@ export function createScreen(ctx) {
|
|
|
1026
1225
|
? shopPane(width >= 100 ? Math.max(24, Math.floor(width * 0.42)) : width, list[index], width < 100) : undefined;
|
|
1027
1226
|
const paneAt = pane && width >= 100 ? Math.max(20, width - pane.width) : undefined;
|
|
1028
1227
|
for (let i = start; i < Math.min(list.length, start + available); i++) {
|
|
1228
|
+
if (list[i].separator) {
|
|
1229
|
+
// A heading keeps its place in the row count, so the detail pane's own
|
|
1230
|
+
// lines still line up with the rows beside them.
|
|
1231
|
+
mark({ kind: 'item', id: list[i].id });
|
|
1232
|
+
const heading = ` ${list[i].label}`;
|
|
1233
|
+
if (!paneAt) { add(heading, 'muted'); continue; }
|
|
1234
|
+
const slot = i - start;
|
|
1235
|
+
add(pad(cut(heading, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), 'muted');
|
|
1236
|
+
for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1029
1239
|
const badge = i - start + 1 === 10 ? '0' : String(i - start + 1);
|
|
1030
1240
|
const rowText = `${i === index ? '›' : ' '} [${badge}] ${list[i].label}`;
|
|
1031
1241
|
if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index ? 'selected' : 'text'); continue; }
|
|
@@ -1076,14 +1286,8 @@ export function createScreen(ctx) {
|
|
|
1076
1286
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1077
1287
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1078
1288
|
}
|
|
1079
|
-
if (!modal && state?.phase === 'shop' && (value === '[' || value === ']')) {
|
|
1080
|
-
const tabs = shopCategories();
|
|
1081
|
-
const at = Math.max(0, tabs.findIndex(([kind]) => kind === shopTab));
|
|
1082
|
-
shopTab = tabs[(at + (value === ']' ? 1 : tabs.length - 1)) % Math.max(1, tabs.length)]?.[0] ?? shopTab;
|
|
1083
|
-
index = 0; selected = undefined; changed(); return;
|
|
1084
|
-
}
|
|
1085
1289
|
if (!modal && state?.phase === 'map' && Array.isArray(state.mapView?.nodes)) {
|
|
1086
|
-
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; changed(); return; }
|
|
1290
|
+
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1087
1291
|
if (value === 'f') { mapFull = !mapFull; changed(); return; }
|
|
1088
1292
|
}
|
|
1089
1293
|
if (value === ' ' && !modal && !targeting) {
|
|
@@ -1167,16 +1371,12 @@ export function createScreen(ctx) {
|
|
|
1167
1371
|
if (event.button === 'left') {
|
|
1168
1372
|
const hit = hitAt(event.x, event.y);
|
|
1169
1373
|
if (hit?.kind === 'node') {
|
|
1170
|
-
// §D: a
|
|
1171
|
-
//
|
|
1172
|
-
mapFocus = hit.id;
|
|
1173
|
-
const
|
|
1174
|
-
if (
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
if (hit?.kind === 'shoptab') {
|
|
1178
|
-
const span = hit.spans?.find(range => event.x >= range.from && event.x < range.to);
|
|
1179
|
-
if (span) { shopTab = span.category; index = 0; selected = undefined; changed(); }
|
|
1374
|
+
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1375
|
+
// room is only looked at, and parks the view there.
|
|
1376
|
+
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
1377
|
+
const row = items().find(row => row.action?.type === 'vote' && row.id === hit.id);
|
|
1378
|
+
if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
|
|
1379
|
+
changed();
|
|
1180
1380
|
return;
|
|
1181
1381
|
}
|
|
1182
1382
|
if (hit?.kind === 'shopBuy') {
|
|
@@ -1188,7 +1388,7 @@ export function createScreen(ctx) {
|
|
|
1188
1388
|
return;
|
|
1189
1389
|
}
|
|
1190
1390
|
if (hit?.kind === 'managePotions') { switchTab(tabs.indexOf('potions')); return; }
|
|
1191
|
-
if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapFull = false; changed(); return; }
|
|
1391
|
+
if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1192
1392
|
if (hit?.kind === 'mapFull') { mapFull = !mapFull; mapScroll = undefined; changed(); return; }
|
|
1193
1393
|
if (hit?.kind === 'endTurn') { endTurn(); return; }
|
|
1194
1394
|
if (hit?.kind === 'entity') {
|
|
@@ -1203,8 +1403,8 @@ export function createScreen(ctx) {
|
|
|
1203
1403
|
if (modal?.detail) { pageDetail(step * 3); return; }
|
|
1204
1404
|
// §D: on the map the wheel looks around the act instead of moving the cursor.
|
|
1205
1405
|
if (state?.phase === 'map' && Array.isArray(state.mapView?.nodes)) {
|
|
1206
|
-
const drawn = mapLines(state.mapView, columns
|
|
1207
|
-
mapScroll = Math.max(0, Math.min(drawn.total - drawn.
|
|
1406
|
+
const drawn = mapLines(state.mapView, columns);
|
|
1407
|
+
mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from) + step));
|
|
1208
1408
|
mapFull = false; changed(); return;
|
|
1209
1409
|
}
|
|
1210
1410
|
moveTo(index + step);
|
|
@@ -1227,3 +1427,7 @@ export async function run(context) {
|
|
|
1227
1427
|
try { const page = context.openScreen(game.screen); void game.start(); await page; }
|
|
1228
1428
|
finally { game.stop(); }
|
|
1229
1429
|
}
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
// Exported so a test can read a drawn act back the way a player does.
|
|
1433
|
+
export { drawMap };
|