linkchat-extension-spire 0.1.0 → 0.1.8
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 +327 -177
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.1.
|
|
1
|
+
# Spire for LinkChat — 0.1.8
|
|
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.8.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
|
|
6
6
|
|
|
7
7
|
## 操作
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**地图**:普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
|
|
14
14
|
|
|
15
15
|
**商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
|
|
16
16
|
|
|
@@ -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,195 @@ export const internalIntentKeys = {
|
|
|
81
81
|
sleeping: '引擎以 asleep 力量表示沉睡,本字段是数据标记',
|
|
82
82
|
};
|
|
83
83
|
const signed = value => `${value > 0 ? '+' : ''}${value}`;
|
|
84
|
+
// --- act map (§D) -----------------------------------------------------------
|
|
85
|
+
function mapData(view) {
|
|
86
|
+
const nodes = (view?.nodes ?? []).filter(node => node && typeof node.id === 'string');
|
|
87
|
+
const byId = new Map(nodes.map(node => [node.id, node]));
|
|
88
|
+
const rows = new Map();
|
|
89
|
+
for (const node of nodes) {
|
|
90
|
+
if (!rows.has(node.row)) rows.set(node.row, []);
|
|
91
|
+
rows.get(node.row).push(node);
|
|
92
|
+
}
|
|
93
|
+
for (const list of rows.values()) list.sort((left, right) => left.col - right.col);
|
|
94
|
+
return { nodes, byId, rows, keys: [...rows.keys()].sort((left, right) => right - left),
|
|
95
|
+
current: byId.get(view.current), reachable: new Set(view.reachable ?? []), visited: new Set(view.path ?? []) };
|
|
96
|
+
}
|
|
97
|
+
// Places cells at display columns, so the frame stays aligned whatever the
|
|
98
|
+
// terminal's character widths are.
|
|
99
|
+
function place(cells, width) {
|
|
100
|
+
let out = '', at = 0;
|
|
101
|
+
const placed = [];
|
|
102
|
+
for (const cell of [...cells].sort((left, right) => left.at - right.at)) {
|
|
103
|
+
if (cell.at < at) continue;
|
|
104
|
+
out += ' '.repeat(cell.at - at) + cell.text;
|
|
105
|
+
placed.push({ ...cell, from: cell.at, width: displayWidth(cell.text) });
|
|
106
|
+
at = cell.at + displayWidth(cell.text);
|
|
107
|
+
}
|
|
108
|
+
return { text: cut(out, width), placed };
|
|
109
|
+
}
|
|
110
|
+
function mapRouteIndex(id, options) {
|
|
111
|
+
return (options ?? []).findIndex(row => row.action?.type === 'vote' && row.action.node === id);
|
|
112
|
+
}
|
|
113
|
+
// One glyph per set of directions, so a corner always joins the lines that meet.
|
|
114
|
+
const JOIN = {
|
|
115
|
+
u: '│', d: '│', l: '─', r: '─', ud: '│', ul: '┘', ur: '└', dl: '┐', dr: '┌', lr: '─',
|
|
116
|
+
udl: '┤', udr: '├', ulr: '┴', dlr: '┬', udlr: '┼',
|
|
117
|
+
};
|
|
118
|
+
const joinGlyph = directions => {
|
|
119
|
+
const set = new Set(directions);
|
|
120
|
+
const key = `${set.has('u') ? 'u' : ''}${set.has('d') ? 'd' : ''}${set.has('l') ? 'l' : ''}${set.has('r') ? 'r' : ''}`;
|
|
121
|
+
return JOIN[key] ?? '─';
|
|
122
|
+
};
|
|
123
|
+
const mapCode = node => `${String(node.row).padStart(2, '0')}-${String.fromCharCode(65 + node.col)}`;
|
|
124
|
+
function mapLabel(view, data, node, options) {
|
|
125
|
+
const route = mapRouteIndex(node.id, options);
|
|
126
|
+
return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
127
|
+
}
|
|
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';
|
|
131
|
+
return `${colour}${label}\x1b[0m`;
|
|
132
|
+
}
|
|
133
|
+
function mapVotes(node, players) {
|
|
134
|
+
const seats = (players ?? []).filter(player => player.vote === node.id).length;
|
|
135
|
+
return seats ? { text: `\x1b[33m${seats}\x1b[0m`, width: 1 } : { text: '', width: 0 };
|
|
136
|
+
}
|
|
137
|
+
function drawMap(view, width, session = {}) {
|
|
138
|
+
const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
|
|
139
|
+
const focus = session.focus ?? view.current;
|
|
140
|
+
const labels = new Map(data.nodes.map(n => [n.id, mapLabel(view, data, n, session.options)]));
|
|
141
|
+
const slot = Math.max(12, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 2));
|
|
142
|
+
const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
|
|
143
|
+
const unsafeGap = layers.some((layer,i)=>{
|
|
144
|
+
const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
|
|
145
|
+
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
146
|
+
return edges.some(([a,b])=>b.row!==layers[i+1] || (Math.abs(a.col-b.col)>1 && rooms.length>1 && next.length>1)) ||
|
|
147
|
+
edges.some(([a,b])=>edges.some(([c,d])=>(a.col-c.col)*(b.col-d.col)<0));
|
|
148
|
+
});
|
|
149
|
+
const compact = session.full || cols * slot > width || (session.height??24)<7 || unsafeGap;
|
|
150
|
+
const blocks = [];
|
|
151
|
+
const cell = (n, col, text) => ({id:n.id, row:0, col, width:displayWidth(text), height:1});
|
|
152
|
+
const plainRow = text => ({text, cells:[], tone:'muted'});
|
|
153
|
+
for (let i=0; i<layers.length; i++) {
|
|
154
|
+
const rooms = data.rows.get(layers[i]), block = [];
|
|
155
|
+
if (compact) {
|
|
156
|
+
for (const n of rooms) {
|
|
157
|
+
const label = `${mapCode(n)} ${labels.get(n.id)}`;
|
|
158
|
+
const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
|
|
159
|
+
const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
|
|
160
|
+
let suffix='';
|
|
161
|
+
const emit=()=>block.push({text:mapPaint(view,data,n,label,focus)+mapVotes(n,session.players).text+suffix,cells:[cell(n,0,label)],tone:'text'});
|
|
162
|
+
for(const target of targets){
|
|
163
|
+
const next=`${suffix?' ·':' →'} ${target}`;
|
|
164
|
+
if(suffix && displayWidth(label+suffix+next)+mapVotes(n,session.players).width>width){emit();suffix='';}
|
|
165
|
+
suffix+=`${suffix?' ·':' →'} ${target}`;
|
|
166
|
+
}
|
|
167
|
+
emit();
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
for (const codes of [true,false]) {
|
|
171
|
+
const pieces = rooms.map(n => {
|
|
172
|
+
const text = codes ? mapCode(n) : labels.get(n.id);
|
|
173
|
+
return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
|
|
174
|
+
});
|
|
175
|
+
const placed = place(pieces,width);
|
|
176
|
+
block.push({text:placed.text,tone:'text',cells:placed.placed.map(e=>cell(e.node,e.from,e.plain))});
|
|
177
|
+
}
|
|
178
|
+
if (i+1<layers.length) {
|
|
179
|
+
const ink = Array.from({length:3},()=>new Map());
|
|
180
|
+
const put=(y,x,dirs,owner)=>{
|
|
181
|
+
let c=ink[y].get(x); if(!c){c={dirs:new Set(),owners:new Set()};ink[y].set(x,c);}
|
|
182
|
+
for(const d of dirs)c.dirs.add(d);c.owners.add(owner);
|
|
183
|
+
};
|
|
184
|
+
const horizontal=(y,a,b,owner)=>{
|
|
185
|
+
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);
|
|
186
|
+
};
|
|
187
|
+
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
188
|
+
// Original noncrossing edges use separate midpoint tracks. Branches
|
|
189
|
+
// share the source row; merges share only the destination row.
|
|
190
|
+
for(const [n,kid] of edges){
|
|
191
|
+
const a=n.col*slot+2,b=kid.col*slot+2,m=Math.round((a+b)/2);
|
|
192
|
+
horizontal(0,a,m,n.id);put(0,a,'u',n.id);put(0,m,'d',n.id);
|
|
193
|
+
put(1,m,'ud',n.id);
|
|
194
|
+
horizontal(2,m,b,n.id);put(2,m,'u',n.id);put(2,b,'d',n.id);
|
|
195
|
+
}
|
|
196
|
+
for(const row of ink){
|
|
197
|
+
const pieces=[...row].map(([at,c])=>({at,text:`${c.owners.has(focus)?'\x1b[1;93m':'\x1b[90m'}${joinGlyph(c.dirs)}\x1b[0m`}));
|
|
198
|
+
block.push(plainRow(place(pieces,width).text));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
blocks.push(block);
|
|
203
|
+
}
|
|
204
|
+
const budget=Math.max(3,session.height??24);
|
|
205
|
+
const anchor=data.byId.get(session.anchor)??data.current;
|
|
206
|
+
const home=Math.max(0,layers.indexOf(anchor?.row));
|
|
207
|
+
let from=Math.max(0,Math.min(blocks.length-1,session.scroll??Math.max(0,home-1)));
|
|
208
|
+
let lines=[],span=0;
|
|
209
|
+
// Scroll offsets count from the bottom (the beginning of the act).
|
|
210
|
+
// Reverse layers, but keep same-layer rooms and wrapped targets in reading order.
|
|
211
|
+
if(compact){
|
|
212
|
+
const all=[...blocks].reverse().flat();
|
|
213
|
+
const focusId=session.anchor??anchor?.id;
|
|
214
|
+
const focusEnd=all.findLastIndex(line=>line.cells.some(c=>c.id===focusId));
|
|
215
|
+
const offset=Math.max(0,Math.min(Math.max(0,all.length-budget),session.scroll??(focusEnd>=0?all.length-focusEnd-1:0)));
|
|
216
|
+
const start=Math.max(0,all.length-budget-offset);
|
|
217
|
+
lines=all.slice(start,start+budget);
|
|
218
|
+
return {lines,from:offset,span:lines.length,total:all.length,height:lines.length,compact:true};
|
|
219
|
+
}
|
|
220
|
+
// Show the focused layer even if the preceding layer uses the whole budget.
|
|
221
|
+
if(session.scroll===undefined && from<home && blocks[from].length+2>budget)from=home;
|
|
222
|
+
for(let i=from;i<blocks.length;i++){
|
|
223
|
+
const last=i===blocks.length-1;
|
|
224
|
+
if(lines.length+blocks[i].length>budget){
|
|
225
|
+
if(lines.length+2<=budget){lines.push(...blocks[i].slice(0,2));span++;}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
lines.push(...blocks[i]);span++;
|
|
229
|
+
if(last)break;
|
|
230
|
+
}
|
|
231
|
+
// Remove dangling connectors at the viewport boundary.
|
|
232
|
+
while(lines.length && !lines.at(-1).cells.length)lines.pop();
|
|
233
|
+
// Reflect the geometry vertically, including each connector's directions.
|
|
234
|
+
// Labels remain code-above-type; neither text nor ANSI escapes are mirrored.
|
|
235
|
+
const mirror={'┌':'└','└':'┌','┐':'┘','┘':'┐','┬':'┴','┴':'┬'};
|
|
236
|
+
lines.reverse();
|
|
237
|
+
for(let i=0;i<lines.length;i++){
|
|
238
|
+
if(lines[i].cells.length){
|
|
239
|
+
[lines[i],lines[i+1]]=[lines[i+1],lines[i]];
|
|
240
|
+
i++;
|
|
241
|
+
}else lines[i]={...lines[i],text:lines[i].text.replace(/[┌└┐┘┬┴]/g,ch=>mirror[ch])};
|
|
242
|
+
}
|
|
243
|
+
return {lines,from,span,total:layers.length,height:lines.length,compact:false};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The package ships without dependencies, so the tab strip is measured here.
|
|
247
|
+
// Its labels are tabNames (CJK), pile counts in fullwidth parens, brackets.
|
|
248
|
+
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)); };
|
|
249
|
+
// A coloured digit is zero columns wide: measuring and cutting step over the
|
|
250
|
+
// escape sequences instead of counting their characters.
|
|
251
|
+
const ESCAPE_END = /[a-zA-Z]/;
|
|
252
|
+
const displayWidth = text => {
|
|
253
|
+
let width = 0, escape = false;
|
|
254
|
+
for (const character of String(text)) {
|
|
255
|
+
if (escape) { if (ESCAPE_END.test(character)) escape = false; continue; }
|
|
256
|
+
if (character === '\x1b') { escape = true; continue; }
|
|
257
|
+
width += wideChar(character) ? 2 : 1;
|
|
258
|
+
}
|
|
259
|
+
return width;
|
|
260
|
+
};
|
|
261
|
+
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
262
|
+
function cut(text, width) {
|
|
263
|
+
let out = '', used = 0, escape = false, trimmed = false;
|
|
264
|
+
for (const character of text) {
|
|
265
|
+
if (escape) { out += character; if (ESCAPE_END.test(character)) escape = false; continue; }
|
|
266
|
+
if (character === '\x1b') { out += character; escape = true; continue; }
|
|
267
|
+
const size = wideChar(character) ? 2 : 1;
|
|
268
|
+
if (used + size > width) { trimmed = true; break; }
|
|
269
|
+
out += character; used += size;
|
|
270
|
+
}
|
|
271
|
+
return trimmed && out.includes('\x1b') ? `${out}\x1b[0m` : out;
|
|
272
|
+
}
|
|
84
273
|
export function createScreen(ctx) {
|
|
85
274
|
let state, roomId, leaseId, connection, online = false, synced = false, rooms = [], loading = true, busy = false, closed = false;
|
|
86
275
|
let tab = 0, selected = 'create', index = 0, modal, note = '', pending, columns = 80, height = 24, serverProtocol = 1, unsupported;
|
|
@@ -90,16 +279,17 @@ export function createScreen(ctx) {
|
|
|
90
279
|
let enemyPage = 0; // the enemy block page the battlefield is showing
|
|
91
280
|
let listPage = 0; // the 1-9/0 page of the item list
|
|
92
281
|
let pageSize = 10; // how many items a page holds at the current size
|
|
93
|
-
let
|
|
282
|
+
let mapBudget = 10;
|
|
283
|
+
let mapScroll; // the map row the view starts at (undefined follows the anchor)
|
|
284
|
+
let mapAnchor; // the room the viewport is parked on (a click or 回到当前位置)
|
|
94
285
|
let mapFocus; // the node being looked at on the map, never a submitted route
|
|
95
|
-
let mapFull = false; //
|
|
286
|
+
let mapFull = false; // compact adjacency overview, with room types retained
|
|
96
287
|
let chain; // §G: the claim that follows this player's own treasure pick
|
|
97
288
|
let rewardLog = new Map(); // §F reward id -> claimed / skipped; display only, never a receipt
|
|
98
289
|
let rewardOpen; // §F the card reward whose candidates are expanded
|
|
99
290
|
let rewardPick = new Map(); // §F the candidate chosen in each card reward
|
|
100
291
|
let skipQueue = []; // §F the server skip rows still to run, in reward order
|
|
101
292
|
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
293
|
const picked = new Set(); let choiceReturn;
|
|
104
294
|
const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
|
|
105
295
|
const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
|
|
@@ -152,24 +342,15 @@ export function createScreen(ctx) {
|
|
|
152
342
|
const open = rewardOpen === group.key;
|
|
153
343
|
rows.push({ id: group.key, label: `${open ? '▾' : '▸'} ${group.title} ${status}`, group: group.key, open });
|
|
154
344
|
if (!open) continue;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
345
|
+
// Picking the card is the claim: no separate 领取 row, and the way past a
|
|
346
|
+
// card reward is 跳过剩余奖励并离开 at the end of the list.
|
|
347
|
+
for (const row of group.candidates) rows.push({ ...row, label: ` ${row.label.replace(/^奖励 \d+:/, '')}`, candidate: true, group: group.key });
|
|
158
348
|
for (const row of group.others) rows.push({ ...row, label: ` ${row.label}`, group: group.key });
|
|
159
|
-
rows.push({ id: `back:${group.key}`, label: ' [返回奖励清单]' });
|
|
160
349
|
}
|
|
161
350
|
rows.push(rows.length ? { id: 'skipAll', label: '跳过剩余奖励并离开' }
|
|
162
351
|
: { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
|
|
163
352
|
return rows;
|
|
164
353
|
}
|
|
165
|
-
if (state.phase === 'map' && !state.paused && Array.isArray(state.mapView?.nodes)) {
|
|
166
|
-
// §D: the map only highlights; going there is this explicit row (or Enter
|
|
167
|
-
// on the route itself).
|
|
168
|
-
const rows = [...state.options];
|
|
169
|
-
const route = rows.find(row => row.action?.type === 'vote' && (row.id === selected || row.id === mapFocus));
|
|
170
|
-
rows.push({ id: 'mapConfirm', label: route ? `确认前往:${route.label}` : '确认前往(先选择一条路线)', action: route?.action });
|
|
171
|
-
return rows;
|
|
172
|
-
}
|
|
173
354
|
return state.options;
|
|
174
355
|
}
|
|
175
356
|
function anchor() {
|
|
@@ -194,6 +375,9 @@ export function createScreen(ctx) {
|
|
|
194
375
|
choiceReturn=undefined;
|
|
195
376
|
}
|
|
196
377
|
}
|
|
378
|
+
if (state?.mapView?.current !== s.mapView?.current || state?.act?.index !== s.act?.index) {
|
|
379
|
+
mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
|
|
380
|
+
}
|
|
197
381
|
if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
|
|
198
382
|
modal = undefined; selected = undefined; index = 0; tab = 0;
|
|
199
383
|
rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
|
|
@@ -349,10 +533,6 @@ export function createScreen(ctx) {
|
|
|
349
533
|
const at = items().findIndex(row => row.id === id);
|
|
350
534
|
if (at >= 0) moveTo(at);
|
|
351
535
|
}
|
|
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
536
|
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
537
|
// --- battlefield ------------------------------------------------------------
|
|
358
538
|
const BAR_CELLS = 10;
|
|
@@ -376,16 +556,6 @@ export function createScreen(ctx) {
|
|
|
376
556
|
}
|
|
377
557
|
return [buffs, debuffs];
|
|
378
558
|
}
|
|
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
559
|
function powerEntries(creature) {
|
|
390
560
|
const monologues = creature.monologueInstances ?? [];
|
|
391
561
|
return Object.entries(creature.powers ?? {}).filter(([, value]) => value !== 0).flatMap(([key, value]) =>
|
|
@@ -460,85 +630,9 @@ export function createScreen(ctx) {
|
|
|
460
630
|
return [`${player.id === ctx.user.id ? '› ' : ''}${player.name}${player.characterName ? ` · ${player.characterName}` : ''}${player.done ? ' · 已结束' : ''}`,
|
|
461
631
|
...statLines(player, resources ? [resources] : [], width)];
|
|
462
632
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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();
|
|
541
|
-
const buttons = ['[回到当前位置 C]', '[查看全图 F]'];
|
|
633
|
+
function mapHeader(width, view) {
|
|
634
|
+
const left = `路线选择 · ${state?.act?.name ?? view.act?.name ?? ''}`.trim();
|
|
635
|
+
const buttons = width<70 ? ['[当前位置 C]', '[总览 F]'] : ['[回到当前位置 C]', '[紧凑总览 F]'];
|
|
542
636
|
const right = buttons.join(' ');
|
|
543
637
|
const head = cut(left, Math.max(8, width - displayWidth(right) - 1));
|
|
544
638
|
const out = pad(head, Math.max(8, width - displayWidth(right))) + right;
|
|
@@ -550,9 +644,10 @@ export function createScreen(ctx) {
|
|
|
550
644
|
const node = data.byId.get(mapFocus ?? view.current);
|
|
551
645
|
if (!node) return '悬停或点击节点查看房间类型与真实后继。';
|
|
552
646
|
const successors = (node.children ?? []).map(id => data.byId.get(id)).filter(Boolean)
|
|
553
|
-
.map(next =>
|
|
647
|
+
.map(next => `${mapCode(next)}[${next.type}]`).join(' ') || '无';
|
|
554
648
|
const standing = node.id === view.current ? '当前位置' : data.reachable.has(node.id) ? '可前往' : data.visited.has(node.id) ? '已走过' : '暂不可达 · 仅查看';
|
|
555
|
-
|
|
649
|
+
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(' ') : '';
|
|
650
|
+
return `选中:${node.type} · ${standing} · ${mapCode(node)} · 后继:${successors}${extra ? ` · 飞靴额外可达:${extra}` : ''}`;
|
|
556
651
|
}
|
|
557
652
|
// --- shop (§E) ---------------------------------------------------------------
|
|
558
653
|
const SHOP_TABS = [['card', '卡牌'], ['relic', '遗物'], ['potion', '药水'], ['removal', '删牌服务']];
|
|
@@ -564,11 +659,19 @@ export function createScreen(ctx) {
|
|
|
564
659
|
function shopRows() {
|
|
565
660
|
const rows = state.options ?? [];
|
|
566
661
|
const leave = rows.filter(row => row.action?.type === 'continue');
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
662
|
+
const sections = [];
|
|
663
|
+
for (const [kind, name] of shopCategories()) {
|
|
664
|
+
const goods = rows.filter(row => (row.action?.type === 'buy' && (row.shop?.kind ?? 'card') === kind)
|
|
665
|
+
|| (kind === 'removal' && row.action?.type === 'removeCard'));
|
|
666
|
+
if (!goods.length) continue;
|
|
667
|
+
sections.push({ id: `section:${kind}`, label: `--${name}--`, separator: true });
|
|
668
|
+
sections.push(...goods);
|
|
669
|
+
}
|
|
670
|
+
if (!sections.length) return rows;
|
|
671
|
+
const listed = new Set(sections.map(row => row.id));
|
|
672
|
+
// Anything the shop cannot place — a nested reward, a forced choice — keeps
|
|
673
|
+
// the generic list instead of disappearing.
|
|
674
|
+
return [...sections, ...rows.filter(row => !listed.has(row.id) && !leave.includes(row)), ...leave];
|
|
572
675
|
}
|
|
573
676
|
// The pane is the same block every time: what it is, what it does, what it
|
|
574
677
|
// costs, and the one button that pays for it.
|
|
@@ -681,6 +784,21 @@ export function createScreen(ctx) {
|
|
|
681
784
|
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
785
|
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
786
|
}
|
|
787
|
+
// §C: a legal target is marked by colour — enemies red, allies blue — and the
|
|
788
|
+
// one being aimed at is the brighter shade.
|
|
789
|
+
const TINTS = { enemy: 31, enemyHot: 91, ally: 34, allyHot: 94 };
|
|
790
|
+
const targetTint = (entry, arena) => `\x1b[${entry.id === targeting?.id ? TINTS[`${arena}Hot`] : TINTS[arena]}m`;
|
|
791
|
+
// Colour is invisible on a host that strips it, so the aimed block also says so
|
|
792
|
+
// in plain text, on its first line that has something to show.
|
|
793
|
+
const aimed = entry => entry.id === targeting?.id ? '▶' : '';
|
|
794
|
+
const tint = (lines, colour, mark) => {
|
|
795
|
+
let marked = false;
|
|
796
|
+
return lines.map(line => {
|
|
797
|
+
const lead = !marked && mark && line.trim() ? mark : '';
|
|
798
|
+
if (lead) marked = true;
|
|
799
|
+
return `${colour}${lead}${line}\x1b[0m`;
|
|
800
|
+
});
|
|
801
|
+
};
|
|
684
802
|
function framed(lines, width, style) {
|
|
685
803
|
const [left, right] = style === 'current' ? ['║', '║'] : ['│', '│'];
|
|
686
804
|
const [topLeft, topRight, bottomLeft, bottomRight, bar] = style === 'current' ? ['╔', '╗', '╚', '╝', '═'] : ['┌', '┐', '└', '┘', '─'];
|
|
@@ -763,7 +881,13 @@ export function createScreen(ctx) {
|
|
|
763
881
|
else void submit(action); return;
|
|
764
882
|
}
|
|
765
883
|
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
|
|
884
|
+
if (tab === 0 && row.candidate && row.action) {
|
|
885
|
+
// Picking a card reward's card is the deliberate claim.
|
|
886
|
+
const action = { ...row.action, phaseId: state.phaseId };
|
|
887
|
+
rewardLog.set(row.group, '已领取');
|
|
888
|
+
void submit(action);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
767
891
|
if (tab === 0 && row.group && row.id.startsWith('claim:')) {
|
|
768
892
|
// §F: the deliberate claim: a card reward claims the chosen candidate, any
|
|
769
893
|
// other reward claims by its own row.
|
|
@@ -829,7 +953,6 @@ export function createScreen(ctx) {
|
|
|
829
953
|
void submit(action);
|
|
830
954
|
}
|
|
831
955
|
else if (row.group) {
|
|
832
|
-
if (row.id.startsWith('back:')) { rewardOpen = undefined; changed(); return; }
|
|
833
956
|
if (row.skipped) rewardLog.set(row.group, '已跳过'); else if (row.action) rewardLog.set(row.group, '已领取');
|
|
834
957
|
if (row.action) void submit({ ...row.action, phaseId: state.phaseId });
|
|
835
958
|
else changed();
|
|
@@ -871,16 +994,40 @@ export function createScreen(ctx) {
|
|
|
871
994
|
else void submit(row.action);
|
|
872
995
|
}
|
|
873
996
|
}
|
|
997
|
+
// The drawing reads the view state and the party; this supplies both.
|
|
998
|
+
const mapLines = (view, width) => drawMap(view, width,
|
|
999
|
+
{ full: mapFull, scroll: mapScroll, anchor: mapAnchor, focus:mapFocus, height:mapBudget, players: state?.players, options: state?.options });
|
|
1000
|
+
const mapNavigation = () => !modal && tab===0 && state?.phase==='map' && !state.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
874
1001
|
const screen = {
|
|
875
1002
|
render(width, rows) {
|
|
876
1003
|
columns = width; height = rows; hits = [];
|
|
877
1004
|
const frame = [], add = (text, tone = 'text') => frame.push({ text, tone });
|
|
878
1005
|
const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
|
|
879
|
-
if (width < 40 || rows < 20) { add(
|
|
1006
|
+
if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
|
|
880
1007
|
const own = me();
|
|
881
1008
|
const heading = state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
882
1009
|
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
|
|
883
1010
|
add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
|
|
1011
|
+
if(mapNavigation()) {
|
|
1012
|
+
const view=state.mapView, head=mapHeader(width,view), headerRow=frame.length;
|
|
1013
|
+
add(head.text,'muted');
|
|
1014
|
+
for(const button of head.buttons) hits.push({kind:button.id,id:button.id,row:headerRow,col:button.at,width:button.width,height:1});
|
|
1015
|
+
mapBudget=Math.max(3,rows-frame.length-5);
|
|
1016
|
+
const drawn=mapLines(view,width);
|
|
1017
|
+
for(const line of drawn.lines){
|
|
1018
|
+
const row=frame.length;add(line.text,line.tone);
|
|
1019
|
+
for(const cell of line.cells)hits.push({kind:'node',id:cell.id,row,col:cell.col,width:cell.width,height:1});
|
|
1020
|
+
}
|
|
1021
|
+
while(frame.length<rows-5)add('');
|
|
1022
|
+
add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
|
|
1023
|
+
add(cut(mapDetailText(view,mapData(view)),width),'muted');
|
|
1024
|
+
add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
|
|
1025
|
+
add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
|
|
1026
|
+
mark({kind:'tab',advance:true});
|
|
1027
|
+
add(cut('[面板 Tab] · 悬停查看 · Esc 返回 · R 重试',width),'muted');
|
|
1028
|
+
hits=hits.filter(hit=>hit.row<rows);
|
|
1029
|
+
return frame.slice(0,rows);
|
|
1030
|
+
}
|
|
884
1031
|
if (unsupported) add(`此扩展版本不认识该阶段「${unsupported}」,请在更新扩展后继续(当前只读)。`, 'error');
|
|
885
1032
|
if (targeting) add(cut(`正在使用:${targeting.label} · 点击目标或 ←/→ 选择 · Enter 确认 · Esc 取消`, width), 'selected');
|
|
886
1033
|
if (state) {
|
|
@@ -914,7 +1061,7 @@ export function createScreen(ctx) {
|
|
|
914
1061
|
...party.map(player => ({ id: String(player?.id), lines: ['', ...allyBlock(player, allyWidth - 2)], legal: legalAlly(String(player?.id)) })),
|
|
915
1062
|
...shown.map(enemy => ({ id: enemy.id, lines: enemyBlock(enemy, foeWidth - 2), legal: legalEnemy(enemy) })),
|
|
916
1063
|
];
|
|
917
|
-
const body = blocks.map(
|
|
1064
|
+
const body = blocks.map(entry => entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines);
|
|
918
1065
|
const tall = Math.max(...body.map(lines => lines.length));
|
|
919
1066
|
const startRow = frame.length;
|
|
920
1067
|
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 +1089,8 @@ export function createScreen(ctx) {
|
|
|
942
1089
|
];
|
|
943
1090
|
for (const entry of section) {
|
|
944
1091
|
const top = frame.length;
|
|
945
|
-
const shownLines = entry.legal ?
|
|
946
|
-
for (const line of shownLines) add(line,
|
|
1092
|
+
const shownLines = entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines;
|
|
1093
|
+
for (const line of shownLines) add(line, 'text');
|
|
947
1094
|
hits.push({ kind: 'entity', id: entry.id, row: top, col: 0, width, height: shownLines.length });
|
|
948
1095
|
}
|
|
949
1096
|
if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · PgUp/PgDn 翻页`, 'muted');
|
|
@@ -952,7 +1099,9 @@ export function createScreen(ctx) {
|
|
|
952
1099
|
const capacity = own?.energyCapacity ?? usable;
|
|
953
1100
|
const orbs = '●'.repeat(Math.max(0, usable)) + '○'.repeat(Math.max(0, capacity - usable));
|
|
954
1101
|
const button = (own?.hp ?? 0) <= 0 ? '[已倒下]' : own?.done ? '[已结束回合]' : (!canAct() || state.paused) ? '[等待队友]' : '[E 结束回合]';
|
|
955
|
-
|
|
1102
|
+
// §2: the Regent's stars are a resource of the same row as the energy.
|
|
1103
|
+
const stars = own?.character === 'regent' || (own?.stars ?? 0) > 0 ? `星星 ${own?.stars ?? 0}` : '';
|
|
1104
|
+
const energy = `能量 ${orbs ? `${orbs} ` : ''}${usable}/${capacity}${stars ? ` ${stars}` : ''}`;
|
|
956
1105
|
const buttonAt = Math.max(8, width - displayWidth(button) - 1);
|
|
957
1106
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
958
1107
|
add(pad(energy, buttonAt) + button, 'title');
|
|
@@ -965,35 +1114,8 @@ export function createScreen(ctx) {
|
|
|
965
1114
|
const flag = !here ? '离线' : state.paused || state.phase === 'lobby' ? (player.ready ? '已准备' : '') : player.done ? '已完成' : '';
|
|
966
1115
|
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
1116
|
}
|
|
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
1117
|
if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
|
|
979
|
-
|
|
980
|
-
// §D: the real act map, drawn from the backend's nodes and edges only.
|
|
981
|
-
const head = mapHeader(width, state.mapView);
|
|
982
|
-
const headRow = frame.length;
|
|
983
|
-
add(head.text, 'muted');
|
|
984
|
-
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, Math.max(3, rows - frame.length - 11));
|
|
986
|
-
for (const line of drawn.lines) {
|
|
987
|
-
const top = frame.length;
|
|
988
|
-
add(line.text, line.tone);
|
|
989
|
-
for (const cell of line.cells) hits.push({ kind: 'node', id: cell.node.id, row: top, col: cell.from, width: cell.width, height: 1 });
|
|
990
|
-
}
|
|
991
|
-
if (drawn.total > drawn.body || mapFull) add(`本幕 ${drawn.total} 行 · 显示第 ${drawn.top + 1}–${Math.min(drawn.total, drawn.top + drawn.body)} 行 · 滚轮查看`, 'muted');
|
|
992
|
-
// A cramped frame spends its rows on the map itself: the legend and
|
|
993
|
-
// the hint are the first things to go, never the nodes.
|
|
994
|
-
add(mapDetailText(state.mapView, mapData(state.mapView)), 'muted');
|
|
995
|
-
if (rows >= 26 || drawn.body >= 4) add('图例:数字 可前往 · ● 当前位置 · ✓ 已走过 · 其他 暂不可达 · 点击节点仅查看', 'muted');
|
|
996
|
-
}
|
|
1118
|
+
|
|
997
1119
|
}
|
|
998
1120
|
const labels = tabs.map((key, i) => {
|
|
999
1121
|
const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
|
|
@@ -1014,6 +1136,7 @@ export function createScreen(ctx) {
|
|
|
1014
1136
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1015
1137
|
if (loading) add('正在加载…', 'muted');
|
|
1016
1138
|
const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - 8)) : 0;
|
|
1139
|
+
if (state) add(''); // breathing room above the list
|
|
1017
1140
|
anchor(); const list = items(), available = Math.max(1, rows - frame.length - 3 - (logLines ? logLines + 1 : 0));
|
|
1018
1141
|
pageSize = available;
|
|
1019
1142
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
@@ -1026,6 +1149,17 @@ export function createScreen(ctx) {
|
|
|
1026
1149
|
? shopPane(width >= 100 ? Math.max(24, Math.floor(width * 0.42)) : width, list[index], width < 100) : undefined;
|
|
1027
1150
|
const paneAt = pane && width >= 100 ? Math.max(20, width - pane.width) : undefined;
|
|
1028
1151
|
for (let i = start; i < Math.min(list.length, start + available); i++) {
|
|
1152
|
+
if (list[i].separator) {
|
|
1153
|
+
// A heading keeps its place in the row count, so the detail pane's own
|
|
1154
|
+
// lines still line up with the rows beside them.
|
|
1155
|
+
mark({ kind: 'item', id: list[i].id });
|
|
1156
|
+
const heading = ` ${list[i].label}`;
|
|
1157
|
+
if (!paneAt) { add(heading, 'muted'); continue; }
|
|
1158
|
+
const slot = i - start;
|
|
1159
|
+
add(pad(cut(heading, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), 'muted');
|
|
1160
|
+
for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1029
1163
|
const badge = i - start + 1 === 10 ? '0' : String(i - start + 1);
|
|
1030
1164
|
const rowText = `${i === index ? '›' : ' '} [${badge}] ${list[i].label}`;
|
|
1031
1165
|
if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index ? 'selected' : 'text'); continue; }
|
|
@@ -1076,15 +1210,31 @@ export function createScreen(ctx) {
|
|
|
1076
1210
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1077
1211
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1078
1212
|
}
|
|
1079
|
-
if (
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1213
|
+
if (mapNavigation()) {
|
|
1214
|
+
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1215
|
+
if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
|
|
1216
|
+
if(tab===0 && ['left','right','up','down'].includes(name)) {
|
|
1217
|
+
const data=mapData(state.mapView), here=data.byId.get(mapFocus)??data.current??data.nodes[0];
|
|
1218
|
+
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);
|
|
1219
|
+
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));
|
|
1220
|
+
if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}
|
|
1221
|
+
changed();return;
|
|
1222
|
+
}
|
|
1223
|
+
if(['pageup','pagedown','home','end'].includes(name)){
|
|
1224
|
+
const drawn=mapLines(state.mapView,columns),max=Math.max(0,drawn.total-drawn.span);
|
|
1225
|
+
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)));
|
|
1226
|
+
changed();return;
|
|
1227
|
+
}
|
|
1228
|
+
if(/^[0-9]$/.test(value??'')){
|
|
1229
|
+
const routes=state.options.filter(row=>row.action?.type==='vote'),at=value==='0'?9:Number(value)-1;
|
|
1230
|
+
if(routes[at]){mapFocus=routes[at].action.node;mapAnchor=mapFocus;mapScroll=undefined;changed();}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if(['return','enter'].includes(name)){
|
|
1234
|
+
const row=items().find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
|
|
1235
|
+
if(row)void submit({...row.action,phaseId:state.phaseId});
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1088
1238
|
}
|
|
1089
1239
|
if (value === ' ' && !modal && !targeting) {
|
|
1090
1240
|
const row = items()[index];
|
|
@@ -1101,14 +1251,14 @@ export function createScreen(ctx) {
|
|
|
1101
1251
|
// never play a card by itself. Enter, or a click, is what acts.
|
|
1102
1252
|
if (!modal && !targeting && /^[0-9]$/.test(value ?? '')) {
|
|
1103
1253
|
const list = items(), at = listPage * pageSize + (value === '0' ? 9 : Number(value) - 1);
|
|
1104
|
-
if (at < list.length) moveTo(at);
|
|
1254
|
+
if (at < list.length) { moveTo(at); if(state?.phase==='map' && tab===0){mapFocus=list[at].action?.node;mapAnchor=mapFocus;mapScroll=undefined;} }
|
|
1105
1255
|
return;
|
|
1106
1256
|
}
|
|
1107
1257
|
if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
|
|
1108
1258
|
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; modal = undefined; anchor(); changed(); }
|
|
1109
1259
|
else ctx.closeScreen(); return;
|
|
1110
1260
|
}
|
|
1111
|
-
if (columns <
|
|
1261
|
+
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1112
1262
|
if (value === '?' && !modal) {
|
|
1113
1263
|
const row = items()[index];
|
|
1114
1264
|
const lines = ctx.wrap(row?.label ?? '方向键选择,Enter 确认;全部玩家结束回合后敌人行动。退出或掉线会暂停整局。', Math.max(1, columns - 4));
|
|
@@ -1147,7 +1297,7 @@ export function createScreen(ctx) {
|
|
|
1147
1297
|
if (name === 'return' || name === 'enter') confirm();
|
|
1148
1298
|
},
|
|
1149
1299
|
mouse(event) {
|
|
1150
|
-
if (columns <
|
|
1300
|
+
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1151
1301
|
if (event.action === 'move') {
|
|
1152
1302
|
// Hover lights a row, or shows which target the pointer is on.
|
|
1153
1303
|
const pointed = hitAt(event.x, event.y);
|
|
@@ -1167,16 +1317,12 @@ export function createScreen(ctx) {
|
|
|
1167
1317
|
if (event.button === 'left') {
|
|
1168
1318
|
const hit = hitAt(event.x, event.y);
|
|
1169
1319
|
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(); }
|
|
1320
|
+
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1321
|
+
// room is only looked at, and parks the view there.
|
|
1322
|
+
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
1323
|
+
const row = items().find(row => row.action?.type === 'vote' && row.id === hit.id);
|
|
1324
|
+
if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
|
|
1325
|
+
changed();
|
|
1180
1326
|
return;
|
|
1181
1327
|
}
|
|
1182
1328
|
if (hit?.kind === 'shopBuy') {
|
|
@@ -1188,7 +1334,7 @@ export function createScreen(ctx) {
|
|
|
1188
1334
|
return;
|
|
1189
1335
|
}
|
|
1190
1336
|
if (hit?.kind === 'managePotions') { switchTab(tabs.indexOf('potions')); return; }
|
|
1191
|
-
if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapFull = false; changed(); return; }
|
|
1337
|
+
if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1192
1338
|
if (hit?.kind === 'mapFull') { mapFull = !mapFull; mapScroll = undefined; changed(); return; }
|
|
1193
1339
|
if (hit?.kind === 'endTurn') { endTurn(); return; }
|
|
1194
1340
|
if (hit?.kind === 'entity') {
|
|
@@ -1202,10 +1348,10 @@ export function createScreen(ctx) {
|
|
|
1202
1348
|
// An open detail pane pages first, exactly like PgUp/PgDn do.
|
|
1203
1349
|
if (modal?.detail) { pageDetail(step * 3); return; }
|
|
1204
1350
|
// §D: on the map the wheel looks around the act instead of moving the cursor.
|
|
1205
|
-
if (
|
|
1206
|
-
const drawn = mapLines(state.mapView, columns
|
|
1207
|
-
mapScroll = Math.max(0, Math.min(drawn.total - drawn.
|
|
1208
|
-
|
|
1351
|
+
if (mapNavigation()) {
|
|
1352
|
+
const drawn = mapLines(state.mapView, columns);
|
|
1353
|
+
mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from) - step));
|
|
1354
|
+
changed(); return;
|
|
1209
1355
|
}
|
|
1210
1356
|
moveTo(index + step);
|
|
1211
1357
|
return;
|
|
@@ -1227,3 +1373,7 @@ export async function run(context) {
|
|
|
1227
1373
|
try { const page = context.openScreen(game.screen); void game.start(); await page; }
|
|
1228
1374
|
finally { game.stop(); }
|
|
1229
1375
|
}
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
// Exported so a test can read a drawn act back the way a player does.
|
|
1379
|
+
export { drawMap };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkchat-extension-spire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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
|
}
|