linkchat-extension-spire 0.1.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/index.js +300 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.
|
|
1
|
+
# Spire for LinkChat — 0.3.0
|
|
2
2
|
|
|
3
|
-
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2
|
|
3
|
+
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);推荐使用 **0.7.4 及以上**以包含备用屏幕行定位修复;路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-4`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
|
|
4
4
|
|
|
5
|
-
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.
|
|
5
|
+
离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.3.0.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
|
|
|
@@ -31,3 +31,11 @@
|
|
|
31
31
|
## 其他
|
|
32
32
|
|
|
33
33
|
扩展只提供游戏界面,存档在服务器,卸载扩展不会删除存档。正式客户端 0.6.3 不包含扩展功能。此版本为正式版。
|
|
34
|
+
|
|
35
|
+
## 协议 5 配套更新
|
|
36
|
+
|
|
37
|
+
本版必须与协议 5 的 Spire 后端一起更新。协议 1–4 会明确提示版本不匹配并阻止游戏操作。聊天 CLI 主程序无需重新发布。
|
|
38
|
+
|
|
39
|
+
升级预览按当前牌堆读取卡牌,保留战斗中的临时费用;队友面板关闭后恢复原选择。水晶球支持方向键、Enter、空格和鼠标操作。
|
|
40
|
+
|
|
41
|
+
进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.3.0 使用游戏协议 5,需要配套后端。
|
package/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const PROTOCOL = 5;
|
|
3
|
+
const VERSION_ERROR = 'Spire 客户端与服务器版本不匹配,请配套升级。';
|
|
2
4
|
const BASE = '/api/games/spire/rooms';
|
|
3
5
|
const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
|
|
4
6
|
export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
|
|
5
|
-
const tabs = ['action', 'deck', '
|
|
7
|
+
const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
|
|
6
8
|
const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
|
|
7
9
|
const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
|
|
8
10
|
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:'虚无免费'});
|
|
@@ -46,7 +48,7 @@ Object.assign(powerNames,{ravenous:'食尸增力',plating:'覆甲',tangled:'缠
|
|
|
46
48
|
// moved on, and the screen has to say so rather than refuse to open. Only the
|
|
47
49
|
// phase check is relaxed, and the caller has to ask for that explicitly.
|
|
48
50
|
function wellFormed(s, knownPhase) {
|
|
49
|
-
return !!s &&
|
|
51
|
+
return !!s && s.protocol === PROTOCOL && typeof s.roomId === 'string' && Number.isSafeInteger(s.revision) && Number.isSafeInteger(s.phaseId) && (knownPhase ? Object.hasOwn(phases, s.phase) : typeof s.phase === 'string')
|
|
50
52
|
&& Array.isArray(s.players) && s.players.length <= 4 && s.players.every(p => Number.isSafeInteger(p.id) && typeof p.name === 'string')
|
|
51
53
|
&& Array.isArray(s.enemies) && s.enemies.length <= 12 && s.enemies.every(e => typeof e.id === 'string' && typeof e.name === 'string' && e.intent)
|
|
52
54
|
&& (!s.act || Number.isInteger(s.act.index) && s.act.index>=0 && s.act.index<3 && typeof s.act.name==='string')
|
|
@@ -58,6 +60,14 @@ function wellFormed(s, knownPhase) {
|
|
|
58
60
|
&& s.players.every(p=>p.companions===undefined || Array.isArray(p.companions) && p.companions.length<=3 && p.companions.every(pet=>pet && typeof pet.name==='string' && (pet.cooldown===undefined || Number.isSafeInteger(pet.cooldown) && pet.cooldown>=0)))
|
|
59
61
|
&& s.players.every(p=>p.monologueInstances===undefined || Array.isArray(p.monologueInstances) && p.monologueInstances.every(record=>record && Number.isSafeInteger(record.strength) && record.strength>0 && Number.isSafeInteger(record.applied) && record.applied>=0))
|
|
60
62
|
&& (!s.mapView || typeof s.mapView === 'object' && s.mapView.nodes && s.mapView.nodes.length <= 200 && Array.isArray(s.mapView.nodes) && s.mapView.nodes.every(n => n && typeof n.id === 'string' && Number.isSafeInteger(n.row) && Number.isSafeInteger(n.col) && typeof n.type === 'string' && (n.children === undefined || Array.isArray(n.children))))
|
|
63
|
+
&& (!!s.endTurn && typeof s.endTurn === 'object' && typeof s.endTurn.canEndTurn === 'boolean' && typeof s.endTurn.canUndoEndTurn === 'boolean' && typeof s.endTurn.reason === 'string')
|
|
64
|
+
&& (!s.eventProgress?.sphere || (Number.isSafeInteger(s.eventProgress.sphere.divinations) && s.eventProgress.sphere.divinations >= 0
|
|
65
|
+
&& Number.isSafeInteger(s.eventProgress.sphere.width) && s.eventProgress.sphere.width > 0 && s.eventProgress.sphere.width <= 32
|
|
66
|
+
&& Number.isSafeInteger(s.eventProgress.sphere.height) && s.eventProgress.sphere.height > 0 && s.eventProgress.sphere.height <= 32
|
|
67
|
+
&& typeof s.eventProgress.sphere.tool === 'string' && typeof s.eventProgress.sphere.finished === 'boolean'
|
|
68
|
+
&& Array.isArray(s.eventProgress.sphere.cells) && s.eventProgress.sphere.cells.length <= 1024
|
|
69
|
+
&& s.eventProgress.sphere.cells.every(cell => cell && Number.isSafeInteger(cell.x) && Number.isSafeInteger(cell.y)
|
|
70
|
+
&& (cell.item === null || cell.item === undefined || typeof cell.item === 'string'))))
|
|
61
71
|
&& Array.isArray(s.options) && Array.isArray(s.log) && s.log.every(entry => typeof entry === 'string' || entry && Number.isSafeInteger(entry.id) && typeof entry.text === 'string');
|
|
62
72
|
}
|
|
63
73
|
export function validState(s) { return wellFormed(s, true); }
|
|
@@ -136,10 +146,14 @@ function mapVotes(node, players) {
|
|
|
136
146
|
}
|
|
137
147
|
function drawMap(view, width, session = {}) {
|
|
138
148
|
const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
|
|
139
|
-
const focus = session.focus
|
|
149
|
+
const focus = data.byId.has(session.focus) ? session.focus : view.current;
|
|
150
|
+
const codeLabel = n => `${n.id===focus?'›':' '} ${mapCode(n)}`;
|
|
140
151
|
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
152
|
const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
|
|
153
|
+
// An 80-column terminal gives the extension 75 columns after host margins.
|
|
154
|
+
// Reserve only measured text plus a separator before falling back to a table.
|
|
155
|
+
const needed = Math.max(8, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 1));
|
|
156
|
+
const slot = Math.max(needed, width >= cols * 12 ? 12 : 6);
|
|
143
157
|
const unsafeGap = layers.some((layer,i)=>{
|
|
144
158
|
const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
|
|
145
159
|
const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
|
|
@@ -154,7 +168,7 @@ function drawMap(view, width, session = {}) {
|
|
|
154
168
|
const rooms = data.rows.get(layers[i]), block = [];
|
|
155
169
|
if (compact) {
|
|
156
170
|
for (const n of rooms) {
|
|
157
|
-
const label = `${
|
|
171
|
+
const label = `${codeLabel(n)} ${labels.get(n.id)}`;
|
|
158
172
|
const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
|
|
159
173
|
const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
|
|
160
174
|
let suffix='';
|
|
@@ -169,7 +183,7 @@ function drawMap(view, width, session = {}) {
|
|
|
169
183
|
} else {
|
|
170
184
|
for (const codes of [true,false]) {
|
|
171
185
|
const pieces = rooms.map(n => {
|
|
172
|
-
const text = codes ?
|
|
186
|
+
const text = codes ? codeLabel(n) : labels.get(n.id);
|
|
173
187
|
return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
|
|
174
188
|
});
|
|
175
189
|
const placed = place(pieces,width);
|
|
@@ -272,7 +286,10 @@ function cut(text, width) {
|
|
|
272
286
|
}
|
|
273
287
|
export function createScreen(ctx) {
|
|
274
288
|
let state, roomId, leaseId, connection, online = false, synced = false, rooms = [], loading = true, busy = false, closed = false;
|
|
275
|
-
let
|
|
289
|
+
let ascension = 0;
|
|
290
|
+
const ascensionNames = ['基础', '精英增多', '疲惫旅人', '贫困', '紧束腰带', '进阶之灾', '通货膨胀', '稀缺', '坚韧敌人', '致命敌人', '双重首领'];
|
|
291
|
+
const ascensionDescriptions = ['无进阶修正', '地图精英配额增加', '古神恢复损失生命的 80%', '战斗与宝箱金币减少', '初始药水栏位减 1', '初始牌组加入进阶之灾', '删牌 100 起,每次增加 50', '稀有卡与升级卡概率降低', '怪物生命与防御等强化', '怪物攻击等强化', '第三幕连续两个不同首领'];
|
|
292
|
+
let tab = 0, selected = 'create', index = 0, modal, note = '', pending, columns = 80, height = 24, serverProtocol = undefined, unsupported;
|
|
276
293
|
// Frame row -> what that row means, rebuilt by every render.
|
|
277
294
|
let hits = [];
|
|
278
295
|
let targeting;
|
|
@@ -294,15 +311,20 @@ export function createScreen(ctx) {
|
|
|
294
311
|
const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
|
|
295
312
|
const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
|
|
296
313
|
const me = () => state?.players.find(p => p.id === ctx.user.id);
|
|
297
|
-
|
|
314
|
+
let characterConfirmed=false, mapOpen, mapReturnTab=0;
|
|
315
|
+
const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
|
|
316
|
+
let pileView;
|
|
317
|
+
const activeTab = () => pileView ?? tabs[tab];
|
|
318
|
+
function openPile(key) { if(state?.phase!=='combat' || modal)return; pileView=key; index=0;selected=undefined;listPage=0;changed(); }
|
|
298
319
|
const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
|
|
299
320
|
// A phase this build cannot render stays read only, so nothing is submitted.
|
|
300
321
|
const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
|
|
301
322
|
const cardItem = c => ({ id: c.id, label: `${c.name} [${c.cost}] ${c.description}`, card: c });
|
|
302
323
|
function items() {
|
|
303
324
|
if (modal) return modal.items;
|
|
304
|
-
if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:'单人攀登 ·
|
|
325
|
+
if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:rooms.some(r=>r.mode==='solo') ? `继续单人攀登 · A${rooms.find(r=>r.mode==='solo').ascension ?? 0}` : '单人攀登 · 开始'}, {id:'ascension',label:`新局难度 · A${ascension} ${ascensionNames[ascension]}(全部开放)`}, ...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(' / ')} · A${r.ascension ?? 0} · ${r.players.length}/4 · ${phases[r.phase] ?? r.phase}${r.paused ? ' · 已暂停' : ''}${r.compatible === false ? ' · 版本不兼容' : ''}` }))];
|
|
305
326
|
if (!state) return [];
|
|
327
|
+
if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
|
|
306
328
|
if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
|
|
307
329
|
if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
|
|
308
330
|
if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
|
|
@@ -351,17 +373,19 @@ export function createScreen(ctx) {
|
|
|
351
373
|
: { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
|
|
352
374
|
return rows;
|
|
353
375
|
}
|
|
376
|
+
if(state.phase==='map' && !state.paused && state.mapView?.nodes?.length)return [{id:'openMap',label:'打开地图 M'}];
|
|
354
377
|
return state.options;
|
|
355
378
|
}
|
|
356
379
|
function anchor() {
|
|
357
380
|
const rows = items(), at = rows.findIndex(row => row.id === selected);
|
|
358
|
-
index = at >= 0 ? at : Math.max(0, Math.min(index, rows.length - 1)); selected = rows[index]?.id;
|
|
381
|
+
index = at >= 0 ? at : Math.max(0, Math.min(Number.isFinite(index) ? index : 0, rows.length - 1)); selected = rows[index]?.id;
|
|
359
382
|
}
|
|
360
383
|
function accept(s) {
|
|
361
384
|
// A phase this build does not know means the server moved ahead of the
|
|
362
385
|
// extension: open it read only instead of refusing the whole state.
|
|
363
386
|
const unknownPhase = typeof s?.phase === 'string' && !Object.hasOwn(phases, s.phase);
|
|
364
|
-
if (
|
|
387
|
+
if (s?.protocol !== PROTOCOL) { synced=false; note=VERSION_ERROR; changed(); throw new Error(VERSION_ERROR); }
|
|
388
|
+
if (!wellFormed(s, !unknownPhase) || s.self.id !== ctx.user.id || s.channelId !== ctx.channel.id || s.roomId !== roomId) { synced=false; throw new Error('游戏状态与客户端不兼容。'); }
|
|
365
389
|
// An older snapshot must not clear the read-only guard before it is discarded:
|
|
366
390
|
// check the revision first, then update anything derived from the state.
|
|
367
391
|
if (state && s.revision < state.revision) return;
|
|
@@ -379,7 +403,7 @@ export function createScreen(ctx) {
|
|
|
379
403
|
mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
|
|
380
404
|
}
|
|
381
405
|
if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
|
|
382
|
-
modal = undefined; selected = undefined; index = 0; tab = 0;
|
|
406
|
+
modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
|
|
383
407
|
rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
|
|
384
408
|
}
|
|
385
409
|
if (s.leaseId) { leaseId = s.leaseId; synced = true; }
|
|
@@ -409,11 +433,12 @@ export function createScreen(ctx) {
|
|
|
409
433
|
try { result = await ctx.request(`${BASE}?channelId=${ctx.channel.id}`); }
|
|
410
434
|
catch (e) { if (e?.status === 404) throw new Error('服务器尚未安装或启用 Spire 后端模块。'); throw e; }
|
|
411
435
|
if (closed) return;
|
|
412
|
-
if (
|
|
436
|
+
if (result?.protocol !== PROTOCOL) { synced=false; serverProtocol=undefined; throw new Error(VERSION_ERROR); }
|
|
437
|
+
if (!Array.isArray(result.rooms)) throw new Error('游戏房间列表无效。');
|
|
413
438
|
serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
|
|
414
439
|
}
|
|
415
440
|
function connect(id) {
|
|
416
|
-
connection?.close(); roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
441
|
+
connection?.close(); characterConfirmed=false;mapOpen=undefined; roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
|
|
417
442
|
connection = ctx.connect(`/ws/games/spire/rooms/${id}`, accept, connected => {
|
|
418
443
|
online = connected; synced = false;
|
|
419
444
|
if (!connected) note = '连接中断,正在重连;操作已暂停。';
|
|
@@ -422,12 +447,13 @@ export function createScreen(ctx) {
|
|
|
422
447
|
});
|
|
423
448
|
}
|
|
424
449
|
async function submit(action, retry = false) {
|
|
425
|
-
if ((!retry && !canAct()) || !leaseId || !roomId) return;
|
|
450
|
+
if ((!retry && !canAct()) || !online || !synced || !leaseId || !roomId) return;
|
|
426
451
|
await guard(async () => {
|
|
427
452
|
if (!retry) pending = { actionId: randomUUID(), leaseId, action: { ...action, phaseId: action.phaseId ?? state.phaseId } };
|
|
428
453
|
const envelope = { ...pending, leaseId };
|
|
429
454
|
try {
|
|
430
455
|
const response = await ctx.request(`${BASE}/${roomId}/actions`, 'POST', envelope);
|
|
456
|
+
if(envelope.action.type==='character'){characterConfirmed=true;mapOpen=undefined;}
|
|
431
457
|
pending = undefined; note = ''; modal = undefined; accept(response);
|
|
432
458
|
} catch (e) {
|
|
433
459
|
// Unknown transport outcomes retain the operation id; R retries exactly
|
|
@@ -439,12 +465,34 @@ export function createScreen(ctx) {
|
|
|
439
465
|
});
|
|
440
466
|
}
|
|
441
467
|
function choose(title, candidates, acceptChoice) {
|
|
442
|
-
modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index }; index = 0; selected = candidates[0]?.id; changed();
|
|
468
|
+
modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index, previousPage: listPage }; listPage = 0; index = 0; selected = candidates[0]?.id; changed();
|
|
443
469
|
}
|
|
444
470
|
function deckChoice(mode, callback) {
|
|
445
471
|
const cards = state.self.deck.filter(c => mode === 'deckUpgrade' ? (!c.upgraded && !['status', 'curse', 'quest'].includes(c.kind)) : mode === 'deckEnchant' ? (c.canSown !== false && !c.sown && !c.inky && !c.enchantment && !['status', 'curse', 'quest'].includes(c.kind)) : c.removable !== false);
|
|
446
472
|
if (!cards.length) { note = '没有符合条件的牌。'; changed(); return; }
|
|
447
|
-
choose('选择牌组中的牌', cards.map(cardItem), row =>
|
|
473
|
+
choose('选择牌组中的牌', cards.map(cardItem), row => {
|
|
474
|
+
if (mode === 'deckUpgrade') { void previewUpgrade(row.id, 'deck', callback); return; }
|
|
475
|
+
callback(row.id);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
async function previewUpgrade(cardId, source, confirmUpgrade) {
|
|
479
|
+
if (!roomId || !canAct() || !cardId) return;
|
|
480
|
+
const originRoom=roomId, originPhase=state.phaseId, originRevision=state.revision;
|
|
481
|
+
await guard(async () => {
|
|
482
|
+
const body = await ctx.request(`${BASE}/${originRoom}/previews/upgrade`, 'POST', { cardId, source });
|
|
483
|
+
if(roomId!==originRoom || state?.phaseId!==originPhase || state?.revision!==originRevision)return;
|
|
484
|
+
const line = card => `${card.name} [${card.cost}] ${card.description}`;
|
|
485
|
+
const rows = [
|
|
486
|
+
{ id:'before', label:`升级前:${line(body.before)}` },
|
|
487
|
+
{ id:'after', label:`升级后:${line(body.after)}` },
|
|
488
|
+
{ id:'back', label:'返回' },
|
|
489
|
+
];
|
|
490
|
+
if (confirmUpgrade) rows.push({ id:'confirm', label:'确认升级' });
|
|
491
|
+
choose('升级预览', rows, answer => {
|
|
492
|
+
if (answer.id === 'confirm') confirmUpgrade(cardId);
|
|
493
|
+
else { modal=undefined; anchor(); changed(); }
|
|
494
|
+
});
|
|
495
|
+
});
|
|
448
496
|
}
|
|
449
497
|
function cardAction(c) {
|
|
450
498
|
if (!canAct() || state.paused || me()?.done || me()?.hp <= 0) return;
|
|
@@ -456,13 +504,16 @@ export function createScreen(ctx) {
|
|
|
456
504
|
else void submit(action);
|
|
457
505
|
};
|
|
458
506
|
const eligible = state.self.hand.filter(x => x.id !== c.id && (c.choice !== 'upgrade' || (!x.upgraded && !['status', 'curse', 'quest'].includes(x.kind))));
|
|
459
|
-
if (c.choice && eligible.length) choose(c.choice === 'upgrade' ? '选择升级的手牌' : '选择消耗的手牌', eligible.map(cardItem), row => {
|
|
507
|
+
if (c.choice && eligible.length) choose(c.choice === 'upgrade' ? '选择升级的手牌' : '选择消耗的手牌', eligible.map(cardItem), row => {
|
|
508
|
+
const commit=()=>{ action.choice = row.id; send(); };
|
|
509
|
+
if (c.choice === 'upgrade') void previewUpgrade(row.id, 'hand', commit); else commit();
|
|
510
|
+
});
|
|
460
511
|
else send();
|
|
461
512
|
}
|
|
462
513
|
// The keyboard branches and clicks move the same state through these, so the
|
|
463
514
|
// two input paths cannot drift apart.
|
|
464
|
-
function switchTab(next) { tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
465
|
-
function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; changed(); }
|
|
515
|
+
function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
|
|
516
|
+
function moveTo(next) { const list = items(); index = Math.max(0, Math.min(Number.isFinite(next) ? next : 0, list.length - 1)); selected = list[index]?.id; changed(); }
|
|
466
517
|
function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
|
|
467
518
|
function clickRow(id) {
|
|
468
519
|
const list = items(), at = list.findIndex(row => row.id === id);
|
|
@@ -773,11 +824,92 @@ function mapHeader(width, view) {
|
|
|
773
824
|
return hits.filter(hit => hit.row <= y && y < hit.row + hit.height && hit.col <= x && x < hit.col + hit.width)
|
|
774
825
|
.sort((left, right) => left.width * left.height - right.width * right.height)[0];
|
|
775
826
|
}
|
|
827
|
+
// The divination board. The server sends only the cells a player has looked
|
|
828
|
+
// at, so the client cannot draw what it has not earned and has no layout of
|
|
829
|
+
// its own to keep in step.
|
|
830
|
+
let sphereCursor = { x: 5, y: 5 };
|
|
831
|
+
function sphereView() { return state?.eventProgress?.sphere; }
|
|
832
|
+
function sphereGlyph(cell) {
|
|
833
|
+
if (!cell) return '·';
|
|
834
|
+
if (cell.item === 'Relic') return 'R';
|
|
835
|
+
if (cell.item === 'Potion') return 'P';
|
|
836
|
+
if (cell.item === 'CardReward') return 'C';
|
|
837
|
+
if (cell.item === 'Gold') return '$';
|
|
838
|
+
if (cell.item === 'Curse') return '!';
|
|
839
|
+
return 'o';
|
|
840
|
+
}
|
|
841
|
+
function sphereAt(view, x, y) {
|
|
842
|
+
return (view.cells ?? []).find(cell => cell.x === x && cell.y === y);
|
|
843
|
+
}
|
|
844
|
+
function sphereLines(view) {
|
|
845
|
+
const header = ' ' + Array.from({ length: view.width }, (_, x) => String(x % 10)).join(' ');
|
|
846
|
+
const lines = [header];
|
|
847
|
+
for (let y = 0; y < view.height; y += 1) {
|
|
848
|
+
const cells = [];
|
|
849
|
+
for (let x = 0; x < view.width; x += 1) {
|
|
850
|
+
const here = x === sphereCursor.x && y === sphereCursor.y;
|
|
851
|
+
const glyph = sphereGlyph(sphereAt(view, x, y));
|
|
852
|
+
cells.push(here ? `[${glyph}` : ` ${glyph}`);
|
|
853
|
+
}
|
|
854
|
+
lines.push(`${String(y).padStart(2, ' ')} ${cells.join('').slice(1)}`);
|
|
855
|
+
}
|
|
856
|
+
return lines;
|
|
857
|
+
}
|
|
858
|
+
function sphereMove(dx, dy) {
|
|
859
|
+
const view = sphereView(); if (!view) return;
|
|
860
|
+
sphereCursor = { x: Math.max(0, Math.min(view.width - 1, sphereCursor.x + dx)),
|
|
861
|
+
y: Math.max(0, Math.min(view.height - 1, sphereCursor.y + dy)) };
|
|
862
|
+
changed();
|
|
863
|
+
}
|
|
864
|
+
function sphereUse() {
|
|
865
|
+
const view = sphereView(); if (!view || view.finished) return;
|
|
866
|
+
void submit({ type: 'sphere', op: 'use', x: sphereCursor.x, y: sphereCursor.y });
|
|
867
|
+
}
|
|
868
|
+
function sphereTool() {
|
|
869
|
+
const view = sphereView(); if (!view || view.finished) return;
|
|
870
|
+
void submit({ type: 'sphere', op: 'tool', tool: view.tool === 'Big' ? 'Small' : 'Big' });
|
|
871
|
+
}
|
|
872
|
+
// Teammate detail. Read only, and paged on the server: the client asks for a
|
|
873
|
+
// page and shows what comes back, so it never holds a second copy of anyone's
|
|
874
|
+
// deck and never has to keep one in step.
|
|
875
|
+
async function inspectPlayer(id, page = 0) {
|
|
876
|
+
if (!roomId || !canAct()) return;
|
|
877
|
+
await guard(async () => {
|
|
878
|
+
const body = await ctx.request(`${BASE}/${roomId}/players/${id}/inspection?page=${page}`);
|
|
879
|
+
const rows = body.rows ?? [];
|
|
880
|
+
const first = body.total === 0 ? 0 : page * (body.pageSize ?? 40) + 1;
|
|
881
|
+
const last = page * (body.pageSize ?? 40) + rows.length;
|
|
882
|
+
const pages = Math.max(1, Math.ceil(body.total / (body.pageSize ?? 40)));
|
|
883
|
+
const previous = modal?.inspection ? {previousSelected:modal.previousSelected, previousIndex:modal.previousIndex, previousPage:modal.previousPage} : undefined;
|
|
884
|
+
choose(`${body.name} · ${first}-${last}/${body.total} · 第 ${page + 1}/${pages} 页`,
|
|
885
|
+
rows.map((row, i) => ({ id: `i${i}`, label: `${row.pile === 'status' ? powerNames[row.key] ?? row.key : row.name ?? row.key}${row.amount !== undefined ? ` ${row.amount}` : ''}${row.description ? `:${row.description}` : ''}${row.upgraded ? ' +' : ''}` })),
|
|
886
|
+
() => { anchor(); changed(); });
|
|
887
|
+
if(previous)Object.assign(modal,previous);
|
|
888
|
+
modal.inspection={id,page,pages,total:body.total};
|
|
889
|
+
changed();
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
function endTurnState() {
|
|
893
|
+
return state.endTurn;
|
|
894
|
+
}
|
|
895
|
+
// One key, three states. The server decides which applies and why, so the
|
|
896
|
+
// client never has to infer "the turn is resolving" from the phase.
|
|
776
897
|
function endTurn() {
|
|
777
|
-
if (modal || !canAct() || state?.phase !== 'combat' || state.paused
|
|
778
|
-
|
|
898
|
+
if (modal || !canAct() || state?.phase !== 'combat' || state.paused) return;
|
|
899
|
+
const control = endTurnState();
|
|
900
|
+
if (control.canUndoEndTurn) { void submit({ type: 'undoEnd', turn: state.turn }); return; }
|
|
901
|
+
if (!control.canEndTurn) return;
|
|
779
902
|
void submit({ type: 'end' });
|
|
780
903
|
}
|
|
904
|
+
function endTurnLabel() {
|
|
905
|
+
const own = me();
|
|
906
|
+
if ((own?.hp ?? 0) <= 0) return '[已倒下]';
|
|
907
|
+
if (state?.phase !== 'combat') return '';
|
|
908
|
+
const control = endTurnState();
|
|
909
|
+
if (control.canUndoEndTurn) return '[E 撤销结束回合]';
|
|
910
|
+
if (control.canEndTurn) return state.paused ? '[等待继续]' : '[E 结束回合]';
|
|
911
|
+
return state.paused ? '[等待继续]' : `[${control.reason || '等待结算'}]`;
|
|
912
|
+
}
|
|
781
913
|
function inspectEntity(id) {
|
|
782
914
|
const enemy = (state.enemies ?? []).find(entry => entry.id === id);
|
|
783
915
|
const player = (state.players ?? []).find(entry => String(entry.id) === id);
|
|
@@ -849,12 +981,15 @@ function mapHeader(width, view) {
|
|
|
849
981
|
function confirm() {
|
|
850
982
|
if (busy || pending) return;
|
|
851
983
|
anchor(); const row = items()[index]; if (!row) return;
|
|
852
|
-
if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; current.accept(row); return; }
|
|
984
|
+
if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; listPage = current.previousPage ?? 0; current.accept(row); return; }
|
|
853
985
|
if (!roomId) {
|
|
986
|
+
if (row.id === 'ascension') { choose('进阶难度 · 效果逐级叠加 · 创建后固定', ascensionNames.map((name, level)=>({id:String(level), label:`A${level} · ${name}:${ascensionDescriptions[level]}${level===ascension ? ' ✓' : ''}`})), choice=>{ascension=Number(choice.id);changed();});return; }
|
|
854
987
|
void guard(async () => {
|
|
988
|
+
if(serverProtocol!==PROTOCOL)throw new Error(VERSION_ERROR);
|
|
855
989
|
if (row.id === 'create' || row.id === 'solo') {
|
|
856
|
-
|
|
857
|
-
|
|
990
|
+
const existing = row.id === 'solo' && rooms.find(r=>r.mode==='solo');
|
|
991
|
+
if (existing) { if(existing.compatible===false)throw new Error('该存档需要对应版本的后端游戏模块。'); await ctx.request(`${BASE}/${existing.id}/join`, 'POST');connect(existing.id);return; }
|
|
992
|
+
const result = await ctx.request(BASE, 'POST', { ascension, channelId: ctx.channel.id, requestId: randomUUID(),mode:row.id==='solo'?'solo':'coop' }); connect(result.id);
|
|
858
993
|
} else {
|
|
859
994
|
if (row.room.compatible === false) throw new Error('该存档需要对应版本的后端游戏模块。');
|
|
860
995
|
await ctx.request(`${BASE}/${row.id}/join`, 'POST'); connect(row.id);
|
|
@@ -862,6 +997,8 @@ function mapHeader(width, view) {
|
|
|
862
997
|
}); return;
|
|
863
998
|
}
|
|
864
999
|
if (!canAct()) return;
|
|
1000
|
+
if(row.id==='keepCharacter'){characterConfirmed=true;mapOpen=undefined;selected=undefined;index=0;changed();return;}
|
|
1001
|
+
if(row.id==='openMap'){mapOpen=true;mapReturnTab=tab;tab=0;changed();return;}
|
|
865
1002
|
if (tab === 0 && row.pick) {
|
|
866
1003
|
const limit = state.pendingChoice?.maximum ?? state.pendingDecision?.maximum ?? 1;
|
|
867
1004
|
if (picked.has(row.pick)) picked.delete(row.pick);
|
|
@@ -869,7 +1006,7 @@ function mapHeader(width, view) {
|
|
|
869
1006
|
else note=`最多选择 ${limit} 张牌。`;
|
|
870
1007
|
changed();return;
|
|
871
1008
|
}
|
|
872
|
-
if (tab === 0 && row.card) { cardAction(row.card); return; }
|
|
1009
|
+
if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
|
|
873
1010
|
if (activeTab() === 'potions' && row.potion) {
|
|
874
1011
|
if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
|
|
875
1012
|
if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
|
|
@@ -997,7 +1134,7 @@ function mapHeader(width, view) {
|
|
|
997
1134
|
// The drawing reads the view state and the party; this supplies both.
|
|
998
1135
|
const mapLines = (view, width) => drawMap(view, width,
|
|
999
1136
|
{ 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
|
|
1137
|
+
const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
1001
1138
|
const screen = {
|
|
1002
1139
|
render(width, rows) {
|
|
1003
1140
|
columns = width; height = rows; hits = [];
|
|
@@ -1005,7 +1142,7 @@ function mapHeader(width, view) {
|
|
|
1005
1142
|
const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
|
|
1006
1143
|
if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
|
|
1007
1144
|
const own = me();
|
|
1008
|
-
const heading = state ?
|
|
1145
|
+
const heading = needsCharacterChoice() ? '选择初始角色' : state ? `A${state.ascension ?? 0} · ${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
|
|
1009
1146
|
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
|
|
1010
1147
|
add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
|
|
1011
1148
|
if(mapNavigation()) {
|
|
@@ -1022,9 +1159,9 @@ function mapHeader(width, view) {
|
|
|
1022
1159
|
add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
|
|
1023
1160
|
add(cut(mapDetailText(view,mapData(view)),width),'muted');
|
|
1024
1161
|
add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
|
|
1025
|
-
add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
|
|
1162
|
+
add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
|
|
1026
1163
|
mark({kind:'tab',advance:true});
|
|
1027
|
-
add(cut('[面板 Tab] ·
|
|
1164
|
+
add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
|
|
1028
1165
|
hits=hits.filter(hit=>hit.row<rows);
|
|
1029
1166
|
return frame.slice(0,rows);
|
|
1030
1167
|
}
|
|
@@ -1039,7 +1176,41 @@ function mapHeader(width, view) {
|
|
|
1039
1176
|
const partyBlocks = party.map(p => allyBlock(p));
|
|
1040
1177
|
const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
|
|
1041
1178
|
const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
|
|
1042
|
-
if
|
|
1179
|
+
if(width>=68 && rows>=24 && !wideBattlefield()) {
|
|
1180
|
+
// Medium terminals keep the two teams side by side. Party members
|
|
1181
|
+
// stack in the left column; enemies page independently on the right.
|
|
1182
|
+
const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
|
|
1183
|
+
const budget=Math.max(4,rows-frame.length-10);
|
|
1184
|
+
const room=Math.max(2,Math.min(5,Math.floor(budget/party.length)));
|
|
1185
|
+
const perPage=Math.max(1,Math.floor(budget/room));
|
|
1186
|
+
const pages=Math.max(1,Math.ceil(alive.length/perPage));
|
|
1187
|
+
if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
|
|
1188
|
+
enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
|
|
1189
|
+
const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
|
|
1190
|
+
const compactBlock=(creature,arena,span)=>{
|
|
1191
|
+
const name=`${arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
|
|
1192
|
+
const health=`${hpText(creature)} HP · 格挡 ${creature.block}`;
|
|
1193
|
+
const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
|
|
1194
|
+
const powers=powerEntries(creature).map(p=>p.text).join(' · ');
|
|
1195
|
+
const body=[name,health];
|
|
1196
|
+
if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
|
|
1197
|
+
if(room>=3)body.push(intent||powers||'无额外状态');
|
|
1198
|
+
if(room>=5)body.push(intent?powers:'');
|
|
1199
|
+
return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
|
|
1200
|
+
};
|
|
1201
|
+
const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
|
|
1202
|
+
shown.map(e=>({id:e.id,lines:compactBlock(e,'enemy',rightWidth),legal:legalEnemy(e)}))];
|
|
1203
|
+
const startRow=frame.length,rendered=[[],[]];
|
|
1204
|
+
columns.forEach((entries,side)=>entries.forEach(entry=>{
|
|
1205
|
+
const top=rendered[side].length;
|
|
1206
|
+
const body=entry.legal?tint(entry.lines,targetTint(entry,targeting.arena),aimed(entry)):entry.lines;
|
|
1207
|
+
rendered[side].push(...body);
|
|
1208
|
+
while(rendered[side].length<top+room)rendered[side].push('');
|
|
1209
|
+
hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
|
|
1210
|
+
}));
|
|
1211
|
+
for(let i=0;i<Math.max(...rendered.map(c=>c.length));i++)add(pad(rendered[0][i]??'',leftWidth)+' │ '+(rendered[1][i]??''));
|
|
1212
|
+
if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · ←/→ 或点击翻页`,'muted');}
|
|
1213
|
+
} else if (wideBattlefield()) {
|
|
1043
1214
|
// The party column is as wide as what it prints (a fixed third used to
|
|
1044
1215
|
// cut 增益/减益 short); the enemies share what is left.
|
|
1045
1216
|
const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
|
|
@@ -1098,13 +1269,20 @@ function mapHeader(width, view) {
|
|
|
1098
1269
|
const usable = own?.energy ?? 0;
|
|
1099
1270
|
const capacity = own?.energyCapacity ?? usable;
|
|
1100
1271
|
const orbs = '●'.repeat(Math.max(0, usable)) + '○'.repeat(Math.max(0, capacity - usable));
|
|
1101
|
-
const button = (
|
|
1272
|
+
const button = endTurnLabel();
|
|
1102
1273
|
// §2: the Regent's stars are a resource of the same row as the energy.
|
|
1103
1274
|
const stars = own?.character === 'regent' || (own?.stars ?? 0) > 0 ? `星星 ${own?.stars ?? 0}` : '';
|
|
1104
1275
|
const energy = `能量 ${orbs ? `${orbs} ` : ''}${usable}/${capacity}${stars ? ` ${stars}` : ''}`;
|
|
1105
1276
|
const buttonAt = Math.max(8, width - displayWidth(button) - 1);
|
|
1106
1277
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
1107
1278
|
add(pad(energy, buttonAt) + button, 'title');
|
|
1279
|
+
const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
|
|
1280
|
+
['exhaust','S','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
|
|
1281
|
+
['discard','D','弃牌堆',state.self.discard.length]];
|
|
1282
|
+
const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
|
|
1283
|
+
const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
|
|
1284
|
+
if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
|
|
1285
|
+
add(place(pileLabels.map((text,i)=>({at:positions[i],text})),width).text,'muted');
|
|
1108
1286
|
} else {
|
|
1109
1287
|
add(`${state.event ?? ''} 金币 ${own?.gold ?? 0}`, 'muted');
|
|
1110
1288
|
// §2: outside combat there is no battlefield, so the party, its health
|
|
@@ -1112,19 +1290,44 @@ function mapHeader(width, view) {
|
|
|
1112
1290
|
for (const player of state.players ?? []) {
|
|
1113
1291
|
const here = state.online?.includes(player.id);
|
|
1114
1292
|
const flag = !here ? '离线' : state.paused || state.phase === 'lobby' ? (player.ready ? '已准备' : '') : player.done ? '已完成' : '';
|
|
1115
|
-
|
|
1293
|
+
const rowText = 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);
|
|
1294
|
+
// Clicking a teammate is the same act as pressing P on them. The
|
|
1295
|
+
// mark is taken before the line is pushed, while the row still is
|
|
1296
|
+
// the one it will land on.
|
|
1297
|
+
if (player.id !== ctx.user.id && !modal) mark({ kind: 'inspect', id: String(player.id), width: displayWidth(rowText) });
|
|
1298
|
+
add(rowText, player.id === ctx.user.id ? 'selected' : 'text');
|
|
1116
1299
|
}
|
|
1117
1300
|
if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
|
|
1301
|
+
const sphere = sphereView();
|
|
1302
|
+
if (sphere && !modal) {
|
|
1303
|
+
const left = sphere.divinations;
|
|
1304
|
+
add(`占卜剩余 ${left} 次 · 工具:${sphere.tool === 'Big' ? '大(3×3)' : '小(1 格)'} · 方向键移动,空格揭开,T 换工具`,
|
|
1305
|
+
left ? 'title' : 'muted');
|
|
1306
|
+
for (const line of sphereLines(sphere)) add(line, 'text');
|
|
1307
|
+
add('R 遗物 · P 药水 · C 卡牌奖励 · $ 金币 · ! 诅咒 · o 空 · · 未揭开', 'muted');
|
|
1308
|
+
}
|
|
1118
1309
|
|
|
1119
1310
|
}
|
|
1120
1311
|
const labels = tabs.map((key, i) => {
|
|
1121
1312
|
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
1313
|
const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
|
|
1123
|
-
return i === tab ? `[${label}]` : label;
|
|
1314
|
+
return !pileView && i === tab ? `[${label}]` : label;
|
|
1124
1315
|
});
|
|
1125
1316
|
const decision = state.pendingChoice ?? state.pendingDecision;
|
|
1126
|
-
if
|
|
1127
|
-
|
|
1317
|
+
if(modal || (tab===0 && decision)) {
|
|
1318
|
+
add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
|
|
1319
|
+
}else {
|
|
1320
|
+
let text='',spans=[];
|
|
1321
|
+
const flush=()=>{mark({kind:'tab',spans});add(text,'title');text='';spans=[];};
|
|
1322
|
+
for(let i=0;i<labels.length;i++){
|
|
1323
|
+
const label=labels[i];
|
|
1324
|
+
if(text && displayWidth(text+' · '+label)>width)flush();
|
|
1325
|
+
const from=displayWidth(text)+(text?3:0);
|
|
1326
|
+
text+=(text?' · ':'')+label;spans.push({tab:i,from,to:from+displayWidth(label)});
|
|
1327
|
+
}
|
|
1328
|
+
if(text)flush();
|
|
1329
|
+
if(pileView)add(`${tabNames[pileView]} · Esc 返回 · Tab 切换面板`,'title');
|
|
1330
|
+
}
|
|
1128
1331
|
if (modal?.detail) {
|
|
1129
1332
|
const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
|
|
1130
1333
|
const count = Math.max(1, Math.min(6, rows - frame.length - 9));
|
|
@@ -1135,14 +1338,15 @@ function mapHeader(width, view) {
|
|
|
1135
1338
|
}
|
|
1136
1339
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1137
1340
|
if (loading) add('正在加载…', 'muted');
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1341
|
+
const footerRows=activeTab()==='potions' && !modal ? 2 : 1;
|
|
1342
|
+
const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - footerRows - 3)) : 0;
|
|
1343
|
+
if (state && state.phase!=='combat') add(''); // breathing room above the list
|
|
1344
|
+
anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1 - (logLines ? logLines + 1 : 0));
|
|
1141
1345
|
pageSize = available;
|
|
1142
1346
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
1143
1347
|
listPage = Math.max(0, Math.min(maxPage, listPage));
|
|
1144
1348
|
const start = listPage * available;
|
|
1145
|
-
if (!list.length && !loading) add(state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1349
|
+
if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1146
1350
|
// §E: a wide frame puts the shop's detail beside the goods; a narrow one
|
|
1147
1351
|
// expands it below the list instead.
|
|
1148
1352
|
const pane = state && state.phase === 'shop' && !modal && list[index]?.shop
|
|
@@ -1179,15 +1383,14 @@ function mapHeader(width, view) {
|
|
|
1179
1383
|
}
|
|
1180
1384
|
}
|
|
1181
1385
|
if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
|
|
1182
|
-
while (frame.length < rows -
|
|
1386
|
+
while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
|
|
1183
1387
|
if (logLines) {
|
|
1184
1388
|
add('战斗日志', 'muted');
|
|
1185
1389
|
const recent = logItems().slice(-logLines);
|
|
1186
1390
|
for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
|
|
1187
1391
|
}
|
|
1188
1392
|
add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
|
|
1189
|
-
|
|
1190
|
-
add('Esc 返回 / 暂停退出 · ? 说明 · R 重试 · B 房间 · D 丢弃药水', 'muted');
|
|
1393
|
+
if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
|
|
1191
1394
|
const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
|
|
1192
1395
|
return frame.slice(0, rows);
|
|
1193
1396
|
},
|
|
@@ -1210,6 +1413,11 @@ function mapHeader(width, view) {
|
|
|
1210
1413
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1211
1414
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1212
1415
|
}
|
|
1416
|
+
if(value==='m' && state?.mapView?.nodes?.length && !modal && !targeting && !state.paused && !needsCharacterChoice() && !state.pendingChoice && !state.pendingDecision){
|
|
1417
|
+
if(mapNavigation()){mapOpen=false;tab=mapReturnTab;}
|
|
1418
|
+
else{mapReturnTab=tab;tab=0;mapOpen=true;}
|
|
1419
|
+
changed();return;
|
|
1420
|
+
}
|
|
1213
1421
|
if (mapNavigation()) {
|
|
1214
1422
|
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1215
1423
|
if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
|
|
@@ -1231,11 +1439,29 @@ function mapHeader(width, view) {
|
|
|
1231
1439
|
return;
|
|
1232
1440
|
}
|
|
1233
1441
|
if(['return','enter'].includes(name)){
|
|
1234
|
-
const row=
|
|
1442
|
+
const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
|
|
1235
1443
|
if(row)void submit({...row.action,phaseId:state.phaseId});
|
|
1236
1444
|
return;
|
|
1237
1445
|
}
|
|
1238
1446
|
}
|
|
1447
|
+
if(mapNavigation() && ['a','s','d','e',' '].includes(value))return;
|
|
1448
|
+
if(!modal && !targeting && !pileView && tab===0 && state?.phase==='combat' && ['left','right'].includes(name)){enemyPage=Math.max(0,enemyPage+(name==='right'?1:-1));changed();return;}
|
|
1449
|
+
if(!modal && !targeting && state?.phase==='combat' && ['a','s','d'].includes(value) && !(value==='d' && activeTab()==='potions')){openPile({a:'draw',s:'exhaust',d:'discard'}[value]);return;}
|
|
1450
|
+
if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
|
|
1451
|
+
// The board takes the arrows before the option list does, or moving on
|
|
1452
|
+
// the grid would scroll the rows underneath it.
|
|
1453
|
+
if (columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
|
|
1454
|
+
if (name === 'up') { sphereMove(0, -1); return; }
|
|
1455
|
+
if (name === 'down') { sphereMove(0, 1); return; }
|
|
1456
|
+
if (name === 'left') { sphereMove(-1, 0); return; }
|
|
1457
|
+
if (name === 'right') { sphereMove(1, 0); return; }
|
|
1458
|
+
if (value === 'h') { sphereMove(-1, 0); return; }
|
|
1459
|
+
if (value === 'l') { sphereMove(1, 0); return; }
|
|
1460
|
+
if (value === 'k') { sphereMove(0, -1); return; }
|
|
1461
|
+
if (value === 'j') { sphereMove(0, 1); return; }
|
|
1462
|
+
if (value === ' ' || name === 'space' || name === 'return' || name === 'enter') { sphereUse(); return; }
|
|
1463
|
+
if (value === 't') { sphereTool(); return; }
|
|
1464
|
+
}
|
|
1239
1465
|
if (value === ' ' && !modal && !targeting) {
|
|
1240
1466
|
const row = items()[index];
|
|
1241
1467
|
if (row?.pick) {
|
|
@@ -1255,7 +1481,7 @@ function mapHeader(width, view) {
|
|
|
1255
1481
|
return;
|
|
1256
1482
|
}
|
|
1257
1483
|
if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
|
|
1258
|
-
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; modal = undefined; anchor(); changed(); }
|
|
1484
|
+
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal = undefined; anchor(); changed(); }
|
|
1259
1485
|
else ctx.closeScreen(); return;
|
|
1260
1486
|
}
|
|
1261
1487
|
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
@@ -1273,14 +1499,37 @@ function mapHeader(width, view) {
|
|
|
1273
1499
|
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
1500
|
}
|
|
1275
1501
|
if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
|
|
1276
|
-
if (value === 's' && !modal && state) { switchTab(tabs.indexOf('status')); return; }
|
|
1277
1502
|
// The key and the button share one entry point.
|
|
1278
1503
|
if (value === 'e' && !modal && !targeting) { endTurn(); return; }
|
|
1504
|
+
if (value === 'p' && !modal && !targeting && state) {
|
|
1505
|
+
const others = (state.players ?? []).filter(player => player.id !== ctx.user.id);
|
|
1506
|
+
if (!others.length) { note = '没有队友可查看。'; changed(); return; }
|
|
1507
|
+
if (others.length === 1) { void inspectPlayer(others[0].id); return; }
|
|
1508
|
+
choose('查看队友', others.map(player => ({ id: String(player.id), player: player.id, label: `${player.name} · ${player.characterName ?? ''}` })),
|
|
1509
|
+
row => { void inspectPlayer(row.player ?? Number(row.id)); });
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (value === 'u' && !modal && !targeting && state) {
|
|
1513
|
+
const row=items()[index];
|
|
1514
|
+
const cardId=row?.card?.id ?? row?.shop?.cardId ??
|
|
1515
|
+
(['reward','customReward'].includes(row?.action?.type) && !['skip','reroll','sacrifice'].includes(row.action.card) ? row.action.card : undefined);
|
|
1516
|
+
if (cardId) {
|
|
1517
|
+
const source=row?.shop ? 'shop' : ['reward','customReward'].includes(row?.action?.type) ? 'reward' : ['deck','draw','discard','exhaust'].includes(activeTab()) ? activeTab() : 'hand';
|
|
1518
|
+
void previewUpgrade(cardId,source);
|
|
1519
|
+
}
|
|
1520
|
+
else { note='当前项目没有可预览的升级。';changed(); }
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1279
1523
|
if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
|
|
1280
1524
|
if (value === 'd' && !modal && activeTab() === 'potions' && canAct()) {
|
|
1281
1525
|
if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
|
|
1282
1526
|
const row = items()[index]; if (row?.potion) choose('丢弃这瓶药水?', [{ id: 'no', label: '保留' }, { id: 'yes', label: '丢弃' }], answer => { if (answer.id === 'yes') void submit({ type: 'dropPotion', potion: row.id }); else changed(); }); return;
|
|
1283
1527
|
}
|
|
1528
|
+
if (modal?.inspection && ['pageup','pagedown'].includes(name)) {
|
|
1529
|
+
const wanted = modal.inspection.page + (name === 'pageup' ? -1 : 1);
|
|
1530
|
+
if (wanted < 0 || wanted >= modal.inspection.pages) return;
|
|
1531
|
+
void inspectPlayer(modal.inspection.id, wanted); return;
|
|
1532
|
+
}
|
|
1284
1533
|
if (modal?.detail && ['pageup','pagedown'].includes(name)) { pageDetail(name === 'pageup' ? -3 : 3); return; }
|
|
1285
1534
|
if (name === 'pageup' || name === 'pagedown') {
|
|
1286
1535
|
const pages = Math.max(1, Math.ceil(items().length / pageSize));
|
|
@@ -1320,11 +1569,14 @@ function mapHeader(width, view) {
|
|
|
1320
1569
|
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1321
1570
|
// room is only looked at, and parks the view there.
|
|
1322
1571
|
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
1323
|
-
const row =
|
|
1572
|
+
const row = state.phase==='map' && state.options.find(row => row.action?.type === 'vote' && row.action.node === hit.id);
|
|
1324
1573
|
if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
|
|
1325
1574
|
changed();
|
|
1326
1575
|
return;
|
|
1327
1576
|
}
|
|
1577
|
+
if(hit?.kind==='enemyPage' && !targeting && !modal){enemyPage=(enemyPage+1)%hit.pages;changed();return;}
|
|
1578
|
+
if (hit?.kind === 'inspect') {void inspectPlayer(Number(hit.id));return;}
|
|
1579
|
+
if (hit?.kind === 'pile') {openPile(hit.id);return;}
|
|
1328
1580
|
if (hit?.kind === 'shopBuy') {
|
|
1329
1581
|
const row = items()[index];
|
|
1330
1582
|
const price = row?.shop?.price ?? 0;
|
|
@@ -1376,4 +1628,4 @@ export async function run(context) {
|
|
|
1376
1628
|
|
|
1377
1629
|
|
|
1378
1630
|
// Exported so a test can read a drawn act back the way a player does.
|
|
1379
|
-
export { drawMap };
|
|
1631
|
+
export { drawMap };
|