linkchat-extension-spire 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/index.js +104 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.1.
|
|
1
|
+
# Spire for LinkChat — 0.1.9
|
|
2
2
|
|
|
3
3
|
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
|
|
4
4
|
|
|
5
|
-
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.
|
|
5
|
+
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.9.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
|
|
6
6
|
|
|
7
7
|
## 操作
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**战斗**:我方在左、敌人在右(默认 80×25 终端使用紧凑双栏,队友在左、敌人在右;有效宽度不足 68 列或高度不足 24 行时改为上下分区);每个信息块自上而下是意图、名称、格挡与血条、生命、增益、减益,多个意图字段会换行而不是被截断。手牌竖排并编号 1–9、0:数字键只把光标移到该位置,Enter 才是执行;每页最多十项,PgUp/PgDn 翻页,方向键跨页时页码同步。能量右侧是固定的 `[E 结束回合]` 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
|
|
10
10
|
|
|
11
11
|
**选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
|
|
14
14
|
|
|
15
15
|
**商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
**宝箱**:单人时点选遗物即为一次明确的领取,客户端会在服务端确认后接着领取同一件遗物(按 reward ID 精确匹配,结果不明时不会猜);多人保留投票、分配、领取三个阶段,投票按钮本身就是确认。
|
|
20
20
|
|
|
21
|
-
**其他**:Tab
|
|
21
|
+
**其他**:Tab 依次查看行动、牌组、遗物、药水、状态、日志,标签随窗口宽度换行;抽牌堆、消耗堆和弃牌堆仅在战斗中显示于能量栏下一行,依次位于左、中、右,点击或按 A / S / D 查看(药水面板的 D 仍为丢弃药水),Esc 返回;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
|
|
22
22
|
|
|
23
23
|
**鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部面板名切换面板;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
|
|
24
24
|
|
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
const BASE = '/api/games/spire/rooms';
|
|
3
3
|
const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
|
|
4
4
|
export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
|
|
5
|
-
const tabs = ['action', 'deck', '
|
|
5
|
+
const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
|
|
6
6
|
const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
|
|
7
7
|
const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
|
|
8
8
|
Object.assign(powerNames, {juggling:'杂耍',aggression:'侵略',barricade:'壁垒',corruption:'腐化',colossus:'巨像',crimsonMantle:'猩红披风',cruelty:'残酷',inferno:'炼狱',juggernaut:'势不可挡',pyre:'柴堆',plating:'覆甲',unmovable:'坚如磐石',freeAttack:'免费攻击',vicious:'凶狠',tank:'坦克',guarded:'被守护',covered:'被掩护',intercept:'拦截',oneTwoPunch:'连环拳',stampede:'奔踏',calamity:'灾祸',tagTeam:'接力',knockdown:'击倒',beaconOfHope:'希望灯塔',fasten:'紧固',prepTime:'准备时间',retainHand:'保留手牌',nostalgia:'怀旧',automation:'自动化',panache:'华丽',bomb:'炸弹',rollingBoulder:'滚石',theGambit:'赌局',coordinate:'协同',blockNextTurn:'下回合格挡',setupStrike:'临时力量',vigor:'活力',artifact:'人工制品',poison:'中毒',strangle:'绞杀',freeSkill:'免费技能',drawNextTurn:'下回合抽牌',focus:'集中',temporaryFocus:'临时集中',freePower:'免费能力',energyNextTurn:'下回合能量',temporaryStrengthDown:'临时力量降低',doom:'灾厄',hang:'绞刑',debilitate:'衰弱',sicEm:'咬住',veilpiercer:'虚无免费'});
|
|
@@ -136,10 +136,14 @@ function mapVotes(node, players) {
|
|
|
136
136
|
}
|
|
137
137
|
function drawMap(view, width, session = {}) {
|
|
138
138
|
const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
|
|
139
|
-
const focus = session.focus
|
|
139
|
+
const focus = data.byId.has(session.focus) ? session.focus : view.current;
|
|
140
|
+
const codeLabel = n => `${n.id===focus?'›':' '} ${mapCode(n)}`;
|
|
140
141
|
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
142
|
const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
|
|
143
|
+
// An 80-column terminal gives the extension 75 columns after host margins.
|
|
144
|
+
// Reserve only measured text plus a separator before falling back to a table.
|
|
145
|
+
const needed = Math.max(8, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 1));
|
|
146
|
+
const slot = Math.max(needed, width >= cols * 12 ? 12 : 6);
|
|
143
147
|
const unsafeGap = layers.some((layer,i)=>{
|
|
144
148
|
const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
|
|
145
149
|
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
@@ -154,7 +158,7 @@ function drawMap(view, width, session = {}) {
|
|
|
154
158
|
const rooms = data.rows.get(layers[i]), block = [];
|
|
155
159
|
if (compact) {
|
|
156
160
|
for (const n of rooms) {
|
|
157
|
-
const label = `${
|
|
161
|
+
const label = `${codeLabel(n)} ${labels.get(n.id)}`;
|
|
158
162
|
const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
|
|
159
163
|
const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
|
|
160
164
|
let suffix='';
|
|
@@ -169,7 +173,7 @@ function drawMap(view, width, session = {}) {
|
|
|
169
173
|
} else {
|
|
170
174
|
for (const codes of [true,false]) {
|
|
171
175
|
const pieces = rooms.map(n => {
|
|
172
|
-
const text = codes ?
|
|
176
|
+
const text = codes ? codeLabel(n) : labels.get(n.id);
|
|
173
177
|
return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
|
|
174
178
|
});
|
|
175
179
|
const placed = place(pieces,width);
|
|
@@ -294,7 +298,11 @@ export function createScreen(ctx) {
|
|
|
294
298
|
const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
|
|
295
299
|
const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
|
|
296
300
|
const me = () => state?.players.find(p => p.id === ctx.user.id);
|
|
297
|
-
|
|
301
|
+
let characterConfirmed=false, mapOpen, mapReturnTab=0;
|
|
302
|
+
const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
|
|
303
|
+
let pileView;
|
|
304
|
+
const activeTab = () => pileView ?? tabs[tab];
|
|
305
|
+
function openPile(key) { if(state?.phase!=='combat' || modal)return; pileView=key; index=0;selected=undefined;listPage=0;changed(); }
|
|
298
306
|
const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
|
|
299
307
|
// A phase this build cannot render stays read only, so nothing is submitted.
|
|
300
308
|
const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
|
|
@@ -303,6 +311,7 @@ export function createScreen(ctx) {
|
|
|
303
311
|
if (modal) return modal.items;
|
|
304
312
|
if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:'单人攀登 · 开始/继续'}, ...rooms.filter(r=>r.mode!=='solo').map(r => ({ id: r.id, room: r, label: `${r.players.some(p => p.id === ctx.user.id) ? '继续' : '加入'} · ${r.players.map(p => p.name).join(' / ')} · ${r.players.length}/4 · ${phases[r.phase] ?? r.phase}${r.paused ? ' · 已暂停' : ''}${r.compatible === false ? ' · 版本不兼容' : ''}` }))];
|
|
305
313
|
if (!state) return [];
|
|
314
|
+
if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
|
|
306
315
|
if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
|
|
307
316
|
if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
|
|
308
317
|
if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
|
|
@@ -351,6 +360,7 @@ export function createScreen(ctx) {
|
|
|
351
360
|
: { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
|
|
352
361
|
return rows;
|
|
353
362
|
}
|
|
363
|
+
if(state.phase==='map' && !state.paused && state.mapView?.nodes?.length)return [{id:'openMap',label:'打开地图 M'}];
|
|
354
364
|
return state.options;
|
|
355
365
|
}
|
|
356
366
|
function anchor() {
|
|
@@ -379,7 +389,7 @@ export function createScreen(ctx) {
|
|
|
379
389
|
mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
|
|
380
390
|
}
|
|
381
391
|
if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
|
|
382
|
-
modal = undefined; selected = undefined; index = 0; tab = 0;
|
|
392
|
+
modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
|
|
383
393
|
rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
|
|
384
394
|
}
|
|
385
395
|
if (s.leaseId) { leaseId = s.leaseId; synced = true; }
|
|
@@ -413,7 +423,7 @@ export function createScreen(ctx) {
|
|
|
413
423
|
serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
|
|
414
424
|
}
|
|
415
425
|
function connect(id) {
|
|
416
|
-
connection?.close(); roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
426
|
+
connection?.close(); characterConfirmed=false;mapOpen=undefined; roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
417
427
|
connection = ctx.connect(`/ws/games/spire/rooms/${id}`, accept, connected => {
|
|
418
428
|
online = connected; synced = false;
|
|
419
429
|
if (!connected) note = '连接中断,正在重连;操作已暂停。';
|
|
@@ -428,6 +438,7 @@ export function createScreen(ctx) {
|
|
|
428
438
|
const envelope = { ...pending, leaseId };
|
|
429
439
|
try {
|
|
430
440
|
const response = await ctx.request(`${BASE}/${roomId}/actions`, 'POST', envelope);
|
|
441
|
+
if(envelope.action.type==='character'){characterConfirmed=true;mapOpen=undefined;}
|
|
431
442
|
pending = undefined; note = ''; modal = undefined; accept(response);
|
|
432
443
|
} catch (e) {
|
|
433
444
|
// Unknown transport outcomes retain the operation id; R retries exactly
|
|
@@ -461,7 +472,7 @@ export function createScreen(ctx) {
|
|
|
461
472
|
}
|
|
462
473
|
// The keyboard branches and clicks move the same state through these, so the
|
|
463
474
|
// two input paths cannot drift apart.
|
|
464
|
-
function switchTab(next) { tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
475
|
+
function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
465
476
|
function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; changed(); }
|
|
466
477
|
function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
|
|
467
478
|
function clickRow(id) {
|
|
@@ -862,6 +873,8 @@ function mapHeader(width, view) {
|
|
|
862
873
|
}); return;
|
|
863
874
|
}
|
|
864
875
|
if (!canAct()) return;
|
|
876
|
+
if(row.id==='keepCharacter'){characterConfirmed=true;mapOpen=undefined;selected=undefined;index=0;changed();return;}
|
|
877
|
+
if(row.id==='openMap'){mapOpen=true;mapReturnTab=tab;tab=0;changed();return;}
|
|
865
878
|
if (tab === 0 && row.pick) {
|
|
866
879
|
const limit = state.pendingChoice?.maximum ?? state.pendingDecision?.maximum ?? 1;
|
|
867
880
|
if (picked.has(row.pick)) picked.delete(row.pick);
|
|
@@ -869,7 +882,7 @@ function mapHeader(width, view) {
|
|
|
869
882
|
else note=`最多选择 ${limit} 张牌。`;
|
|
870
883
|
changed();return;
|
|
871
884
|
}
|
|
872
|
-
if (tab === 0 && row.card) { cardAction(row.card); return; }
|
|
885
|
+
if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
|
|
873
886
|
if (activeTab() === 'potions' && row.potion) {
|
|
874
887
|
if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
|
|
875
888
|
if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
|
|
@@ -997,7 +1010,7 @@ function mapHeader(width, view) {
|
|
|
997
1010
|
// The drawing reads the view state and the party; this supplies both.
|
|
998
1011
|
const mapLines = (view, width) => drawMap(view, width,
|
|
999
1012
|
{ 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
|
|
1013
|
+
const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
1001
1014
|
const screen = {
|
|
1002
1015
|
render(width, rows) {
|
|
1003
1016
|
columns = width; height = rows; hits = [];
|
|
@@ -1005,7 +1018,7 @@ function mapHeader(width, view) {
|
|
|
1005
1018
|
const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
|
|
1006
1019
|
if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
|
|
1007
1020
|
const own = me();
|
|
1008
|
-
const heading = state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
1021
|
+
const heading = needsCharacterChoice() ? '选择初始角色' : state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
1009
1022
|
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
|
|
1010
1023
|
add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
|
|
1011
1024
|
if(mapNavigation()) {
|
|
@@ -1022,9 +1035,9 @@ function mapHeader(width, view) {
|
|
|
1022
1035
|
add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
|
|
1023
1036
|
add(cut(mapDetailText(view,mapData(view)),width),'muted');
|
|
1024
1037
|
add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
|
|
1025
|
-
add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
|
|
1038
|
+
add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
|
|
1026
1039
|
mark({kind:'tab',advance:true});
|
|
1027
|
-
add(cut('[面板 Tab] ·
|
|
1040
|
+
add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
|
|
1028
1041
|
hits=hits.filter(hit=>hit.row<rows);
|
|
1029
1042
|
return frame.slice(0,rows);
|
|
1030
1043
|
}
|
|
@@ -1039,7 +1052,41 @@ function mapHeader(width, view) {
|
|
|
1039
1052
|
const partyBlocks = party.map(p => allyBlock(p));
|
|
1040
1053
|
const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
|
|
1041
1054
|
const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
|
|
1042
|
-
if
|
|
1055
|
+
if(width>=68 && rows>=24 && !wideBattlefield()) {
|
|
1056
|
+
// Medium terminals keep the two teams side by side. Party members
|
|
1057
|
+
// stack in the left column; enemies page independently on the right.
|
|
1058
|
+
const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
|
|
1059
|
+
const budget=Math.max(4,rows-frame.length-10);
|
|
1060
|
+
const room=Math.max(2,Math.min(5,Math.floor(budget/party.length)));
|
|
1061
|
+
const perPage=Math.max(1,Math.floor(budget/room));
|
|
1062
|
+
const pages=Math.max(1,Math.ceil(alive.length/perPage));
|
|
1063
|
+
if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
|
|
1064
|
+
enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
|
|
1065
|
+
const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
|
|
1066
|
+
const compactBlock=(creature,arena,span)=>{
|
|
1067
|
+
const name=`${arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
|
|
1068
|
+
const health=`${hpText(creature)} HP · 格挡 ${creature.block}`;
|
|
1069
|
+
const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
|
|
1070
|
+
const powers=powerEntries(creature).map(p=>p.text).join(' · ');
|
|
1071
|
+
const body=[name,health];
|
|
1072
|
+
if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
|
|
1073
|
+
if(room>=3)body.push(intent||powers||'无额外状态');
|
|
1074
|
+
if(room>=5)body.push(intent?powers:'');
|
|
1075
|
+
return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
|
|
1076
|
+
};
|
|
1077
|
+
const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
|
|
1078
|
+
shown.map(e=>({id:e.id,lines:compactBlock(e,'enemy',rightWidth),legal:legalEnemy(e)}))];
|
|
1079
|
+
const startRow=frame.length,rendered=[[],[]];
|
|
1080
|
+
columns.forEach((entries,side)=>entries.forEach(entry=>{
|
|
1081
|
+
const top=rendered[side].length;
|
|
1082
|
+
const body=entry.legal?tint(entry.lines,targetTint(entry,targeting.arena),aimed(entry)):entry.lines;
|
|
1083
|
+
rendered[side].push(...body);
|
|
1084
|
+
while(rendered[side].length<top+room)rendered[side].push('');
|
|
1085
|
+
hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
|
|
1086
|
+
}));
|
|
1087
|
+
for(let i=0;i<Math.max(...rendered.map(c=>c.length));i++)add(pad(rendered[0][i]??'',leftWidth)+' │ '+(rendered[1][i]??''));
|
|
1088
|
+
if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · ←/→ 或点击翻页`,'muted');}
|
|
1089
|
+
} else if (wideBattlefield()) {
|
|
1043
1090
|
// The party column is as wide as what it prints (a fixed third used to
|
|
1044
1091
|
// cut 增益/减益 short); the enemies share what is left.
|
|
1045
1092
|
const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
|
|
@@ -1105,6 +1152,13 @@ function mapHeader(width, view) {
|
|
|
1105
1152
|
const buttonAt = Math.max(8, width - displayWidth(button) - 1);
|
|
1106
1153
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
1107
1154
|
add(pad(energy, buttonAt) + button, 'title');
|
|
1155
|
+
const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
|
|
1156
|
+
['exhaust','S','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
|
|
1157
|
+
['discard','D','弃牌堆',state.self.discard.length]];
|
|
1158
|
+
const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
|
|
1159
|
+
const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
|
|
1160
|
+
if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
|
|
1161
|
+
add(place(pileLabels.map((text,i)=>({at:positions[i],text})),width).text,'muted');
|
|
1108
1162
|
} else {
|
|
1109
1163
|
add(`${state.event ?? ''} 金币 ${own?.gold ?? 0}`, 'muted');
|
|
1110
1164
|
// §2: outside combat there is no battlefield, so the party, its health
|
|
@@ -1120,11 +1174,23 @@ function mapHeader(width, view) {
|
|
|
1120
1174
|
const labels = tabs.map((key, i) => {
|
|
1121
1175
|
const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
|
|
1122
1176
|
const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
|
|
1123
|
-
return i === tab ? `[${label}]` : label;
|
|
1177
|
+
return !pileView && i === tab ? `[${label}]` : label;
|
|
1124
1178
|
});
|
|
1125
1179
|
const decision = state.pendingChoice ?? state.pendingDecision;
|
|
1126
|
-
if
|
|
1127
|
-
|
|
1180
|
+
if(modal || (tab===0 && decision)) {
|
|
1181
|
+
add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
|
|
1182
|
+
}else {
|
|
1183
|
+
let text='',spans=[];
|
|
1184
|
+
const flush=()=>{mark({kind:'tab',spans});add(text,'title');text='';spans=[];};
|
|
1185
|
+
for(let i=0;i<labels.length;i++){
|
|
1186
|
+
const label=labels[i];
|
|
1187
|
+
if(text && displayWidth(text+' · '+label)>width)flush();
|
|
1188
|
+
const from=displayWidth(text)+(text?3:0);
|
|
1189
|
+
text+=(text?' · ':'')+label;spans.push({tab:i,from,to:from+displayWidth(label)});
|
|
1190
|
+
}
|
|
1191
|
+
if(text)flush();
|
|
1192
|
+
if(pileView)add(`${tabNames[pileView]} · Esc 返回 · Tab 切换面板`,'title');
|
|
1193
|
+
}
|
|
1128
1194
|
if (modal?.detail) {
|
|
1129
1195
|
const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
|
|
1130
1196
|
const count = Math.max(1, Math.min(6, rows - frame.length - 9));
|
|
@@ -1135,14 +1201,15 @@ function mapHeader(width, view) {
|
|
|
1135
1201
|
}
|
|
1136
1202
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1137
1203
|
if (loading) add('正在加载…', 'muted');
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1204
|
+
const footerRows=activeTab()==='potions' && !modal ? 2 : 1;
|
|
1205
|
+
const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - footerRows - 3)) : 0;
|
|
1206
|
+
if (state && state.phase!=='combat') add(''); // breathing room above the list
|
|
1207
|
+
anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1 - (logLines ? logLines + 1 : 0));
|
|
1141
1208
|
pageSize = available;
|
|
1142
1209
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
1143
1210
|
listPage = Math.max(0, Math.min(maxPage, listPage));
|
|
1144
1211
|
const start = listPage * available;
|
|
1145
|
-
if (!list.length && !loading) add(state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1212
|
+
if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1146
1213
|
// §E: a wide frame puts the shop's detail beside the goods; a narrow one
|
|
1147
1214
|
// expands it below the list instead.
|
|
1148
1215
|
const pane = state && state.phase === 'shop' && !modal && list[index]?.shop
|
|
@@ -1179,15 +1246,14 @@ function mapHeader(width, view) {
|
|
|
1179
1246
|
}
|
|
1180
1247
|
}
|
|
1181
1248
|
if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
|
|
1182
|
-
while (frame.length < rows -
|
|
1249
|
+
while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
|
|
1183
1250
|
if (logLines) {
|
|
1184
1251
|
add('战斗日志', 'muted');
|
|
1185
1252
|
const recent = logItems().slice(-logLines);
|
|
1186
1253
|
for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
|
|
1187
1254
|
}
|
|
1188
1255
|
add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
|
|
1189
|
-
|
|
1190
|
-
add('Esc 返回 / 暂停退出 · ? 说明 · R 重试 · B 房间 · D 丢弃药水', 'muted');
|
|
1256
|
+
if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
|
|
1191
1257
|
const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
|
|
1192
1258
|
return frame.slice(0, rows);
|
|
1193
1259
|
},
|
|
@@ -1210,6 +1276,11 @@ function mapHeader(width, view) {
|
|
|
1210
1276
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1211
1277
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1212
1278
|
}
|
|
1279
|
+
if(value==='m' && state?.mapView?.nodes?.length && !modal && !targeting && !state.paused && !needsCharacterChoice() && !state.pendingChoice && !state.pendingDecision){
|
|
1280
|
+
if(mapNavigation()){mapOpen=false;tab=mapReturnTab;}
|
|
1281
|
+
else{mapReturnTab=tab;tab=0;mapOpen=true;}
|
|
1282
|
+
changed();return;
|
|
1283
|
+
}
|
|
1213
1284
|
if (mapNavigation()) {
|
|
1214
1285
|
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1215
1286
|
if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
|
|
@@ -1231,11 +1302,15 @@ function mapHeader(width, view) {
|
|
|
1231
1302
|
return;
|
|
1232
1303
|
}
|
|
1233
1304
|
if(['return','enter'].includes(name)){
|
|
1234
|
-
const row=
|
|
1305
|
+
const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
|
|
1235
1306
|
if(row)void submit({...row.action,phaseId:state.phaseId});
|
|
1236
1307
|
return;
|
|
1237
1308
|
}
|
|
1238
1309
|
}
|
|
1310
|
+
if(mapNavigation() && ['a','s','d','e',' '].includes(value))return;
|
|
1311
|
+
if(!modal && !targeting && !pileView && tab===0 && state?.phase==='combat' && ['left','right'].includes(name)){enemyPage=Math.max(0,enemyPage+(name==='right'?1:-1));changed();return;}
|
|
1312
|
+
if(!modal && !targeting && state?.phase==='combat' && ['a','s','d'].includes(value) && !(value==='d' && activeTab()==='potions')){openPile({a:'draw',s:'exhaust',d:'discard'}[value]);return;}
|
|
1313
|
+
if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
|
|
1239
1314
|
if (value === ' ' && !modal && !targeting) {
|
|
1240
1315
|
const row = items()[index];
|
|
1241
1316
|
if (row?.pick) {
|
|
@@ -1273,7 +1348,6 @@ function mapHeader(width, view) {
|
|
|
1273
1348
|
void guard(async () => { connection?.close(); connection = undefined; roomId = undefined; state = undefined; modal = undefined; pending = undefined; online = synced = false; selected = 'create'; index = 0; await loadRooms(); }); return;
|
|
1274
1349
|
}
|
|
1275
1350
|
if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
|
|
1276
|
-
if (value === 's' && !modal && state) { switchTab(tabs.indexOf('status')); return; }
|
|
1277
1351
|
// The key and the button share one entry point.
|
|
1278
1352
|
if (value === 'e' && !modal && !targeting) { endTurn(); return; }
|
|
1279
1353
|
if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
|
|
@@ -1320,11 +1394,13 @@ function mapHeader(width, view) {
|
|
|
1320
1394
|
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1321
1395
|
// room is only looked at, and parks the view there.
|
|
1322
1396
|
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
1323
|
-
const row =
|
|
1397
|
+
const row = state.phase==='map' && state.options.find(row => row.action?.type === 'vote' && row.action.node === hit.id);
|
|
1324
1398
|
if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
|
|
1325
1399
|
changed();
|
|
1326
1400
|
return;
|
|
1327
1401
|
}
|
|
1402
|
+
if(hit?.kind==='enemyPage' && !targeting && !modal){enemyPage=(enemyPage+1)%hit.pages;changed();return;}
|
|
1403
|
+
if (hit?.kind === 'pile') {openPile(hit.id);return;}
|
|
1328
1404
|
if (hit?.kind === 'shopBuy') {
|
|
1329
1405
|
const row = items()[index];
|
|
1330
1406
|
const price = row?.shop?.price ?? 0;
|