linkchat-extension-spire 0.3.0 → 0.4.2
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 +19 -9
- package/index.js +321 -251
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.
|
|
1
|
+
# Spire for LinkChat — 0.4.2
|
|
2
2
|
|
|
3
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.4.2.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
|
|
6
6
|
|
|
7
7
|
## 操作
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**战斗**:我方在左、敌人在右(只保留左右分组和上下排列两种布局;至少 68×24 时按 G 切换,窗口不足时自动上下排列,放大后恢复所选布局);每个信息块自上而下是意图、名称、格挡与血条、生命、Buff、DeBuff,多个意图字段会换行而不是被截断。手牌竖排,选中项显示箭头,不显示列表序号;方向键选择、Enter 执行;方向键跨页时列表自动跟随焦点。能量右侧是固定的 `[E 结束回合 X]`(X 为当前回合数) 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
|
|
10
10
|
|
|
11
11
|
**选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
|
|
12
12
|
|
|
13
|
-
**地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓`
|
|
13
|
+
**地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,方向键在地图边缘继续移动可进入装备或顶部入口。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
|
|
14
14
|
|
|
15
15
|
**商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
|
|
16
16
|
|
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
|
|
19
19
|
**宝箱**:单人时点选遗物即为一次明确的领取,客户端会在服务端确认后接着领取同一件遗物(按 reward ID 精确匹配,结果不明时不会猜);多人保留投票、分配、领取三个阶段,投票按钮本身就是确认。
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
**装备与详情**:顶部常驻本人遗物、药水实际槽位(含空槽)及“牌组 N · D”。遗物显示计数;过长的栏可横向翻页。悬停或方向键选择在底部显示完整说明,移出恢复键盘焦点;`?` 查看全文。药水点击或 Enter 打开“使用/丢弃”,丢弃需确认;自动药水及事件锁定状态会明确标注。火堆升级列表悬停或键盘选中即可查看升级前后,确认才执行升级。
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
**快捷键**:`D` 总牌组、`A` 抽牌堆、`S` 弃牌堆、`X` 消耗堆、`M` 地图、`E` 结束回合、`V` 完整战斗日志、`G` 切换战斗布局。地图仍用 `C` 定位当前位置。方向键按画面位置在主操作、单位、装备、牌堆和顶部入口间移动;Enter 确认,Esc 返回主操作或关闭弹窗。Tab、PageUp、PageDown 已移除。牌堆查看、日志与弹窗用 Esc 返回并恢复位置。`D` 不再丢弃药水。请求结果不明时 `R` 重试原操作。
|
|
24
|
+
|
|
25
|
+
**战斗日志**:每场独立保存完整记录,战后结算保留至下一战;常规快照仅带最近 20 条。默认隐藏底部日志预览;`V` 或点击顶部“战斗日志”打开按回合分组的完整记录,最新记录在最上方,打开从顶部开始,↑↓ / 滚轮滚动,在记录首尾继续按 ↑↓ 自动读取相邻页,← 较新 / → 较旧读取前后页(每页 100 条)。抽牌等私有信息仅本人可见,不显示洗牌顺序;旧局早期日志可能不完整。敌人意图按原作声明顺序显示剑、盾、强化、减益等符号,攻击显示对本人伤害与次数,隐藏底层执行参数。
|
|
26
|
+
|
|
27
|
+
列表与地图节点前均不显示数字定位标号。
|
|
28
|
+
|
|
29
|
+
**鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部牌组或日志入口打开查看;遗物悬停查看,药水点击打开操作菜单;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
|
|
24
30
|
|
|
25
31
|
## 内容
|
|
26
32
|
|
|
@@ -32,10 +38,14 @@
|
|
|
32
38
|
|
|
33
39
|
扩展只提供游戏界面,存档在服务器,卸载扩展不会删除存档。正式客户端 0.6.3 不包含扩展功能。此版本为正式版。
|
|
34
40
|
|
|
35
|
-
## 协议
|
|
41
|
+
## 协议 6 配套更新
|
|
36
42
|
|
|
37
|
-
本版必须与协议
|
|
43
|
+
本版必须与协议 6 的 Spire 后端一起更新。协议 1–4 会明确提示版本不匹配并阻止游戏操作。聊天 CLI 主程序无需重新发布。
|
|
38
44
|
|
|
39
45
|
升级预览按当前牌堆读取卡牌,保留战斗中的临时费用;队友面板关闭后恢复原选择。水晶球支持方向键、Enter、空格和鼠标操作。
|
|
40
46
|
|
|
41
|
-
进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.
|
|
47
|
+
进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.4.2 使用游戏协议 6,需要配套后端。
|
|
48
|
+
|
|
49
|
+
悬停详情靠窗口底部对齐,长说明最多预览三行,`?` 查看全文。
|
|
50
|
+
|
|
51
|
+
单位格挡值以盾牌显示(例如 `⛨ 12`),我方、敌人及详情使用相同格式。
|
package/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
const PROTOCOL =
|
|
2
|
+
const PROTOCOL = 6;
|
|
3
3
|
const VERSION_ERROR = 'Spire 客户端与服务器版本不匹配,请配套升级。';
|
|
4
4
|
const BASE = '/api/games/spire/rooms';
|
|
5
5
|
const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
|
|
6
6
|
export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
|
|
7
|
-
const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
|
|
8
7
|
const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
|
|
9
8
|
const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
|
|
10
9
|
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:'虚无免费'});
|
|
@@ -117,9 +116,6 @@ function place(cells, width) {
|
|
|
117
116
|
}
|
|
118
117
|
return { text: cut(out, width), placed };
|
|
119
118
|
}
|
|
120
|
-
function mapRouteIndex(id, options) {
|
|
121
|
-
return (options ?? []).findIndex(row => row.action?.type === 'vote' && row.action.node === id);
|
|
122
|
-
}
|
|
123
119
|
// One glyph per set of directions, so a corner always joins the lines that meet.
|
|
124
120
|
const JOIN = {
|
|
125
121
|
u: '│', d: '│', l: '─', r: '─', ud: '│', ul: '┘', ur: '└', dl: '┐', dr: '┌', lr: '─',
|
|
@@ -132,8 +128,7 @@ const joinGlyph = directions => {
|
|
|
132
128
|
};
|
|
133
129
|
const mapCode = node => `${String(node.row).padStart(2, '0')}-${String.fromCharCode(65 + node.col)}`;
|
|
134
130
|
function mapLabel(view, data, node, options) {
|
|
135
|
-
|
|
136
|
-
return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}${route >= 0 ? route + 1 : ''}[${node.type}]`;
|
|
131
|
+
return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}[${node.type}]`;
|
|
137
132
|
}
|
|
138
133
|
function mapPaint(view, data, node, label, focus) {
|
|
139
134
|
const child = data.byId.get(focus)?.children?.includes(node.id);
|
|
@@ -313,9 +308,72 @@ export function createScreen(ctx) {
|
|
|
313
308
|
const me = () => state?.players.find(p => p.id === ctx.user.id);
|
|
314
309
|
let characterConfirmed=false, mapOpen, mapReturnTab=0;
|
|
315
310
|
const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
|
|
316
|
-
let pileView;
|
|
317
|
-
const
|
|
318
|
-
|
|
311
|
+
let pileView, pileReturn, focusRegion='action', focusIndex=0, hovered, focusControl;
|
|
312
|
+
const equipmentOffset={relics:0,potions:0};
|
|
313
|
+
const activeTab = () => pileView ?? 'action';
|
|
314
|
+
function closePile() {
|
|
315
|
+
pileView=undefined;
|
|
316
|
+
if(pileReturn){({index,selected,listPage,focusRegion,focusIndex,mapOpen}=pileReturn);pileReturn=undefined;}
|
|
317
|
+
changed();
|
|
318
|
+
}
|
|
319
|
+
function openPile(key) {
|
|
320
|
+
if(!state || modal || (key!=='deck' && state.phase!=='combat'))return;
|
|
321
|
+
if(!pileView)pileReturn={index,selected,listPage,focusRegion,focusIndex,mapOpen};
|
|
322
|
+
pileView=key;mapOpen=false;focusRegion='action';hovered=undefined;index=0;selected=undefined;listPage=0;changed();
|
|
323
|
+
}
|
|
324
|
+
const regionItems = region => !state ? [] : region==='relics' ? state.self.relics.map(r=>({...r,id:r.id??r.key})) :
|
|
325
|
+
region==='potions' ? Array.from({length:state.self.potionCapacity??3},(_,i)=>state.self.potions[i]??{id:`empty:${i}`,name:'空槽',description:'没有药水',empty:true}) :
|
|
326
|
+
region==='units' ? [...state.players,...state.enemies.filter(e=>e.hp>0||e.awaitingRevive)] :
|
|
327
|
+
region==='entries' ? [{id:'deck',name:`牌组 ${state.self.deck.length} · D`,description:'查看永久总牌组 · U 升级预览'},{id:'log',name:'战斗日志 · V',description:'查看当前战斗完整记录'}] : [];
|
|
328
|
+
function focusItem(){return regionItems(focusRegion)[focusIndex];}
|
|
329
|
+
function detailText(){
|
|
330
|
+
const row=hovered?.row ?? (focusRegion==='action' ? items()[index] : focusItem());
|
|
331
|
+
if(!row)return '方向键移动焦点 · Enter 操作 · Esc 返回 · ? 完整说明';
|
|
332
|
+
if(row.hp!==undefined)return `${row.name} · ${hpText(row)} HP · ⛨ ${row.block??0} · ${row.intent?intentLines(row.intent,row,state).join(' · '):resourcesText(row)} · ${powersText(row.powers,row.monologueInstances)}`;
|
|
333
|
+
return [row.name??row.card?.name,row.description??row.card?.description??row.label,
|
|
334
|
+
row.counter!==undefined?`当前计数 ${row.counter}`:'',row.usage==='Automatic'?'自动触发,不能主动使用':'',
|
|
335
|
+
(hovered?.region??focusRegion)==='potions' && state?.self.potionsLocked?'事件锁定:不能使用或丢弃':''].filter(Boolean).join(' · ');
|
|
336
|
+
}
|
|
337
|
+
function potionMenu(potion){
|
|
338
|
+
if(!potion || potion.empty)return;
|
|
339
|
+
const id=potion.id;
|
|
340
|
+
choose(`${potion.name} · 药水操作`,[{id:'use',label:'使用'},{id:'drop',label:'丢弃'},{id:'back',label:'返回'}],answer=>{
|
|
341
|
+
const current=state.self.potions.find(p=>p.id===id);
|
|
342
|
+
if(!current){note='这瓶药水已不在栏中。';changed();return;}
|
|
343
|
+
if(answer.id==='back'){changed();return;}
|
|
344
|
+
if(state.self.potionsLocked){note='事件锁定:不能使用或丢弃药水。';changed();return;}
|
|
345
|
+
if(!canAct())return;
|
|
346
|
+
if(answer.id==='drop'){
|
|
347
|
+
choose('丢弃这瓶药水?',[{id:'no',label:'保留'},{id:'yes',label:'丢弃'}],row=>{if(row.id==='yes')void submit({type:'dropPotion',potion:id});else changed();});return;
|
|
348
|
+
}
|
|
349
|
+
if(current.usage==='Automatic'){note='这瓶药水会自动触发,不能主动使用。';changed();return;}
|
|
350
|
+
if(state.paused || (state.phase==='combat'?me()?.done:current.usage!=='AnyTime')){note='当前不能使用这瓶药水。';changed();return;}
|
|
351
|
+
const action={type:'potion',potion:id,phaseId:state.phaseId,label:current.name};
|
|
352
|
+
if(current.target==='AnyEnemy'||current.key==='FirePotion')beginTargeting(action,'enemy','选择药水目标');
|
|
353
|
+
else if(current.target==='AnyPlayer'&&state.players.filter(p=>p.hp>0).length>1)beginTargeting(action,'ally','选择药水目标');
|
|
354
|
+
else {delete action.label;void submit(action);}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async function openLog(cursor={},atEnd=false){
|
|
358
|
+
if(!state || !roomId)return;
|
|
359
|
+
const battle=state.battleLog?.battleId, originRoom=roomId;
|
|
360
|
+
if(!battle){note='当前没有战斗日志。';changed();return;}
|
|
361
|
+
const previous=modal?.battleLog ? {selected:modal.previousSelected,index:modal.previousIndex,page:modal.previousPage} : {selected,index,page:listPage};
|
|
362
|
+
await guard(async()=>{
|
|
363
|
+
const pageCursor=Object.keys(cursor).length?cursor:{before:String((state.battleLog?.last??Number.MAX_SAFE_INTEGER-1)+1)};
|
|
364
|
+
const query=new URLSearchParams({battleId:String(battle),limit:'100',...pageCursor});
|
|
365
|
+
const body=await ctx.request(`${BASE}/${originRoom}/logs?${query}`);
|
|
366
|
+
if(roomId!==originRoom || state.battleLog?.battleId!==battle)return;
|
|
367
|
+
const rows=[];let turn;
|
|
368
|
+
for(const entry of [...body.entries].reverse().filter(entry=>entry.kind!=='pile')){
|
|
369
|
+
if(entry.turn!==turn){turn=entry.turn;rows.push({id:`turn:${entry.id}`,label:`── 回合 ${turn} ──`,separator:true});}
|
|
370
|
+
rows.push({id:String(entry.id),label:`${entry.id}. ${entry.text}${entry.source?.name?` [${entry.source.name}]`:''}`});
|
|
371
|
+
}
|
|
372
|
+
choose(`战斗日志 · ${body.total} 条${body.incomplete?' · 早期记录可能不完整':''} · ← 较新 / → 较旧`,rows.length?rows:[{id:'empty',label:'暂无记录'}],()=>changed());
|
|
373
|
+
Object.assign(modal,{battleLog:body,previousSelected:previous.selected,previousIndex:previous.index,previousPage:previous.page});
|
|
374
|
+
if(atEnd)moveTo(rows.length-1);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
319
377
|
const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
|
|
320
378
|
// A phase this build cannot render stays read only, so nothing is submitted.
|
|
321
379
|
const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
|
|
@@ -326,10 +384,7 @@ export function createScreen(ctx) {
|
|
|
326
384
|
if (!state) return [];
|
|
327
385
|
if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
|
|
328
386
|
if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
|
|
329
|
-
|
|
330
|
-
if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
|
|
331
|
-
if (activeTab() === 'log') return logItems();
|
|
332
|
-
if (activeTab() === 'status') return [...state.players,...state.enemies.filter(e=>e.hp>0 || e.awaitingRevive)].map(p=>({id:String(p.id),label:`${p.name} ${hpText(p)} HP · 格挡 ${p.block} · ${powersText(p.powers, p.monologueInstances)}${resourcesText(p) ? ` · ${resourcesText(p)}` : ''}`}));
|
|
387
|
+
|
|
333
388
|
if (state.pendingChoice && !state.paused) return state.options.map(row => row.pick ? {...row,label:`${picked.has(row.pick) ? '[x]' : '[ ]'} ${row.label}`} : row.id === 'confirmChoice' ? {...row,label:`确认选择(${picked.size}/${state.pendingChoice.maximum})`} : row);
|
|
334
389
|
if (state.pendingDecision && !state.paused) {
|
|
335
390
|
const decision = state.pendingDecision;
|
|
@@ -403,11 +458,16 @@ export function createScreen(ctx) {
|
|
|
403
458
|
mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
|
|
404
459
|
}
|
|
405
460
|
if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
|
|
406
|
-
modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
|
|
461
|
+
modal = undefined; mapOpen=undefined; pileView=undefined;pileReturn=undefined;focusRegion='action';focusIndex=0;hovered=undefined; selected = undefined; index = 0; tab = 0;
|
|
407
462
|
rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
|
|
408
463
|
}
|
|
409
464
|
if (s.leaseId) { leaseId = s.leaseId; synced = true; }
|
|
465
|
+
const revisionChanged=state?.revision!==s.revision;
|
|
410
466
|
state = s; loading = false;
|
|
467
|
+
if(revisionChanged){hovered=undefined;upgradeHoverCache.clear();upgradeHoverToken++;}
|
|
468
|
+
focusIndex=Math.max(0,Math.min(focusIndex,regionItems(focusRegion).length-1));
|
|
469
|
+
if(!['action','controls'].includes(focusRegion)&&!regionItems(focusRegion).length)focusRegion='action';
|
|
470
|
+
if(modal?.upgradePicker && revisionChanged)void hoverUpgrade();
|
|
411
471
|
if (modal?.validFor && !modal.validFor(s)) { modal=undefined; selected=undefined; index=0; note='奖励已失效,请重新选择。'; }
|
|
412
472
|
if (!pending && (note.startsWith('已连接') || note.startsWith('连接中断'))) note = '';
|
|
413
473
|
// The chained claim waits for the pick to be confirmed, then submits the exact
|
|
@@ -467,13 +527,26 @@ export function createScreen(ctx) {
|
|
|
467
527
|
function choose(title, candidates, acceptChoice) {
|
|
468
528
|
modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index, previousPage: listPage }; listPage = 0; index = 0; selected = candidates[0]?.id; changed();
|
|
469
529
|
}
|
|
530
|
+
const upgradeHoverCache=new Map();let upgradeHoverToken=0;
|
|
531
|
+
async function hoverUpgrade(){
|
|
532
|
+
if(!modal?.upgradePicker)return;
|
|
533
|
+
const row=items()[index];if(!row?.card)return;
|
|
534
|
+
const key=`${roomId}:${state.revision}:${row.id}`, token=++upgradeHoverToken, currentModal=modal;
|
|
535
|
+
const show=body=>{if(modal!==currentModal || items()[index]?.id!==row.id || token!==upgradeHoverToken)return;
|
|
536
|
+
modal.upgradeDetail=`升级前:${body.before.name} [${body.before.cost}] ${body.before.description}\n升级后:${body.after.name} [${body.after.cost}] ${body.after.description}`;changed();};
|
|
537
|
+
modal.upgradeDetail='正在读取升级预览…';changed();
|
|
538
|
+
if(upgradeHoverCache.has(key)){show(upgradeHoverCache.get(key));return;}
|
|
539
|
+
try{const body=await ctx.request(`${BASE}/${roomId}/previews/upgrade`,'POST',{cardId:row.id,source:'deck'});upgradeHoverCache.set(key,body);show(body);}
|
|
540
|
+
catch(error){if(modal===currentModal && token===upgradeHoverToken){modal.upgradeDetail=errorText(error);changed();}}
|
|
541
|
+
}
|
|
470
542
|
function deckChoice(mode, callback) {
|
|
471
543
|
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);
|
|
472
544
|
if (!cards.length) { note = '没有符合条件的牌。'; changed(); return; }
|
|
473
545
|
choose('选择牌组中的牌', cards.map(cardItem), row => {
|
|
474
|
-
if (mode === 'deckUpgrade') {
|
|
546
|
+
if (mode === 'deckUpgrade') { callback(row.id); return; }
|
|
475
547
|
callback(row.id);
|
|
476
548
|
});
|
|
549
|
+
if(mode==='deckUpgrade'){modal.upgradePicker=true;void hoverUpgrade();}
|
|
477
550
|
}
|
|
478
551
|
async function previewUpgrade(cardId, source, confirmUpgrade) {
|
|
479
552
|
if (!roomId || !canAct() || !cardId) return;
|
|
@@ -512,10 +585,88 @@ export function createScreen(ctx) {
|
|
|
512
585
|
}
|
|
513
586
|
// The keyboard branches and clicks move the same state through these, so the
|
|
514
587
|
// two input paths cannot drift apart.
|
|
515
|
-
|
|
516
|
-
function
|
|
588
|
+
const navKinds=new Set(['item','equipment','entry','entity','pile','endTurn','inspect','node','mapCenter','mapFull']);
|
|
589
|
+
function focusKey(hit){return `${hit.kind}:${hit.region??''}:${hit.id}`;}
|
|
590
|
+
function currentHit(){
|
|
591
|
+
if(focusRegion==='action')return hits.find(h=>mapNavigation()?h.kind==='node'&&h.id===(mapFocus??state.mapView.current):h.kind==='item'&&h.id===items()[index]?.id);
|
|
592
|
+
if(focusRegion==='units')return hits.find(h=>h.kind==='entity'&&String(h.id)===String(focusItem()?.id));
|
|
593
|
+
if(focusRegion==='entries')return hits.find(h=>h.kind==='entry'&&h.id===focusItem()?.id);
|
|
594
|
+
if(focusRegion==='controls')return hits.find(h=>focusKey(h)===focusControl);
|
|
595
|
+
return hits.find(h=>h.kind==='equipment'&&h.region===focusRegion&&h.index===focusIndex);
|
|
596
|
+
}
|
|
597
|
+
function selectHit(hit){
|
|
598
|
+
hovered=undefined;
|
|
599
|
+
if(hit.kind==='item'){focusRegion='action';moveTo(items().findIndex(row=>row.id===hit.id));}
|
|
600
|
+
else if(hit.kind==='node'){focusRegion='action';mapFocus=hit.id;mapAnchor=hit.id;mapScroll=undefined;}
|
|
601
|
+
else if(hit.kind==='equipment'){focusRegion=hit.region;focusIndex=hit.index;}
|
|
602
|
+
else if(hit.kind==='entity'){focusRegion='units';focusIndex=regionItems('units').findIndex(row=>String(row.id)===String(hit.id));}
|
|
603
|
+
else if(hit.kind==='entry'){focusRegion='entries';focusIndex=regionItems('entries').findIndex(row=>row.id===hit.id);}
|
|
604
|
+
else{focusRegion='controls';focusControl=focusKey(hit);}
|
|
605
|
+
changed();
|
|
606
|
+
}
|
|
607
|
+
function spatialMove(direction,skipLocal=false){
|
|
608
|
+
hovered=undefined;
|
|
609
|
+
screen.render(columns,height);
|
|
610
|
+
if(!skipLocal && focusRegion==='action' && !mapNavigation() && ['up','down'].includes(direction)){
|
|
611
|
+
const next=index+(direction==='up'?-1:1);
|
|
612
|
+
if(next>=0&&next<items().length){moveTo(next);return;}
|
|
613
|
+
}
|
|
614
|
+
if(['relics','potions'].includes(focusRegion)&&['left','right'].includes(direction)){
|
|
615
|
+
const next=focusIndex+(direction==='left'?-1:1);
|
|
616
|
+
if(next>=0&&next<regionItems(focusRegion).length){focusIndex=next;changed();return;}
|
|
617
|
+
}
|
|
618
|
+
if(focusRegion==='units'){
|
|
619
|
+
const enemies=state.enemies.filter(e=>e.hp>0||e.awaitingRevive),at=enemies.findIndex(e=>String(e.id)===String(focusItem()?.id));
|
|
620
|
+
const vertical=true;
|
|
621
|
+
if(at>=0 && (vertical?['up','down']:['left','right']).includes(direction)){
|
|
622
|
+
const next=at+(['up','left'].includes(direction)?-1:1);
|
|
623
|
+
if(next>=0&&next<enemies.length){focusIndex=regionItems('units').findIndex(row=>String(row.id)===String(enemies[next].id));changed();return;}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const current=currentHit()??hits.find(h=>navKinds.has(h.kind));
|
|
627
|
+
if(!current)return;
|
|
628
|
+
const point=h=>({x:h.col+Math.min(Number.isFinite(h.width)?h.width:columns,24)/2,y:h.row+(h.height-1)/2});
|
|
629
|
+
const origin=point(current),vertical=['up','down'].includes(direction),sign=['up','left'].includes(direction)?-1:1;
|
|
630
|
+
let candidates=hits.filter(h=>navKinds.has(h.kind)&&h!==current&&!(h.kind==='item'&&items().find(r=>r.id===h.id)?.separator));
|
|
631
|
+
if(focusRegion==='action' && (skipLocal||['up','down'].includes(direction)))candidates=candidates.filter(h=>h.kind!==(mapNavigation()?'node':'item'));
|
|
632
|
+
const score=h=>{const p=point(h),forward=sign*(vertical?p.y-origin.y:p.x-origin.x),cross=Math.abs(vertical?p.x-origin.x:p.y-origin.y);return {h,forward,cross,score:forward+cross*(vertical?1:100)};};
|
|
633
|
+
const choices=candidates.map(score).filter(p=>p.forward>0).sort((a,b)=>a.score-b.score);
|
|
634
|
+
if(choices.length){selectHit(choices[0].h);return;}
|
|
635
|
+
// A boundary wraps to the opposite edge, keeping every region reachable.
|
|
636
|
+
const wrap=candidates.map(score).sort((a,b)=>a.forward-b.forward||a.cross-b.cross);
|
|
637
|
+
if(wrap.length)selectHit(wrap[0].h);
|
|
638
|
+
}
|
|
639
|
+
function showFocusedDetail(){
|
|
640
|
+
const text=detailText(),title=focusItem()?.name??'详情';
|
|
641
|
+
choose(title,wrapText(text,Math.max(1,columns-4)).map((label,i)=>({id:String(i),label})),()=>changed());
|
|
642
|
+
}
|
|
643
|
+
function activateFocus(){
|
|
644
|
+
if(focusRegion==='action'){confirm();return;}
|
|
645
|
+
const row=focusItem(),hit=currentHit();
|
|
646
|
+
if(focusRegion==='potions')potionMenu(row);
|
|
647
|
+
else if(focusRegion==='entries'){if(row?.id==='deck')openPile('deck');else void openLog();}
|
|
648
|
+
else if(focusRegion==='units'||focusRegion==='relics')showFocusedDetail();
|
|
649
|
+
else if(hit?.kind==='pile')openPile(hit.id);
|
|
650
|
+
else if(hit?.kind==='endTurn')endTurn();
|
|
651
|
+
else if(hit?.kind==='inspect')void inspectPlayer(Number(hit.id));
|
|
652
|
+
else if(hit?.kind==='mapCenter'){mapScroll=undefined;mapAnchor=undefined;mapFocus=undefined;mapFull=false;changed();}
|
|
653
|
+
else if(hit?.kind==='mapFull'){mapFull=!mapFull;mapScroll=undefined;changed();}
|
|
654
|
+
}
|
|
655
|
+
function paintFocus(frame){
|
|
656
|
+
if(modal||targeting||!state)return;
|
|
657
|
+
const hit=currentHit();if(!hit||!frame[hit.row])return;
|
|
658
|
+
if(focusRegion==='action')return;
|
|
659
|
+
const text=frame[hit.row].text;let col=0,start=0,end=text.length;
|
|
660
|
+
for(const char of text){const next=col+displayWidth(char);if(next<=hit.col)start+=char.length;if(next>hit.col+Math.min(hit.width,columns)){end=start;break;}col=next;}
|
|
661
|
+
// Highlight only the focused control, not its neighbours on the same row.
|
|
662
|
+
let offset=0;col=0;end=text.length;
|
|
663
|
+
for(const char of text){if(col>=hit.col+Math.min(hit.width,columns)){end=offset;break;}col+=displayWidth(char);offset+=char.length;}
|
|
664
|
+
frame[hit.row].text=text.slice(0,start)+'\x1b[7m'+text.slice(start,end)+'\x1b[27m'+text.slice(end);
|
|
665
|
+
}
|
|
666
|
+
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();if(modal?.upgradePicker)void hoverUpgrade(); }
|
|
517
667
|
function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
|
|
518
668
|
function clickRow(id) {
|
|
669
|
+
focusRegion='action';hovered=undefined;
|
|
519
670
|
const list = items(), at = list.findIndex(row => row.id === id);
|
|
520
671
|
// A pushed state can replace the list under a click: re-anchor and never let
|
|
521
672
|
// the cursor clamp confirm a row the player never saw.
|
|
@@ -524,71 +675,23 @@ export function createScreen(ctx) {
|
|
|
524
675
|
confirm();
|
|
525
676
|
}
|
|
526
677
|
function intentLines(v, enemy, state) {
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (v.vigor) intents.push(`下一次攻击伤害 +${v.vigor}`);
|
|
532
|
-
if (v.vitalSpark) intents.push(`污染强度 +${v.vitalSpark}`);
|
|
533
|
-
if (v.ritual) intents.push(`仪式 +${v.ritual}(从下次行动结束起获得力量)`);
|
|
534
|
-
if (v.block) intents.push(`格挡 +${v.block}`);
|
|
535
|
-
if (v.moveBlock) intents.push(`格挡基础值 +${v.moveBlock}`);
|
|
536
|
-
if (v.dexterity) intents.push(`敏捷 +${v.dexterity}`);
|
|
537
|
-
if (v.status) {
|
|
538
|
-
const pile = v.statusPile === 'hand' ? '手中' : v.statusPile === 'draw' ? '抽牌堆' : '弃牌堆';
|
|
539
|
-
intents.push(`加入 ${v.count + (v.statusExtra ?? 0)} 张状态牌(${pile})`);
|
|
540
|
-
} else if (v.statusExtra) intents.push(`额外加入 ${v.statusExtra} 张状态牌`);
|
|
541
|
-
for (const [key,amount] of Object.entries(v.debuffs ?? {})) intents.push(`${powerNames[key] ?? key} ${amount < 0 ? '持续至施加者死亡' : amount}`);
|
|
542
|
-
if (v.knowledge) intents.push('知识诅咒:每人选择一种代价');
|
|
543
|
-
if (v.healPerPlayer) intents.push(`恢复 ${v.healPerPlayer * state.players.length} HP`);
|
|
544
|
-
if (v.stunned) intents.push('眩晕:跳过本次行动');
|
|
545
|
-
if (v.sleep) intents.push('沉睡');
|
|
546
|
-
if (v.revive) intents.push('复活');
|
|
547
|
-
if (v.reviveIllusion) intents.push('幻象恢复全部生命');
|
|
548
|
-
if (v.summonEnemy) intents.push(`召唤 ${v.summonEnemy}`);
|
|
549
|
-
if (v.bloat) intents.push('召唤一枚气体炸弹');
|
|
550
|
-
if (v.ratBackup) intents.push('召唤一只双尾鼠');
|
|
551
|
-
if (v.stealGold) intents.push(`窃取每人至多 ${v.stealGold} 金币`);
|
|
552
|
-
if (v.wakeGremlin) intents.push('起身');
|
|
553
|
-
if (v.escapeEnemy) intents.push('逃跑');
|
|
554
|
-
if (v.soar) intents.push('飞翔:攻击承伤减半');
|
|
555
|
-
if (v.land) intents.push('落地:结束飞翔');
|
|
556
|
-
if (v.fabricate) intents.push(v.fabricate === 2 ? '制造防御与攻击机器人各一台' : '制造一台攻击机器人');
|
|
557
|
-
if (v.guardFabricator) intents.push(`制造者格挡 +${v.guardFabricator}`);
|
|
558
|
-
if (v.layEggs) intents.push(`产下至多 ${v.layEggs} 枚蛋`);
|
|
559
|
-
if (v.hatchEgg) intents.push('孵化:清除仆从以外状态并恢复幼体生命');
|
|
560
|
-
if (v.stealCard) intents.push('窃取抽牌堆或弃牌堆的一张原始牌');
|
|
561
|
-
if (v.flutter) intents.push(`振翅 ${v.flutter}(按人数缩放)`);
|
|
562
|
-
if (enemy.stolenCards?.length) intents.push(`窃走你的牌:${enemy.stolenCards.map(c => c.name).join('、')}`);
|
|
563
|
-
if (v.teamStrength) intents.push(`全体敌人力量 +${v.teamStrength}`);
|
|
564
|
-
if (v.reattach) intents.push('重连:其他体节存活则恢复生命');
|
|
565
|
-
if (v.liquify) intents.push('流沙倒计时 4;每人抽牌堆、弃牌堆各加入 3 张拼命逃脱');
|
|
566
|
-
if (v.hiveSpit) intents.push('强化蜂巢与力量;蜂巢达到 3 层后改为力量 +2');
|
|
567
|
-
if (v.thornsBefore) intents.push(`反伤 ${signed(v.thornsBefore)}`);
|
|
568
|
-
if (v.thornsAfter) intents.push(`反伤 ${signed(v.thornsAfter)}`);
|
|
569
|
-
if (v.plow) intents.push(`冲锋:生命降至 ${v.plow} 后转阶段`);
|
|
570
|
-
if (v.selfKill) intents.push('自爆:行动后自身死亡');
|
|
571
|
-
if (v.intangible) intents.push(`无实体 ${v.intangible}`);
|
|
572
|
-
if (v.beckon) intents.push('塞入 2 张「召唤」(抽牌堆与弃牌堆各 1 张)');
|
|
573
|
-
if (v.burrowed) intents.push(`钻地 ${v.burrowed}:保留格挡,破盾后眩晕`);
|
|
574
|
-
if (v.allyStrength) intents.push(`友军力量 +${v.allyStrength}`);
|
|
575
|
-
if (v.steam) intents.push(`蒸汽压力 +${v.steam}`);
|
|
576
|
-
for (const [key, value] of Object.entries(v)) {
|
|
577
|
-
if (renderedIntentKeys.includes(key) || key in internalIntentKeys) continue;
|
|
578
|
-
// A field the rules just added is shown as the raw field, never dropped.
|
|
579
|
-
intents.push(`原始字段 ${key}=${JSON.stringify(value)}`);
|
|
580
|
-
}
|
|
581
|
-
return intents;
|
|
678
|
+
const symbols={attack:'⚔',defend:'⛨',buff:'↑',debuff:'↓',status:'↓',heal:'♥',summon:'⊕',sleep:'zZ',stun:'…',escape:'↗',unknown:'?',deathBlow:'☠'};
|
|
679
|
+
const names={attack:'攻击',defend:'防御',buff:'Buff',debuff:'DeBuff',status:'状态牌',heal:'治疗',summon:'召唤',sleep:'沉睡',stun:'眩晕',escape:'逃跑',unknown:'未知',deathBlow:'致命攻击'};
|
|
680
|
+
const declared=enemy.intentDisplay ?? (v?.damage?[{type:'attack',damage:v.damageByTarget?.[ctx.user.id]??v.damage,hits:v.hits??1}]:[{type:v?.sleep?'sleep':'unknown'}]);
|
|
681
|
+
return declared.filter(part=>part.type!=='hidden').map(part=>`${symbols[part.type]??'?'} ${names[part.type]??'未知'}${part.damage!==undefined?` ${part.damage}${(part.hits??1)>1?`×${part.hits}`:''}`:''}${part.count!==undefined?` ${part.count}`:''}`);
|
|
582
682
|
}
|
|
683
|
+
|
|
583
684
|
function hoverRow(id) {
|
|
685
|
+
focusRegion='action';
|
|
584
686
|
const at = items().findIndex(row => row.id === id);
|
|
585
687
|
if (at >= 0) moveTo(at);
|
|
586
688
|
}
|
|
587
|
-
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; }); }
|
|
588
689
|
// --- battlefield ------------------------------------------------------------
|
|
589
690
|
const BAR_CELLS = 10;
|
|
590
|
-
|
|
591
|
-
const
|
|
691
|
+
let battleLayout='columns';
|
|
692
|
+
const canSwitchLayout=()=>columns>=68&&height>=24;
|
|
693
|
+
const sideBySide=()=>canSwitchLayout()&&battleLayout==='columns';
|
|
694
|
+
function toggleLayout(){if(!canSwitchLayout())return;battleLayout=battleLayout==='columns'?'stacked':'columns';changed();}
|
|
592
695
|
function healthBar(current, maximum) {
|
|
593
696
|
const ratio = maximum > 0 ? Math.max(0, Math.min(1, current / maximum)) : 0;
|
|
594
697
|
const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
|
|
@@ -637,27 +740,6 @@ export function createScreen(ctx) {
|
|
|
637
740
|
if (line) lines.push(line);
|
|
638
741
|
return lines.length ? lines : [''];
|
|
639
742
|
}
|
|
640
|
-
function statLines(creature, extras = [], width) {
|
|
641
|
-
const entries = powerEntries(creature);
|
|
642
|
-
const buffs = entries.filter(entry => !debuffPowers.has(entry.key)).map(entry => entry.text);
|
|
643
|
-
const debuffs = entries.filter(entry => debuffPowers.has(entry.key)).map(entry => entry.text);
|
|
644
|
-
const wrap = (value, prefix) => {
|
|
645
|
-
if (!width) return [prefix + value];
|
|
646
|
-
return wrapText(prefix + value, width).map((line, index) => index ? ` ${line}` : line);
|
|
647
|
-
};
|
|
648
|
-
return [`格挡 ${creature.block} ${healthBar(creature.hp, creature.maxHp)}`, `${hpText(creature)} HP`,
|
|
649
|
-
...wrap(buffs.join(' · ') || '无', '增益:'), ...wrap(debuffs.join(' · ') || '无', '减益:'),
|
|
650
|
-
...extras.flatMap(value => width ? wrapText(value, width) : [value])];
|
|
651
|
-
}
|
|
652
|
-
function enemyBlock(enemy, width) {
|
|
653
|
-
const intents = intentLines(enemy.intent, enemy, state);
|
|
654
|
-
const head = intents.length
|
|
655
|
-
? (width ? wrapText(intents.join(' · '), width) : [intents.join(' · ')])
|
|
656
|
-
: ['(本回合无行动)'];
|
|
657
|
-
return [...head, enemy.name, ...statLines(enemy, [], width)];
|
|
658
|
-
}
|
|
659
|
-
// Narrow screens stack the two sides as sections; one line per creature keeps
|
|
660
|
-
// every number and both power lists visible instead of shrinking blocks.
|
|
661
743
|
function compactLines(creature, arena, width, room = 1) {
|
|
662
744
|
const head = arena === 'ally'
|
|
663
745
|
? `${creature.id === ctx.user.id ? '› ' : ''}${creature.name}${creature.characterName ? ` · ${creature.characterName}` : ''}${creature.done ? ' · 已结束' : ''}`
|
|
@@ -667,8 +749,8 @@ export function createScreen(ctx) {
|
|
|
667
749
|
const buffs = entries.filter(entry => !debuffPowers.has(entry.key)).map(entry => entry.text);
|
|
668
750
|
const debuffs = entries.filter(entry => debuffPowers.has(entry.key)).map(entry => entry.text);
|
|
669
751
|
const resources = arena === 'ally' ? resourcesText(creature) : '';
|
|
670
|
-
const text = [head, `${hpText(creature)} HP ·
|
|
671
|
-
|
|
752
|
+
const text = [head, `${hpText(creature)} HP · ⛨ ${creature.block}`, intent,
|
|
753
|
+
`Buff:${buffs.join(' · ') || '无'}`, `DeBuff:${debuffs.join(' · ') || '无'}`, resources]
|
|
672
754
|
.filter(Boolean).join(' · ');
|
|
673
755
|
if (!width) return [text];
|
|
674
756
|
// Two lines at most: a tight frame keeps one cut row per creature so nobody
|
|
@@ -857,6 +939,7 @@ function mapHeader(width, view) {
|
|
|
857
939
|
}
|
|
858
940
|
function sphereMove(dx, dy) {
|
|
859
941
|
const view = sphereView(); if (!view) return;
|
|
942
|
+
if((dx<0&&sphereCursor.x===0)||(dx>0&&sphereCursor.x===view.width-1)||(dy<0&&sphereCursor.y===0)||(dy>0&&sphereCursor.y===view.height-1)){spatialMove(dx<0?'left':dx>0?'right':dy<0?'up':'down',true);return;}
|
|
860
943
|
sphereCursor = { x: Math.max(0, Math.min(view.width - 1, sphereCursor.x + dx)),
|
|
861
944
|
y: Math.max(0, Math.min(view.height - 1, sphereCursor.y + dy)) };
|
|
862
945
|
changed();
|
|
@@ -872,7 +955,7 @@ function mapHeader(width, view) {
|
|
|
872
955
|
// Teammate detail. Read only, and paged on the server: the client asks for a
|
|
873
956
|
// page and shows what comes back, so it never holds a second copy of anyone's
|
|
874
957
|
// deck and never has to keep one in step.
|
|
875
|
-
async function inspectPlayer(id, page = 0) {
|
|
958
|
+
async function inspectPlayer(id, page = 0, atEnd=false) {
|
|
876
959
|
if (!roomId || !canAct()) return;
|
|
877
960
|
await guard(async () => {
|
|
878
961
|
const body = await ctx.request(`${BASE}/${roomId}/players/${id}/inspection?page=${page}`);
|
|
@@ -886,6 +969,7 @@ function mapHeader(width, view) {
|
|
|
886
969
|
() => { anchor(); changed(); });
|
|
887
970
|
if(previous)Object.assign(modal,previous);
|
|
888
971
|
modal.inspection={id,page,pages,total:body.total};
|
|
972
|
+
if(atEnd)moveTo(modal.items.length-1);
|
|
889
973
|
changed();
|
|
890
974
|
});
|
|
891
975
|
}
|
|
@@ -903,18 +987,18 @@ function mapHeader(width, view) {
|
|
|
903
987
|
}
|
|
904
988
|
function endTurnLabel() {
|
|
905
989
|
const own = me();
|
|
906
|
-
if ((own?.hp ?? 0) <= 0) return
|
|
990
|
+
if ((own?.hp ?? 0) <= 0) return `[已倒下 ${state.turn}]`;
|
|
907
991
|
if (state?.phase !== 'combat') return '';
|
|
908
992
|
const control = endTurnState();
|
|
909
|
-
if (control.canUndoEndTurn) return
|
|
910
|
-
if (control.canEndTurn) return state.paused ?
|
|
911
|
-
return state.paused ?
|
|
993
|
+
if (control.canUndoEndTurn) return `[E 撤销结束回合 ${state.turn}]`;
|
|
994
|
+
if (control.canEndTurn) return state.paused ? `[等待继续 ${state.turn}]` : `[E 结束回合 ${state.turn}]`;
|
|
995
|
+
return state.paused ? `[等待继续 ${state.turn}]` : `[${control.reason || '等待结算'} ${state.turn}]`;
|
|
912
996
|
}
|
|
913
997
|
function inspectEntity(id) {
|
|
914
998
|
const enemy = (state.enemies ?? []).find(entry => entry.id === id);
|
|
915
999
|
const player = (state.players ?? []).find(entry => String(entry.id) === id);
|
|
916
|
-
if (enemy) choose(`${enemy.name} · Esc 返回`, [intentLines(enemy.intent, enemy, state).join(' · ') || '(本回合无行动)', `${hpText(enemy)} HP ·
|
|
917
|
-
else if (player) choose(`${player.name} · Esc 返回`, [`${hpText(player)} HP ·
|
|
1000
|
+
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(); });
|
|
1001
|
+
else if (player) choose(`${player.name} · Esc 返回`, [`${hpText(player)} HP · ⛨ ${player.block} · ${powersText(player.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
|
|
918
1002
|
}
|
|
919
1003
|
// §C: a legal target is marked by colour — enemies red, allies blue — and the
|
|
920
1004
|
// one being aimed at is the brighter shade.
|
|
@@ -955,8 +1039,8 @@ function mapHeader(width, view) {
|
|
|
955
1039
|
// there, so the list stays the surface for that case alone.
|
|
956
1040
|
if (state?.phase !== 'combat') {
|
|
957
1041
|
const rows = arena === 'enemy'
|
|
958
|
-
? (state?.enemies ?? []).filter(e => e.hp > 0).map(e => ({ id: e.id, label: `${e.name} · ${hpText(e)} HP ·
|
|
959
|
-
: (state?.players ?? []).filter(p => p.hp > 0 && p.id !== ctx.user.id).map(p => ({ id: String(p.id), playerId: p.id, label: `${p.name} · ${p.hp}/${p.maxHp} HP ·
|
|
1042
|
+
? (state?.enemies ?? []).filter(e => e.hp > 0).map(e => ({ id: e.id, label: `${e.name} · ${hpText(e)} HP · ⛨ ${e.block}` }))
|
|
1043
|
+
: (state?.players ?? []).filter(p => p.hp > 0 && p.id !== ctx.user.id).map(p => ({ id: String(p.id), playerId: p.id, label: `${p.name} · ${p.hp}/${p.maxHp} HP · ⛨ ${p.block}` }));
|
|
960
1044
|
if (!rows.length) { note = arena === 'enemy' ? '没有可攻击的敌人。' : '没有存活的其他队友。'; changed(); return; }
|
|
961
1045
|
choose(title, rows, row => void submit({ ...payloadOf(action), target: arena === 'ally' ? row.playerId : row.id }));
|
|
962
1046
|
return;
|
|
@@ -979,7 +1063,7 @@ function mapHeader(width, view) {
|
|
|
979
1063
|
void submit({ ...payloadOf(action), target: arena === 'ally' ? Number(id) : id });
|
|
980
1064
|
}
|
|
981
1065
|
function confirm() {
|
|
982
|
-
if (busy || pending) return;
|
|
1066
|
+
if (busy || pending || modal?.battleLog) return;
|
|
983
1067
|
anchor(); const row = items()[index]; if (!row) return;
|
|
984
1068
|
if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; listPage = current.previousPage ?? 0; current.accept(row); return; }
|
|
985
1069
|
if (!roomId) {
|
|
@@ -1007,16 +1091,6 @@ function mapHeader(width, view) {
|
|
|
1007
1091
|
changed();return;
|
|
1008
1092
|
}
|
|
1009
1093
|
if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
|
|
1010
|
-
if (activeTab() === 'potions' && row.potion) {
|
|
1011
|
-
if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
|
|
1012
|
-
if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
|
|
1013
|
-
if (state.paused || (state.phase === 'combat' ? me()?.done : row.potion.usage !== 'AnyTime')) { note = '当前不能使用这瓶药水。'; changed(); return; }
|
|
1014
|
-
const action = { type: 'potion', potion: row.id, phaseId: state.phaseId };
|
|
1015
|
-
// §C: a potion with a single target is aimed at the same region as a card.
|
|
1016
|
-
if (row.potion.target === 'AnyEnemy' || row.potion.key === 'FirePotion') beginTargeting({ ...action, label: row.potion.name ?? '药水' }, 'enemy', '选择药水目标');
|
|
1017
|
-
else if (row.potion.target === 'AnyPlayer' && state.players.filter(p => p.hp > 0).length > 1) beginTargeting({ ...action, label: row.potion.name ?? '药水' }, 'ally', '选择药水目标');
|
|
1018
|
-
else void submit(action); return;
|
|
1019
|
-
}
|
|
1020
1094
|
if (tab === 0 && row.group && row.id === row.group) { if (row.open) rewardPick.delete(row.group); rewardOpen = row.open ? undefined : row.group; changed(); return; }
|
|
1021
1095
|
if (tab === 0 && row.candidate && row.action) {
|
|
1022
1096
|
// Picking a card reward's card is the deliberate claim.
|
|
@@ -1134,7 +1208,7 @@ function mapHeader(width, view) {
|
|
|
1134
1208
|
// The drawing reads the view state and the party; this supplies both.
|
|
1135
1209
|
const mapLines = (view, width) => drawMap(view, width,
|
|
1136
1210
|
{ full: mapFull, scroll: mapScroll, anchor: mapAnchor, focus:mapFocus, height:mapBudget, players: state?.players, options: state?.options });
|
|
1137
|
-
const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
1211
|
+
const mapNavigation = () => !modal && !targeting && !pileView && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
|
|
1138
1212
|
const screen = {
|
|
1139
1213
|
render(width, rows) {
|
|
1140
1214
|
columns = width; height = rows; hits = [];
|
|
@@ -1143,8 +1217,37 @@ function mapHeader(width, view) {
|
|
|
1143
1217
|
if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
|
|
1144
1218
|
const own = me();
|
|
1145
1219
|
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} · 合作房间`;
|
|
1146
|
-
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${
|
|
1220
|
+
const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${state.self.potions.length}/${state.self.potionCapacity ?? 3}` : '';
|
|
1147
1221
|
add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
|
|
1222
|
+
if(state){
|
|
1223
|
+
const entries=regionItems('entries'), labels=entries.map((row,i)=>`${focusRegion==='entries'&&focusIndex===i?'› ':''}${row.name}`);
|
|
1224
|
+
const right=Math.max(0,width-displayWidth(labels[0]));
|
|
1225
|
+
mark({kind:'entry',id:'log',col:0,width:displayWidth(labels[1])});
|
|
1226
|
+
mark({kind:'entry',id:'deck',col:right,width:displayWidth(labels[0])});
|
|
1227
|
+
add(pad(labels[1],right)+labels[0],'muted');
|
|
1228
|
+
for(const region of ['relics','potions']){
|
|
1229
|
+
const list=regionItems(region);let from=Math.min(equipmentOffset[region],Math.max(0,list.length-1));
|
|
1230
|
+
const wanted=focusRegion===region?Math.max(0,Math.min(focusIndex,list.length-1)):from;
|
|
1231
|
+
if(wanted<from)from=wanted;
|
|
1232
|
+
const label=row=>`${row.name}${row.counter!==undefined?`(${row.counter})`:''}`;
|
|
1233
|
+
while(from<wanted && displayWidth(list.slice(from,wanted+1).map(label).join(' · '))>width-14)from++;
|
|
1234
|
+
equipmentOffset[region]=Math.max(0,from);
|
|
1235
|
+
let text=(region==='relics'?'遗物':'药水')+' ';let shown=0;
|
|
1236
|
+
for(let i=from;i<list.length;i++){
|
|
1237
|
+
const part=cut(`${focusRegion===region&&focusIndex===i?'›':''}${label(list[i])}`,Math.max(4,width-14));
|
|
1238
|
+
if(displayWidth(text+' '+part)>width-5)break;
|
|
1239
|
+
const col=displayWidth(text);mark({kind:'equipment',region,id:list[i].id,index:i,col,width:displayWidth(part)});
|
|
1240
|
+
text+=part+' · ';shown++;
|
|
1241
|
+
}
|
|
1242
|
+
if(!list.length)text+='无';
|
|
1243
|
+
if(from>0||from+shown<list.length){
|
|
1244
|
+
mark({kind:'equipmentPage',region,delta:-1,col:width-5,width:2});
|
|
1245
|
+
mark({kind:'equipmentPage',region,delta:1,col:width-2,width:2});
|
|
1246
|
+
text=pad(cut(text,width-5),width-5)+'‹ ›';
|
|
1247
|
+
}
|
|
1248
|
+
add(cut(text,width),'muted');
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1148
1251
|
if(mapNavigation()) {
|
|
1149
1252
|
const view=state.mapView, head=mapHeader(width,view), headerRow=frame.length;
|
|
1150
1253
|
add(head.text,'muted');
|
|
@@ -1157,45 +1260,42 @@ function mapHeader(width, view) {
|
|
|
1157
1260
|
}
|
|
1158
1261
|
while(frame.length<rows-5)add('');
|
|
1159
1262
|
add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
|
|
1160
|
-
add(cut(mapDetailText(view,mapData(view)),width),'muted');
|
|
1263
|
+
add(cut(focusRegion==='action'&&!hovered?mapDetailText(view,mapData(view)):detailText(),width),'muted');
|
|
1161
1264
|
add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
|
|
1162
1265
|
add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
|
|
1163
|
-
|
|
1164
|
-
add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
|
|
1266
|
+
add(cut('方向键焦点 · D 牌组 · V 日志 · M 关闭地图',width),'muted');
|
|
1165
1267
|
hits=hits.filter(hit=>hit.row<rows);
|
|
1166
|
-
return frame.slice(0,rows);
|
|
1268
|
+
paintFocus(frame);return frame.slice(0,rows);
|
|
1167
1269
|
}
|
|
1168
1270
|
if (unsupported) add(`此扩展版本不认识该阶段「${unsupported}」,请在更新扩展后继续(当前只读)。`, 'error');
|
|
1169
1271
|
if (targeting) add(cut(`正在使用:${targeting.label} · 点击目标或 ←/→ 选择 · Enter 确认 · Esc 取消`, width), 'selected');
|
|
1170
1272
|
if (state) {
|
|
1171
1273
|
if (state.phase === 'combat') {
|
|
1172
|
-
add(`回合 ${state.turn}${own?.done ? ' · 你已结束回合' : ' · 你的回合'}`, 'muted');
|
|
1173
1274
|
const alive = state.enemies.filter(e => e.hp > 0 || e.awaitingRevive);
|
|
1174
|
-
const chosen = targetIndex();
|
|
1275
|
+
const chosen = focusRegion==='units'&&!targeting?alive.findIndex(e=>String(e.id)===String(focusItem()?.id)):targetIndex();
|
|
1175
1276
|
const party = [own, ...state.players.filter(p => p.id !== ctx.user.id)];
|
|
1176
|
-
const partyBlocks = party.map(p => allyBlock(p));
|
|
1177
1277
|
const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
|
|
1178
1278
|
const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
|
|
1179
|
-
if(
|
|
1279
|
+
if(sideBySide()) {
|
|
1180
1280
|
// Medium terminals keep the two teams side by side. Party members
|
|
1181
1281
|
// stack in the left column; enemies page independently on the right.
|
|
1182
1282
|
const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
|
|
1183
|
-
const budget=Math.max(4,rows-frame.length-
|
|
1184
|
-
const room=Math.max(2,Math.min(
|
|
1283
|
+
const budget=Math.max(4,rows-frame.length-13);
|
|
1284
|
+
const room=Math.max(2,Math.min(7,Math.floor(budget/party.length)));
|
|
1185
1285
|
const perPage=Math.max(1,Math.floor(budget/room));
|
|
1186
1286
|
const pages=Math.max(1,Math.ceil(alive.length/perPage));
|
|
1187
1287
|
if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
|
|
1188
1288
|
enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
|
|
1189
1289
|
const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
|
|
1190
1290
|
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 ·
|
|
1291
|
+
const name=`${focusRegion==='units'&&String(focusItem()?.id)===String(creature.id)?'› ':arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
|
|
1292
|
+
const health=`${hpText(creature)} HP · ⛨ ${creature.block}`;
|
|
1193
1293
|
const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
|
|
1194
1294
|
const powers=powerEntries(creature).map(p=>p.text).join(' · ');
|
|
1195
1295
|
const body=[name,health];
|
|
1196
1296
|
if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
|
|
1197
1297
|
if(room>=3)body.push(intent||powers||'无额外状态');
|
|
1198
|
-
if(room>=5)body.push(intent?powers:'');
|
|
1298
|
+
if(room>=6){const entries=powerEntries(creature),buffs=entries.filter(e=>!debuffPowers.has(e.key)).map(e=>e.text),debuffs=entries.filter(e=>debuffPowers.has(e.key)).map(e=>e.text);body.push(`Buff:${buffs.join(' · ')||'无'}`,`DeBuff:${debuffs.join(' · ')||'无'}`);}else if(room>=5)body.push(intent?powers:'');
|
|
1199
1299
|
return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
|
|
1200
1300
|
};
|
|
1201
1301
|
const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
|
|
@@ -1209,40 +1309,7 @@ function mapHeader(width, view) {
|
|
|
1209
1309
|
hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
|
|
1210
1310
|
}));
|
|
1211
1311
|
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} 页 ·
|
|
1213
|
-
} else if (wideBattlefield()) {
|
|
1214
|
-
// The party column is as wide as what it prints (a fixed third used to
|
|
1215
|
-
// cut 增益/减益 short); the enemies share what is left.
|
|
1216
|
-
const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
|
|
1217
|
-
let allyWidth = Math.max(MIN_BLOCK, Math.min(Math.floor(width * 0.42), natural + 2));
|
|
1218
|
-
const perPage = Math.max(1, Math.floor((width + 2 - partyBlocks.length * (allyWidth + 2)) / (MIN_BLOCK + 2)));
|
|
1219
|
-
const pages = Math.max(1, Math.ceil(alive.length / perPage));
|
|
1220
|
-
if (chosen >= 0) enemyPage = Math.floor(chosen / perPage);
|
|
1221
|
-
enemyPage = Math.max(0, Math.min(pages - 1, enemyPage ?? 0));
|
|
1222
|
-
const shown = alive.slice(enemyPage * perPage, enemyPage * perPage + perPage);
|
|
1223
|
-
const gaps = 2 * Math.max(0, partyBlocks.length + shown.length - 1);
|
|
1224
|
-
if (shown.length && width - gaps - allyWidth * partyBlocks.length < MIN_BLOCK * shown.length) {
|
|
1225
|
-
allyWidth = Math.max(MIN_BLOCK, Math.floor((width - gaps - MIN_BLOCK * shown.length) / Math.max(1, partyBlocks.length)));
|
|
1226
|
-
}
|
|
1227
|
-
const foeWidth = shown.length ? Math.max(MIN_BLOCK, Math.floor((width - gaps - allyWidth * partyBlocks.length) / shown.length)) : 0;
|
|
1228
|
-
const widths = [...party.map(() => allyWidth), ...shown.map(() => foeWidth)];
|
|
1229
|
-
const blocks = [
|
|
1230
|
-
// §B: the party block keeps the enemy block's row count so names, bars
|
|
1231
|
-
// and power lines sit on the same rows across the battlefield.
|
|
1232
|
-
...party.map(player => ({ id: String(player?.id), lines: ['', ...allyBlock(player, allyWidth - 2)], legal: legalAlly(String(player?.id)) })),
|
|
1233
|
-
...shown.map(enemy => ({ id: enemy.id, lines: enemyBlock(enemy, foeWidth - 2), legal: legalEnemy(enemy) })),
|
|
1234
|
-
];
|
|
1235
|
-
const body = blocks.map(entry => entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines);
|
|
1236
|
-
const tall = Math.max(...body.map(lines => lines.length));
|
|
1237
|
-
const startRow = frame.length;
|
|
1238
|
-
for (let line = 0; line < tall; line++) add(body.map((lines, index) => pad(cut(lines[line] ?? '', widths[index] - 2), widths[index])).join(' '), 'text');
|
|
1239
|
-
blocks.forEach((entry, index) => hits.push({
|
|
1240
|
-
kind: 'entity', id: entry.id,
|
|
1241
|
-
row: startRow, col: widths.slice(0, index).reduce((total, value) => total + value + 2, 0),
|
|
1242
|
-
width: widths[index], height: tall,
|
|
1243
|
-
}));
|
|
1244
|
-
if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · PgUp/PgDn 翻页`, 'muted');
|
|
1245
|
-
add('─'.repeat(Math.max(0, width)), 'muted');
|
|
1312
|
+
if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · 方向键选择 · 自动翻页`,'muted');}
|
|
1246
1313
|
} else {
|
|
1247
1314
|
const budget = Math.max(2, rows - frame.length - 12);
|
|
1248
1315
|
const room = budget >= party.length * 2 ? 2 : 1;
|
|
@@ -1264,7 +1331,7 @@ function mapHeader(width, view) {
|
|
|
1264
1331
|
for (const line of shownLines) add(line, 'text');
|
|
1265
1332
|
hits.push({ kind: 'entity', id: entry.id, row: top, col: 0, width, height: shownLines.length });
|
|
1266
1333
|
}
|
|
1267
|
-
if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 ·
|
|
1334
|
+
if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · 方向键继续浏览`, 'muted');
|
|
1268
1335
|
}
|
|
1269
1336
|
const usable = own?.energy ?? 0;
|
|
1270
1337
|
const capacity = own?.energyCapacity ?? usable;
|
|
@@ -1277,8 +1344,8 @@ function mapHeader(width, view) {
|
|
|
1277
1344
|
if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
|
|
1278
1345
|
add(pad(energy, buttonAt) + button, 'title');
|
|
1279
1346
|
const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
|
|
1280
|
-
['exhaust','
|
|
1281
|
-
['discard','
|
|
1347
|
+
['exhaust','X','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
|
|
1348
|
+
['discard','S','弃牌堆',state.self.discard.length]];
|
|
1282
1349
|
const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
|
|
1283
1350
|
const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
|
|
1284
1351
|
if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
|
|
@@ -1294,7 +1361,7 @@ function mapHeader(width, view) {
|
|
|
1294
1361
|
// Clicking a teammate is the same act as pressing P on them. The
|
|
1295
1362
|
// mark is taken before the line is pushed, while the row still is
|
|
1296
1363
|
// the one it will land on.
|
|
1297
|
-
if (
|
|
1364
|
+
if (!modal) mark({ kind: 'entity', id: String(player.id), width: displayWidth(rowText) });
|
|
1298
1365
|
add(rowText, player.id === ctx.user.id ? 'selected' : 'text');
|
|
1299
1366
|
}
|
|
1300
1367
|
if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
|
|
@@ -1308,44 +1375,32 @@ function mapHeader(width, view) {
|
|
|
1308
1375
|
}
|
|
1309
1376
|
|
|
1310
1377
|
}
|
|
1311
|
-
const labels = tabs.map((key, i) => {
|
|
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;
|
|
1313
|
-
const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
|
|
1314
|
-
return !pileView && i === tab ? `[${label}]` : label;
|
|
1315
|
-
});
|
|
1316
1378
|
const decision = state.pendingChoice ?? state.pendingDecision;
|
|
1317
|
-
if(modal || (tab===0 && decision)) {
|
|
1318
|
-
|
|
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
|
-
}
|
|
1379
|
+
if(modal || (tab===0 && decision))add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
|
|
1380
|
+
else if(pileView)add(`${tabNames[pileView]} · Esc 返回 · U 升级预览`,'title');
|
|
1331
1381
|
if (modal?.detail) {
|
|
1332
1382
|
const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
|
|
1333
1383
|
const count = Math.max(1, Math.min(6, rows - frame.length - 9));
|
|
1334
1384
|
modal.detailOffset = Math.min(modal.detailOffset ?? 0, Math.max(0, lines.length - count));
|
|
1335
1385
|
mark({ kind: 'detail' });
|
|
1336
1386
|
for (const line of lines.slice(modal.detailOffset, modal.detailOffset + count)) add(line);
|
|
1337
|
-
if (lines.length > count) add('
|
|
1387
|
+
if (lines.length > count) add('↑↓ 浏览 · 到边缘继续滚动说明', 'muted');
|
|
1338
1388
|
}
|
|
1339
1389
|
} else add('频道成员可加入 · R 刷新房间列表', 'muted');
|
|
1340
1390
|
if (loading) add('正在加载…', 'muted');
|
|
1341
|
-
const
|
|
1342
|
-
const
|
|
1391
|
+
const detailRows=state && (!modal || modal.upgradePicker) ? 3 : 0;
|
|
1392
|
+
const footerRows=1+detailRows;
|
|
1343
1393
|
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
|
|
1394
|
+
anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1);
|
|
1345
1395
|
pageSize = available;
|
|
1396
|
+
listPage=Math.floor(Math.max(0,index)/available);
|
|
1346
1397
|
const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
|
|
1347
1398
|
listPage = Math.max(0, Math.min(maxPage, listPage));
|
|
1348
1399
|
const start = listPage * available;
|
|
1400
|
+
const cards=list.filter(row=>row.card).map(row=>row.card);
|
|
1401
|
+
const cardNameWidth=Math.min(Math.max(4,...cards.map(card=>displayWidth(card.name))),Math.max(8,Math.floor(width*.4)));
|
|
1402
|
+
const cardCostWidth=Math.max(3,...cards.map(card=>displayWidth(`[${card.cost}]`)));
|
|
1403
|
+
const rowLabel=row=>row.card?`${pad(cut(row.card.name,cardNameWidth),cardNameWidth)} ${pad(`[${row.card.cost}]`,cardCostWidth)} ${row.card.description}`:row.label;
|
|
1349
1404
|
if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
|
|
1350
1405
|
// §E: a wide frame puts the shop's detail beside the goods; a narrow one
|
|
1351
1406
|
// expands it below the list instead.
|
|
@@ -1364,12 +1419,11 @@ function mapHeader(width, view) {
|
|
|
1364
1419
|
for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
|
|
1365
1420
|
continue;
|
|
1366
1421
|
}
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index ? 'selected' : 'text'); continue; }
|
|
1422
|
+
const rowText = `${i === index && (modal||focusRegion==='action') ? '›' : ' '} ${rowLabel(list[i])}`;
|
|
1423
|
+
if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index && (modal||focusRegion==='action') ? 'selected' : 'text'); continue; }
|
|
1370
1424
|
const slot = i - start;
|
|
1371
1425
|
mark({ kind: 'item', id: list[i].id, width: paneAt });
|
|
1372
|
-
add(pad(cut(rowText, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), i === index ? 'selected' : 'text');
|
|
1426
|
+
add(pad(cut(rowText, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), i === index && (modal||focusRegion==='action') ? 'selected' : 'text');
|
|
1373
1427
|
for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
|
|
1374
1428
|
}
|
|
1375
1429
|
if (pane) {
|
|
@@ -1382,20 +1436,30 @@ function mapHeader(width, view) {
|
|
|
1382
1436
|
for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: at, col: (paneAt ?? 0) + button.at, height: 1 });
|
|
1383
1437
|
}
|
|
1384
1438
|
}
|
|
1385
|
-
if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 ·
|
|
1386
|
-
while (frame.length < rows - footerRows
|
|
1387
|
-
if (logLines) {
|
|
1388
|
-
add('战斗日志', 'muted');
|
|
1389
|
-
const recent = logItems().slice(-logLines);
|
|
1390
|
-
for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
|
|
1391
|
-
}
|
|
1439
|
+
if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · 方向键继续浏览 · 共 ${list.length} 项`, 'muted');
|
|
1440
|
+
while (frame.length < rows - footerRows) add('');
|
|
1392
1441
|
add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
|
|
1393
|
-
if(
|
|
1442
|
+
if(detailRows){const detail=wrapText(modal?.upgradeDetail??detailText(),width).slice(0,detailRows);for(let i=detail.length;i<detailRows;i++)add('');for(const line of detail)add(cut(line,width),'muted');}
|
|
1443
|
+
if(frame.length>rows && detailRows){const tail=frame.splice(-detailRows);frame.splice(rows-detailRows);frame.push(...tail);}
|
|
1394
1444
|
const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
|
|
1395
|
-
return frame.slice(0, rows);
|
|
1445
|
+
paintFocus(frame);return frame.slice(0, rows);
|
|
1396
1446
|
},
|
|
1397
1447
|
key(key) {
|
|
1398
1448
|
const name = key.name, value = key.value?.toLowerCase();
|
|
1449
|
+
if(['tab','pageup','pagedown'].includes(name))return;
|
|
1450
|
+
if(!modal && !targeting && state){
|
|
1451
|
+
if(value==='d' && !key.ctrl){openPile('deck');return;}
|
|
1452
|
+
if(value==='v'){void openLog();return;}
|
|
1453
|
+
if(value==='g' && state.phase==='combat'&&!mapNavigation()){toggleLayout();return;}
|
|
1454
|
+
if(name==='escape' && pileView){closePile();return;}
|
|
1455
|
+
if(focusRegion!=='action' && ['home','end'].includes(name)){focusIndex=name==='home'?0:Math.max(0,regionItems(focusRegion).length-1);hovered=undefined;changed();return;}
|
|
1456
|
+
if(focusRegion!=='action' && ['return','enter'].includes(name)){activateFocus();return;}
|
|
1457
|
+
}
|
|
1458
|
+
if(modal?.battleLog && ['left','right'].includes(name)){
|
|
1459
|
+
const cursor=name==='left'?modal.battleLog.after:modal.battleLog.before;
|
|
1460
|
+
if(cursor!=null)void openLog({[name==='left'?'after':'before']:String(cursor)});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1399
1463
|
if (targeting) {
|
|
1400
1464
|
const ids = targetIds();
|
|
1401
1465
|
if (name === 'escape') { cancelTargeting(); return; }
|
|
@@ -1405,11 +1469,6 @@ function mapHeader(width, view) {
|
|
|
1405
1469
|
targeting.id = ids[(at + step + ids.length) % ids.length];
|
|
1406
1470
|
changed(); return;
|
|
1407
1471
|
}
|
|
1408
|
-
if (['pageup', 'pagedown', 'home', 'end'].includes(name)) {
|
|
1409
|
-
const at = name === 'home' ? 0 : name === 'end' ? ids.length - 1 : (Math.max(0, ids.indexOf(targeting.id)) + (name === 'pageup' ? -5 : 5));
|
|
1410
|
-
targeting.id = ids[Math.max(0, Math.min(ids.length - 1, at))];
|
|
1411
|
-
changed(); return;
|
|
1412
|
-
}
|
|
1413
1472
|
if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
|
|
1414
1473
|
if (value === 'e') { cancelTargeting(); return; }
|
|
1415
1474
|
}
|
|
@@ -1421,16 +1480,11 @@ function mapHeader(width, view) {
|
|
|
1421
1480
|
if (mapNavigation()) {
|
|
1422
1481
|
if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1423
1482
|
if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
|
|
1424
|
-
if(tab===0 && ['left','right','up','down'].includes(name)) {
|
|
1483
|
+
if(focusRegion==='action' && tab===0 && ['left','right','up','down'].includes(name)) {
|
|
1425
1484
|
const data=mapData(state.mapView), here=data.byId.get(mapFocus)??data.current??data.nodes[0];
|
|
1426
1485
|
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);
|
|
1427
1486
|
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));
|
|
1428
|
-
if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}
|
|
1429
|
-
changed();return;
|
|
1430
|
-
}
|
|
1431
|
-
if(['pageup','pagedown','home','end'].includes(name)){
|
|
1432
|
-
const drawn=mapLines(state.mapView,columns),max=Math.max(0,drawn.total-drawn.span);
|
|
1433
|
-
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)));
|
|
1487
|
+
if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}else spatialMove(name,true);
|
|
1434
1488
|
changed();return;
|
|
1435
1489
|
}
|
|
1436
1490
|
if(/^[0-9]$/.test(value??'')){
|
|
@@ -1438,19 +1492,18 @@ function mapHeader(width, view) {
|
|
|
1438
1492
|
if(routes[at]){mapFocus=routes[at].action.node;mapAnchor=mapFocus;mapScroll=undefined;changed();}
|
|
1439
1493
|
return;
|
|
1440
1494
|
}
|
|
1441
|
-
if(['return','enter'].includes(name)){
|
|
1495
|
+
if(focusRegion==='action' && ['return','enter'].includes(name)){
|
|
1442
1496
|
const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
|
|
1443
1497
|
if(row)void submit({...row.action,phaseId:state.phaseId});
|
|
1444
1498
|
return;
|
|
1445
1499
|
}
|
|
1446
1500
|
}
|
|
1447
|
-
if(mapNavigation() && ['a','s','
|
|
1448
|
-
if(!modal && !targeting &&
|
|
1449
|
-
if(
|
|
1450
|
-
if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
|
|
1501
|
+
if(mapNavigation() && ['a','s','x','e',' '].includes(value))return;
|
|
1502
|
+
if(!modal && !targeting && state?.phase==='combat' && ['a','s','x'].includes(value)){openPile({a:'draw',s:'discard',x:'exhaust'}[value]);return;}
|
|
1503
|
+
if(name==='escape' && pileView && !modal){closePile();return;}
|
|
1451
1504
|
// The board takes the arrows before the option list does, or moving on
|
|
1452
1505
|
// the grid would scroll the rows underneath it.
|
|
1453
|
-
if (columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
|
|
1506
|
+
if (focusRegion==='action' && columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
|
|
1454
1507
|
if (name === 'up') { sphereMove(0, -1); return; }
|
|
1455
1508
|
if (name === 'down') { sphereMove(0, 1); return; }
|
|
1456
1509
|
if (name === 'left') { sphereMove(-1, 0); return; }
|
|
@@ -1462,6 +1515,7 @@ function mapHeader(width, view) {
|
|
|
1462
1515
|
if (value === ' ' || name === 'space' || name === 'return' || name === 'enter') { sphereUse(); return; }
|
|
1463
1516
|
if (value === 't') { sphereTool(); return; }
|
|
1464
1517
|
}
|
|
1518
|
+
if(!modal && !targeting && state && ['up','down','left','right'].includes(name)){spatialMove(name);return;}
|
|
1465
1519
|
if (value === ' ' && !modal && !targeting) {
|
|
1466
1520
|
const row = items()[index];
|
|
1467
1521
|
if (row?.pick) {
|
|
@@ -1481,14 +1535,16 @@ function mapHeader(width, view) {
|
|
|
1481
1535
|
return;
|
|
1482
1536
|
}
|
|
1483
1537
|
if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
|
|
1484
|
-
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal =
|
|
1538
|
+
if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal = modal.previousModal; anchor(); changed(); }
|
|
1539
|
+
else if(focusRegion!=='action'){focusRegion='action';hovered=undefined;changed();}
|
|
1485
1540
|
else ctx.closeScreen(); return;
|
|
1486
1541
|
}
|
|
1487
1542
|
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1488
|
-
if (value === '?' && !modal) {
|
|
1543
|
+
if (value === '?' && (!modal || modal.upgradePicker)) {
|
|
1489
1544
|
const row = items()[index];
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1545
|
+
const previousPicker=modal?.upgradePicker?modal:undefined;
|
|
1546
|
+
const lines = ctx.wrap(modal?.upgradeDetail ?? (detailText() || '方向键选择,Enter 确认'), Math.max(1, columns - 4));
|
|
1547
|
+
choose('说明 · ↑↓ 滚动 · Esc 返回', lines.map((label, i) => ({ id: String(i), label })), () => {if(previousPicker)modal=previousPicker;anchor(); changed(); });if(previousPicker)modal.previousModal=previousPicker;return;
|
|
1492
1548
|
}
|
|
1493
1549
|
if (value === 'r' && !busy) {
|
|
1494
1550
|
if (pending && online && synced) void submit(pending.action, true);
|
|
@@ -1498,7 +1554,6 @@ function mapHeader(width, view) {
|
|
|
1498
1554
|
if (value === 'b' && roomId && !busy) {
|
|
1499
1555
|
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;
|
|
1500
1556
|
}
|
|
1501
|
-
if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
|
|
1502
1557
|
// The key and the button share one entry point.
|
|
1503
1558
|
if (value === 'e' && !modal && !targeting) { endTurn(); return; }
|
|
1504
1559
|
if (value === 'p' && !modal && !targeting && state) {
|
|
@@ -1521,20 +1576,15 @@ function mapHeader(width, view) {
|
|
|
1521
1576
|
return;
|
|
1522
1577
|
}
|
|
1523
1578
|
if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
|
|
1524
|
-
if
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
void inspectPlayer(
|
|
1532
|
-
|
|
1533
|
-
if (modal?.detail && ['pageup','pagedown'].includes(name)) { pageDetail(name === 'pageup' ? -3 : 3); return; }
|
|
1534
|
-
if (name === 'pageup' || name === 'pagedown') {
|
|
1535
|
-
const pages = Math.max(1, Math.ceil(items().length / pageSize));
|
|
1536
|
-
listPage = Math.max(0, Math.min(pages - 1, listPage + (name === 'pagedown' ? 1 : -1)));
|
|
1537
|
-
moveTo(listPage * pageSize); return;
|
|
1579
|
+
if(modal && ['up','down'].includes(name)){
|
|
1580
|
+
const step=name==='up'?-1:1,boundary=index+step<0||index+step>=items().length;
|
|
1581
|
+
if(boundary && modal.battleLog){
|
|
1582
|
+
const cursor=step<0?modal.battleLog.after:modal.battleLog.before;
|
|
1583
|
+
if(cursor!=null&&!busy)void openLog({[step<0?'after':'before']:String(cursor)},step<0);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if(boundary && modal.inspection){const view=modal.inspection,page=view.page+step;if(page>=0&&page<view.pages)void inspectPlayer(view.id,page,step<0);return;}
|
|
1587
|
+
if(modal.detail && boundary){pageDetail(step);return;}
|
|
1538
1588
|
}
|
|
1539
1589
|
if (['up', 'down', 'home', 'end'].includes(name)) {
|
|
1540
1590
|
const list = items();
|
|
@@ -1547,6 +1597,27 @@ function mapHeader(width, view) {
|
|
|
1547
1597
|
},
|
|
1548
1598
|
mouse(event) {
|
|
1549
1599
|
if (columns < 40 || height < (mapNavigation()?12:20)) return;
|
|
1600
|
+
if(!modal && !targeting){
|
|
1601
|
+
const hit=hitAt(event.x,event.y);
|
|
1602
|
+
if(event.action==='move'){
|
|
1603
|
+
let next;
|
|
1604
|
+
if(hit?.kind==='equipment')next={region:hit.region,row:regionItems(hit.region).find(row=>row.id===hit.id)};
|
|
1605
|
+
else if(hit?.kind==='entity')next={region:'units',row:regionItems('units').find(row=>String(row.id)===String(hit.id))};
|
|
1606
|
+
if(hovered?.row?.id!==next?.row?.id){hovered=next;changed();}
|
|
1607
|
+
if(next)return;
|
|
1608
|
+
}
|
|
1609
|
+
if(event.action==='press' && event.button==='left'){
|
|
1610
|
+
if(hit?.kind==='entry'){if(hit.id==='deck')openPile('deck');else void openLog();return;}
|
|
1611
|
+
if(hit?.kind==='equipmentPage'){
|
|
1612
|
+
equipmentOffset[hit.region]=Math.max(0,Math.min(regionItems(hit.region).length-1,equipmentOffset[hit.region]+hit.delta));
|
|
1613
|
+
focusRegion=hit.region;focusIndex=equipmentOffset[hit.region];hovered=undefined;changed();return;
|
|
1614
|
+
}
|
|
1615
|
+
if(hit?.kind==='equipment'){
|
|
1616
|
+
focusRegion=hit.region;focusIndex=hit.index;hovered=undefined;
|
|
1617
|
+
if(hit.region==='potions')potionMenu(focusItem());else changed();return;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1550
1621
|
if (event.action === 'move') {
|
|
1551
1622
|
// Hover lights a row, or shows which target the pointer is on.
|
|
1552
1623
|
const pointed = hitAt(event.x, event.y);
|
|
@@ -1556,6 +1627,7 @@ function mapHeader(width, view) {
|
|
|
1556
1627
|
}
|
|
1557
1628
|
// §D: hovering a map node looks at it; pointing never submits.
|
|
1558
1629
|
if (pointed?.kind === 'node') {
|
|
1630
|
+
focusRegion='action';
|
|
1559
1631
|
if (mapFocus !== pointed.id) { mapFocus = pointed.id; changed(); }
|
|
1560
1632
|
return;
|
|
1561
1633
|
}
|
|
@@ -1566,6 +1638,7 @@ function mapHeader(width, view) {
|
|
|
1566
1638
|
if (event.button === 'left') {
|
|
1567
1639
|
const hit = hitAt(event.x, event.y);
|
|
1568
1640
|
if (hit?.kind === 'node') {
|
|
1641
|
+
focusRegion='action';
|
|
1569
1642
|
// §D: clicking a room you can travel to is the vote itself; any other
|
|
1570
1643
|
// room is only looked at, and parks the view there.
|
|
1571
1644
|
mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
|
|
@@ -1585,7 +1658,7 @@ function mapHeader(width, view) {
|
|
|
1585
1658
|
void submit(row.action);
|
|
1586
1659
|
return;
|
|
1587
1660
|
}
|
|
1588
|
-
if (hit?.kind === 'managePotions') {
|
|
1661
|
+
if (hit?.kind === 'managePotions') { focusRegion='potions';focusIndex=0;changed();return; }
|
|
1589
1662
|
if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
|
|
1590
1663
|
if (hit?.kind === 'mapFull') { mapFull = !mapFull; mapScroll = undefined; changed(); return; }
|
|
1591
1664
|
if (hit?.kind === 'endTurn') { endTurn(); return; }
|
|
@@ -1597,7 +1670,7 @@ function mapHeader(width, view) {
|
|
|
1597
1670
|
}
|
|
1598
1671
|
if (event.button === 'wheel-up' || event.button === 'wheel-down') {
|
|
1599
1672
|
const step = event.button === 'wheel-up' ? -1 : 1;
|
|
1600
|
-
// An open detail pane pages first,
|
|
1673
|
+
// An open detail pane pages first, before moving the option list.
|
|
1601
1674
|
if (modal?.detail) { pageDetail(step * 3); return; }
|
|
1602
1675
|
// §D: on the map the wheel looks around the act instead of moving the cursor.
|
|
1603
1676
|
if (mapNavigation()) {
|
|
@@ -1612,10 +1685,7 @@ function mapHeader(width, view) {
|
|
|
1612
1685
|
const hit = hitAt(event.x, event.y);
|
|
1613
1686
|
if (!hit) return;
|
|
1614
1687
|
if (hit.kind === 'item') { clickRow(hit.id); return; }
|
|
1615
|
-
|
|
1616
|
-
const span = hit.spans?.find(range => event.x >= range.from && event.x < range.to);
|
|
1617
|
-
if (span) switchTab(span.tab); else if (hit.advance) switchTab(tab + 1);
|
|
1618
|
-
}
|
|
1688
|
+
|
|
1619
1689
|
},
|
|
1620
1690
|
};
|
|
1621
1691
|
return { screen, start: () => guard(loadRooms), stop: () => { closed = true; connection?.close(); }, accept };
|