0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -1030,14 +1030,43 @@
|
||||
.topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; }
|
||||
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: calc(.3 * var(--t)); font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
|
||||
.topbar__sep { width: 1px; height: .28rem; background: var(--line-2); }
|
||||
/* the lobby's ⚙ / 📖 / install squares: a size up from `sm` and spaced apart; on touch screens at least 40 px each with
|
||||
a 10 px gap, so their 44 px hit areas (css/devices.css) never overlap and a tap lands on the intended one */
|
||||
.topbar__tools { display: flex; align-items: center; gap: .14rem; }
|
||||
.topbar__tools .btn--square.btn--sm { --sq: .46rem; height: var(--sq); min-width: var(--sq); }
|
||||
.sp-coarse .topbar__tools { gap: max(.16rem, 10px); }
|
||||
.sp-coarse .topbar__tools .btn--square.btn--sm { --sq: max(.56rem, 40px); }
|
||||
.topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
|
||||
|
||||
/* Optional resume/install controls and translated XL titles must share actual space instead of overlapping the
|
||||
centered 1fr/auto/1fr title. On narrow displays the title has its own row; the controls stay in their two groups. */
|
||||
.lobby-screen .topbar { grid-template-columns: auto minmax(0, 1fr) auto; height: auto; min-height: 1.1rem; padding-block: .12rem; }
|
||||
.lobby-screen .topbar__center { min-width: 0; }
|
||||
.lobby-screen .topbar__center .micro { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lobby-screen .topbar__title { justify-content: center; overflow-wrap: anywhere; }
|
||||
@media (max-width: 1000px) {
|
||||
.lobby-screen .topbar { grid-template-columns: minmax(0, 1fr) auto; gap: .1rem .2rem; }
|
||||
.lobby-screen .topbar__center { grid-column: 1 / -1; grid-row: 2; }
|
||||
.lobby-screen .topbar__center .micro { display: none; }
|
||||
.lobby-screen .topbar__right { grid-column: 2; grid-row: 1; }
|
||||
.lobby-screen .me-chip__name { max-width: 1.5rem; }
|
||||
}
|
||||
|
||||
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: calc(.2 * var(--t)); font-weight: 700; letter-spacing: .08em; }
|
||||
.section-label__idx { font-size: calc(.15 * var(--t)); color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
|
||||
|
||||
.me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
|
||||
.me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
|
||||
.me-chip__name { font-weight: 700; font-size: calc(.18 * var(--t)); max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* the lobby's chip doubles as the 统计数据 entry */
|
||||
.me-chip--btn {
|
||||
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
||||
transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out);
|
||||
}
|
||||
.me-chip--btn:hover { border-color: var(--mint-700); background: rgba(34, 42, 38, .6); }
|
||||
.me-chip--btn:active { transform: translateY(1px); }
|
||||
.me-chip__stats { flex: none; width: .2rem; height: .2rem; margin-left: .04rem; color: var(--mint-500); opacity: .7; }
|
||||
.me-chip--btn:hover .me-chip__stats { opacity: 1; }
|
||||
|
||||
.crash { align-items: center; justify-content: center; }
|
||||
.crash__box {
|
||||
|
||||
@@ -520,6 +520,9 @@
|
||||
inside the label, so the label lays glyph and text out like the button itself */
|
||||
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
|
||||
.settings-btn .btn__icon { color: var(--mint-500); }
|
||||
/* square (the lobby's gear alone): the label box drops out so the gear sizes and centres against the button, like an `icon` */
|
||||
.settings-btn.btn--square .btn__label { display: contents; }
|
||||
.settings-btn.btn--square .btn__icon { width: 62%; height: 62%; } /* the gear glyph is drawn smaller in its box than the book */
|
||||
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
|
||||
.set-row { display: grid; grid-template-columns: minmax(0, 3.3rem) minmax(0, 1fr) auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
||||
/* The label column may shrink (the label wraps) instead of pushing its control out of the modal: at 设置 →「文字大小」
|
||||
|
||||
@@ -455,44 +455,44 @@
|
||||
padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); }
|
||||
.diy-slot__fill b { font-size: max(.16rem, 11px); }
|
||||
.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__fill b { font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-slot__fill small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; }
|
||||
.diy-slot__plus .icon { width: 60%; height: 60%; }
|
||||
.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
|
||||
.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); }
|
||||
.diy-slot__art b { font-size: max(calc(.3 * var(--t)), 18px); color: var(--text-lo); }
|
||||
.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.diy-slot__head { display: flex; align-items: center; gap: .05rem; }
|
||||
.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); }
|
||||
.diy-slot__label { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(calc(.17 * var(--t)), 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(calc(.13 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
|
||||
.diy-slot__micon--none { border: 1px solid var(--line-2); }
|
||||
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
|
||||
/* 0.2.2: an owned pick's 潜能 / 练度 (screens/cultivation.js) — a prototype has neither (a dim line instead) */
|
||||
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(.12rem, 9px); }
|
||||
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(calc(.12 * var(--t)), 9px); }
|
||||
.diy-slot__cult .lo-cult { max-width: max(2.6rem, 200px); }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(calc(.12 * var(--t)), 9px); color: var(--amber); }
|
||||
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(calc(.11 * var(--t)), 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag--proto { color: var(--ice-900); background: var(--ice); }
|
||||
.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); }
|
||||
/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */
|
||||
.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; }
|
||||
.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(calc(.13 * var(--t)), 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; }
|
||||
.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: 1fr; align-content: start; gap: .06rem; }
|
||||
.diy-opt {
|
||||
position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
|
||||
@@ -504,19 +504,19 @@
|
||||
.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; }
|
||||
.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); }
|
||||
.diy-opt__name { font-size: max(calc(.15 * var(--t)), 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); }
|
||||
.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(calc(.1 * var(--t)), 8px); color: var(--amber); }
|
||||
.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); }
|
||||
.diy-pick__who { display: flex; align-items: center; gap: .08rem; }
|
||||
.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.diy-pick__wtxt b { font-size: max(.17rem, 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__wtxt b { font-size: max(calc(.17 * var(--t)), 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(calc(.14 * var(--t)), 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.diy-choice {
|
||||
display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
@@ -527,8 +527,8 @@
|
||||
.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); }
|
||||
.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); }
|
||||
.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(calc(.14 * var(--t)), 10px); }
|
||||
.diy-choice__desc { font-size: max(calc(.12 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; }
|
||||
/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */
|
||||
.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
@@ -590,7 +590,7 @@
|
||||
.lo-body.is-detail .lo-detail-wrap { display: flex; }
|
||||
.lo-detail-back {
|
||||
display: inline-flex; align-self: flex-start; align-items: center; gap: .04rem; margin-bottom: .08rem; padding: .04rem .12rem .04rem .04rem;
|
||||
font: inherit; font-size: max(.15rem, 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
font: inherit; font-size: max(calc(.15 * var(--t)), 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
}
|
||||
.lo-detail-back .icon { width: .2rem; height: .2rem; }
|
||||
}
|
||||
@@ -605,10 +605,10 @@
|
||||
}
|
||||
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
|
||||
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(calc(.18 * var(--t)), 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(calc(.15 * var(--t)), 11px); }
|
||||
.lo .lo-search .field__box { height: max(.56rem, 28px); padding: 0 max(.16rem, 6px); }
|
||||
.lo .lo-search .field__input { font-size: max(.2rem, 12px); }
|
||||
.lo .lo-search .field__input { font-size: max(calc(.2 * var(--t)), 12px); }
|
||||
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
|
||||
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
|
||||
.sp-coarse .lo-seg button { min-height: 28px; }
|
||||
@@ -617,15 +617,15 @@
|
||||
/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
|
||||
it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
|
||||
else, and `.lo > *` needs no exception for it. Below: the payload textarea + the hidden input behind 选择文件. */
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(.18rem, 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(calc(.18 * var(--t)), 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(calc(.22 * var(--t)), 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint strong { color: var(--gold); }
|
||||
.lo-io__text {
|
||||
box-sizing: border-box; width: 100%; height: max(2.4rem, 160px); min-height: 120px; resize: vertical;
|
||||
padding: max(.1rem, 8px) max(.12rem, 10px);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
/* 16 px: iOS zooms into a focused field below that (same reason as ui/clipboard.js) */
|
||||
font-size: max(.2rem, 16px); line-height: 1.45;
|
||||
font-size: max(calc(.2 * var(--t)), 16px); line-height: 1.45;
|
||||
color: var(--text-hi); background: rgba(4, 10, 8, .55);
|
||||
border: 1px solid var(--line-2); border-radius: 2px; outline: none;
|
||||
/* wrap instead of a horizontal scrollbar: phones are narrow and the payload is meant to be read/copied whole */
|
||||
@@ -639,3 +639,14 @@
|
||||
.lo-voice { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; padding: .14rem 0; font-size: calc(.18 * var(--t)); }
|
||||
.lo-voice > small { flex-basis: 100%; color: var(--text-dim); font-size: calc(.15 * var(--t)); }
|
||||
.lo-voice .lo-select { min-width: 1.6rem; max-width: 100%; }
|
||||
|
||||
/* DIY uses the same aligned rows and detail sections as operator loadout. */
|
||||
.diy-pick__list .diy-opt { min-height: 64px; padding: .1rem; }
|
||||
.diy-pick__list .diy-opt__txt { flex: 1; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: .1rem; }
|
||||
.diy-pick__list .diy-opt__top { flex-wrap: wrap; }
|
||||
.diy-pick__detail > .lo-sec { flex: none; }
|
||||
@media (max-width: 900px) {
|
||||
.diy-pick__list .diy-opt__txt { display: flex; flex-direction: column; align-items: flex-start; gap: .04rem; }
|
||||
}
|
||||
|
||||
.diy-choice__tags { display: flex; flex-wrap: wrap; gap: .08rem; color: var(--text-lo); font-size: max(calc(.12 * var(--t)), 10px); }
|
||||
@@ -10,6 +10,7 @@
|
||||
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
|
||||
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
|
||||
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
|
||||
.result__summary { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; min-height: 0; width: 100%; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
|
||||
.result__headline { font-size: calc(.96 * var(--t)); font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
|
||||
.result.is-lose .result__headline { color: var(--red-premium); text-shadow: 0 0 .4rem rgba(255, 60, 60, .4); }
|
||||
@@ -74,5 +75,5 @@
|
||||
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: calc(.13 * var(--t)); color: var(--text-lo); white-space: nowrap; min-width: 0; }
|
||||
.rstat b { font-size: calc(.18 * var(--t)); color: #fff; }
|
||||
.rstat.is-best b { color: var(--gold); }
|
||||
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot { flex: 0 0 auto; margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot .btn { min-width: 3.4rem; }
|
||||
+6
-1
@@ -1207,5 +1207,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
|
||||
"同意重刷": "Agree to reroll",
|
||||
"拒绝重刷": "Reject reroll",
|
||||
"取消投票": "Cancel vote"
|
||||
"取消投票": "Cancel vote",
|
||||
"只能部署在召唤者相邻四格": "Can only be deployed on the four tiles adjacent to the summoner",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
|
||||
"库存不足": "Out of stock"
|
||||
}
|
||||
+14
-9
@@ -200,7 +200,7 @@
|
||||
"该队友当前没有战场": "この同盟者には現在戦場がありません",
|
||||
"队友与你在同一战场,使用 ‹ › 切换视角": "同盟者はあなたと同じ戦場にいます。‹ › で視点を切り替えます",
|
||||
"无法查看另一组队友的战场": "もう一組の同盟者の戦場は確認できません",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。戦闘終了後に確認できます",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。作戦終了後に確認できます",
|
||||
"无人在家": "不在",
|
||||
"你自己": "自分",
|
||||
"全景": "全体",
|
||||
@@ -362,7 +362,7 @@
|
||||
"{owner} 的盟约": "{owner}の盟約",
|
||||
"我的盟约": "自分の盟約",
|
||||
"(含调和 +{harmony})": "(調和 +{harmony} を含む)",
|
||||
"层数叠加已禁用": "加算数は増加しません",
|
||||
"层数叠加已禁用": "盟約加算無効化中",
|
||||
"调和": "調和",
|
||||
"层数": "加算数",
|
||||
"盟约效果": "盟約効果",
|
||||
@@ -408,8 +408,8 @@
|
||||
"再次点击": "もう一度タップ",
|
||||
"选择中": "選択中",
|
||||
"队友作战进度": "同盟者の戦闘状況",
|
||||
"作战结束": "戦闘終了",
|
||||
"作战结束,等待队友完成作战": "戦闘終了。同盟者の戦闘終了を待っています",
|
||||
"作战结束": "作戦終了",
|
||||
"作战结束,等待队友完成作战": "作戦終了。同盟者の作戦終了を待っています",
|
||||
"你已被淘汰,正在观战": "あなたは脱落しました。観戦中",
|
||||
"上一个战场": "前の戦場",
|
||||
"下一个战场": "次の戦場",
|
||||
@@ -442,7 +442,7 @@
|
||||
"辅助": "補助",
|
||||
"特种": "特殊",
|
||||
"召唤物": "召喚物",
|
||||
"自动回复": "自動回復",
|
||||
"自动回复": "自然回復",
|
||||
"攻击回复": "攻撃回復",
|
||||
"受击回复": "被撃回復",
|
||||
"被动": "パッシブ",
|
||||
@@ -518,7 +518,7 @@
|
||||
"团队增益": "連合バフ",
|
||||
"生效中的效果": "発動中の効果",
|
||||
"表情": "スタンプ",
|
||||
"交流": "通信",
|
||||
"交流": "交流",
|
||||
"上一组表情": "前のスタンプ",
|
||||
"下一组表情": "次のスタンプ",
|
||||
"表情主题": "スタンプのテーマ",
|
||||
@@ -573,7 +573,7 @@
|
||||
"被源石侵蚀的假想敌": "源石に侵食された仮想敵",
|
||||
"回合开始": "ラウンド開始",
|
||||
"回合结算": "ラウンド集計",
|
||||
"休息一下": "ひと休み",
|
||||
"休息一下": "休憩タイム",
|
||||
"找不到该单位": "ユニットが見つかりません",
|
||||
"无法放置在该位置": "この位置には置けません",
|
||||
"位置未变化": "位置が変わっていません",
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络": "インポート失敗:ゲームデータが読み込まれていないため、何も変更していません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください",
|
||||
"游戏数据没有载入": "ゲームデータが読み込まれていません",
|
||||
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
|
||||
"全员无伤!": "全員無傷!",
|
||||
"全员无伤!": "完璧な作戦!",
|
||||
"生命值减少 −{n}": "耐久値減少 −{n}",
|
||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
|
||||
"统计数据": "統計データ",
|
||||
@@ -1213,5 +1213,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
|
||||
"同意重刷": "再抽選に同意",
|
||||
"拒绝重刷": "再抽選を拒否",
|
||||
"取消投票": "投票を取り消す"
|
||||
"取消投票": "投票を取り消す",
|
||||
"只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
|
||||
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
|
||||
"库存不足": "在庫不足"
|
||||
}
|
||||
+6
-1
@@ -1209,5 +1209,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.",
|
||||
"同意重刷": "다시 추첨에 동의",
|
||||
"拒绝重刷": "다시 추첨 거부",
|
||||
"取消投票": "투표 취소"
|
||||
"取消投票": "투표 취소",
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다",
|
||||
"库存不足": "재고 부족"
|
||||
}
|
||||
@@ -1211,5 +1211,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
|
||||
"同意重刷": "同意重刷",
|
||||
"拒绝重刷": "拒絕重刷",
|
||||
"取消投票": "取消投票"
|
||||
"取消投票": "取消投票",
|
||||
"只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
|
||||
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
|
||||
"库存不足": "庫存不足"
|
||||
}
|
||||
+2
-1
@@ -840,10 +840,11 @@ export function createAssets(options) {
|
||||
});
|
||||
|
||||
/** Image element (cached; failures resolve to null). */
|
||||
function image(u) {
|
||||
function image(u, { retry = false } = {}) {
|
||||
const s = str(u);
|
||||
if (!s) return Promise.resolve(null);
|
||||
let e = images.get(s);
|
||||
if (retry && e?.done && !e.value) { images.delete(s); e = null; }
|
||||
if (!e) {
|
||||
e = { value: null, done: false, promise: null };
|
||||
e.promise = Promise.resolve().then(() => loadImage(s)).then(
|
||||
|
||||
+22
-6
@@ -432,6 +432,13 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
|
||||
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
let boardRetry = null;
|
||||
function retryBoard() {
|
||||
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
|
||||
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
|
||||
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
|
||||
.finally(() => { boardRetry = null; });
|
||||
}
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
|
||||
// manifest arrives late)
|
||||
let shadowAsked = false;
|
||||
@@ -797,8 +804,16 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -806,11 +821,10 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
@@ -1747,6 +1761,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
retryBoard();
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
@@ -1758,6 +1773,7 @@ export async function createFieldView(host, options = {}) {
|
||||
setupCleanup.push(() => offAssets?.());
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
retryBoard();
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
|
||||
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
|
||||
export const THREE_URL = '/vendor/three.module.js';
|
||||
|
||||
let threePromise = null;
|
||||
let threeFailures = 0;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||||
if (threeFailures >= 3) return Promise.resolve(null);
|
||||
const source = new URL(url, import.meta.url);
|
||||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||||
return m;
|
||||
});
|
||||
threePromise = pending;
|
||||
}
|
||||
return threePromise;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ async function fetchJson(url) {
|
||||
*/
|
||||
export function loadBoardPack(assets) {
|
||||
if (cached) return cached;
|
||||
cached = (async () => {
|
||||
const pending = (async () => {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!isObj(manifest)) return null;
|
||||
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
|
||||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||||
const u = url(g, n);
|
||||
if (!u) return;
|
||||
const img = await assets.image(u).catch(() => null);
|
||||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||||
}));
|
||||
if (!images.D) return null;
|
||||
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
|
||||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||||
};
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
|
||||
return cached;
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||||
// An older attempt finishing after reset must not discard a newer cached request.
|
||||
if (!pack && cached === pending) cached = null;
|
||||
return pack;
|
||||
});
|
||||
cached = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
@@ -116,7 +116,7 @@ export function normalizeSnapshot(snap) {
|
||||
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
|
||||
}
|
||||
}
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units);
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
|
||||
let neg = null;
|
||||
if (Array.isArray(snap.neg)) {
|
||||
for (const e of snap.neg) {
|
||||
@@ -147,7 +147,7 @@ export function normalizeSnapshot(snap) {
|
||||
(standCut || (standCut = new Map())).set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, neg, stand, standCut, raw: snap };
|
||||
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +402,7 @@ export class SnapshotBuffer {
|
||||
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
|
||||
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
|
||||
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
|
||||
o.coins = A.coins?.get(id) ?? null;
|
||||
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
|
||||
o.seen = stamp;
|
||||
}
|
||||
|
||||
@@ -477,6 +477,8 @@ export class UnitView {
|
||||
this.sp = 0; this.spMax = 0;
|
||||
this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
|
||||
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
|
||||
this.coins = null; // [balance, cap] of 琳琅诗怀雅's skill purse, including zero
|
||||
this.coinHud = null;
|
||||
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
|
||||
this.flags = 0; this.anim = ANIM.IDLE;
|
||||
this.statuses = new Set();
|
||||
@@ -825,6 +827,7 @@ export class UnitView {
|
||||
this.hp = hp;
|
||||
this.sp = s.sp; this.spMax = s.spMax;
|
||||
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
|
||||
this.coins = s.coins || null;
|
||||
const prevFlags = this.flags;
|
||||
this.flags = s.flags | 0;
|
||||
this.anim = s.anim | 0;
|
||||
@@ -1412,7 +1415,8 @@ export class UnitView {
|
||||
// knocked-down operator (b.snap `down`)
|
||||
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
|
||||
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t);
|
||||
const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t);
|
||||
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
|
||||
// blocked marker at the feet (enemies held by a blocker)
|
||||
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
|
||||
@@ -1493,6 +1497,26 @@ export class UnitView {
|
||||
return d + 2 + WOLF_PIPS.pad;
|
||||
}
|
||||
|
||||
/** Coin icon and balance/cap, read from the current snapshot rather than transient FX. */
|
||||
_updateCoins(show, x, top, s) {
|
||||
let r = this.coinHud;
|
||||
if (!show) { if (r) r.root.visible = false; return 0; }
|
||||
if (!r) {
|
||||
const P = this.P, root = new P.Container(), icon = new P.Sprite(fxAtlas().tex.coin);
|
||||
icon.width = icon.height = 16;
|
||||
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 });
|
||||
text.position.set(19, -2); root.addChild(icon, text); this.hud.addChild(root);
|
||||
r = this.coinHud = { root, text, label: '' };
|
||||
}
|
||||
const label = `${this.coins[0]}/${this.coins[1]}`;
|
||||
if (r.label !== label) { r.label = label; r.text.text = label; }
|
||||
const size = clamp(s * 0.2, 10, 16);
|
||||
r.root.scale.set(size / 16); r.root.position.set(x, top + 3); r.root.visible = true;
|
||||
// Text includes font metrics and stroke beyond its nominal 16 px size; reserve the measured row so the next
|
||||
// elemental gauge cannot overlap it (especially with a substituted browser font).
|
||||
return Math.max(16, r.root.getLocalBounds?.().height || 24) * (size / 16) + 8;
|
||||
}
|
||||
|
||||
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
|
||||
elementCooling() {
|
||||
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
||||
|
||||
@@ -14,9 +14,10 @@ import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText, skillTags } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
import { CultivationSelects } from './cultivation.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -107,14 +108,16 @@ function OptionCard({ m, opt, selected, onSel }) {
|
||||
}
|
||||
|
||||
/** A skill choice of an owned pick (or the locked skill of a prototype). */
|
||||
function SkillRow({ m, s, on, locked, onPick }) {
|
||||
const rec = s.elite || s.normal;
|
||||
function SkillRow({ m, s, on, locked, onPick, level }) {
|
||||
const rec = level === 'elite' ? s.elite || s.normal : s.normal || s.elite;
|
||||
const tags = skillTags(rec);
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
|
||||
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
|
||||
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
|
||||
<span class="diy-choice__tags"><span>${tags.sp}</span>${!tags.passive ? html`<span>${t('初始')} ${tags.init} · ${t('消耗')} ${tags.cost}</span>` : null}${tags.duration ? html`<span>${tags.duration}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${rec?.descRaw || rec?.desc || ''} />
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
@@ -143,6 +146,7 @@ function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function DiyPicker(props) {
|
||||
const [previewLevel, setPreviewLevel] = useState('elite');
|
||||
const cur = props.picks[props.slot.slotId] || null;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -161,14 +165,14 @@ export function DiyPicker(props) {
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [props.onClose]);
|
||||
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
return DiyPickerView({ ...props, previewLevel, onPreviewLevel: setPreviewLevel, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
|
||||
* made (null = none chosen yet) and their setters.
|
||||
*/
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft, ops = {}, onOps = null, previewLevel = 'elite', onPreviewLevel = () => {} }) {
|
||||
const D = diyData();
|
||||
const cur = picks[slot.slotId] || null;
|
||||
const options = pickOptions(slot.slotId, picks, D, kitted);
|
||||
@@ -185,6 +189,11 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
|
||||
const unit = draft ? D.backups?.units?.[draft.charId] : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
const potential = ops[draft?.charId]?.potential ?? 6;
|
||||
const base = draft ? slotRecord(slot.slotId, draft, D, { potential }) : null;
|
||||
const golden = draft ? slotRecord(slot.slotId, draft, D, { elite: true, potential }) : null;
|
||||
const previewEntries = base ? { [base.baseId || base.chessId]: { skill: skillOn, module: modOn || 'none' } } : {};
|
||||
const previewChess = (id) => id === golden?.chessId ? golden : base;
|
||||
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
|
||||
<header class="diy-pick__head">
|
||||
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
|
||||
@@ -212,9 +221,12 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
|
||||
<${KindTag} proto=${ch.proto} />
|
||||
</div>
|
||||
${!ch.proto && onOps ? html`<section class="lo-sec"><header class="lo-sec__head"><h3>${t('潜能与练度')}</h3></header><${CultivationSelects} charId=${draft.charId} ops=${ops} onSet=${onOps} /></section>` : null}
|
||||
<${LoadoutStats} base=${base} golden=${golden} entries=${previewEntries} level=${previewLevel}
|
||||
onLevel=${onPreviewLevel} getChess=${previewChess} ops=${ch.proto ? null : ops} />
|
||||
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
|
||||
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} level=${previewLevel} on=${s.index === skillOn} locked=${ch.proto}
|
||||
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
|
||||
@@ -264,7 +276,7 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} ops=${ops} onOps=${onOps} onClose=${() => setPicking(null)}
|
||||
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
|
||||
</main>`;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
battleOverSfx, uniteResultBox, battleResultBox,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
@@ -408,8 +408,22 @@ function MatchScreen() {
|
||||
live.current.deviceBattle = !!shownField && !shownField.prep;
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
const enteredViewRef = useRef(null);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
useEffect(() => {
|
||||
// Read before a view change clears the refs. A later refresh of this same field (g.watch / reshow replaces the
|
||||
// field object) must still know which battle the device units already noted belong to.
|
||||
const previousFieldId = lastFieldRef.current;
|
||||
const previousDevices = deviceUnitsRef.current;
|
||||
if (enteredViewRef.current !== view) {
|
||||
// A replacement engine has no field, even when the match's m.field object did not change. While it loads,
|
||||
// buffer frames instead of sending them to a null/destroyed view; its subscription below asks for fresh meta.
|
||||
enteredViewRef.current = view;
|
||||
enteredFieldRef.current = null;
|
||||
lastFieldRef.current = null;
|
||||
viewModeRef.current = null;
|
||||
reentryRef.current = null;
|
||||
}
|
||||
if (!view) return;
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
@@ -442,10 +456,18 @@ function MatchScreen() {
|
||||
reentryRef.current = null;
|
||||
viewModeRef.current = 'battle';
|
||||
snapUnitsRef.current = new Map();
|
||||
view.enterBattle(field);
|
||||
const early = evBufRef.current.get(field.fieldId);
|
||||
evBufRef.current.delete(field.fieldId);
|
||||
deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
// Crates and turrets are absent from a start-of-battle meta and arrive once, as spawn events. Re-entering the
|
||||
// same field from a refresh that repeats that meta (the subscription below asks for one) must not drop them.
|
||||
const noted = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
deviceUnitsRef.current = deviceUnitsForEntry(previousDevices, noted, previousFieldId != null && previousFieldId === field.fieldId);
|
||||
// The renderer clears its unit metadata on entry too. Restore known devices there as well as in the card lookup;
|
||||
// otherwise their next HP tuple becomes an unknown enemy whose pieceClick consumes the device's tileClick.
|
||||
const units = Array.isArray(field.units) ? [...field.units] : [];
|
||||
const ids = new Set(units.map((u) => u?.id));
|
||||
for (const [id, info] of deviceUnitsRef.current) if (!ids.has(id)) units.push(info);
|
||||
view.enterBattle({ ...field, units });
|
||||
const earlySnap = snapBufRef.current.get(field.fieldId);
|
||||
snapBufRef.current.delete(field.fieldId);
|
||||
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
||||
@@ -565,6 +587,15 @@ function MatchScreen() {
|
||||
};
|
||||
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
|
||||
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
|
||||
// Metadata must include enemies/tokens spawned before this renderer existed, not only events buffered during
|
||||
// loading. Subscribe first so the reshow's metadata and snapshot take the ordinary buffered re-entry path.
|
||||
if (view && !showPrep && field?.fieldId) {
|
||||
if (field.local) battleRunner?.reshow();
|
||||
else if (combat || watchingOther) {
|
||||
const who = watchWho?.fieldId === field.fieldId ? watchWho.playerId : null;
|
||||
net.request('g.watch', { fieldId: field.fieldId, ...(who ? { playerId: who } : {}) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
|
||||
}, [view]);
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { chessStatsBlock, traitText, chessTalents, GarrisonBlock } from '../ui/detailPanel.js';
|
||||
import { chessLoadout } from '../ui/gameLogic.js';
|
||||
import { GarrisonBlock } from '../ui/detailPanel.js';
|
||||
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
@@ -44,6 +43,8 @@ import { atPotential } from '../../../shared/potential.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { DiyPanel, diyData } from './diy.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
export { LoadoutStats, statsPreview } from '../ui/loadoutStats.js';
|
||||
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
|
||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
@@ -301,61 +302,9 @@ export function ModuleInfo({ m, golden, opt, potential = null }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
|
||||
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
|
||||
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
|
||||
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
|
||||
* stats. Nothing is recomputed here.
|
||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||
* @param {(id: string) => any} getChess
|
||||
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
|
||||
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
|
||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||
*/
|
||||
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const chess = elite ? golden : base;
|
||||
if (!chess) return null;
|
||||
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
|
||||
const record = lo?.record || chess;
|
||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
|
||||
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
|
||||
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
|
||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||
*/
|
||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
|
||||
const pv = statsPreview(base, golden, entries, level, getChess, ops);
|
||||
if (!pv) return null;
|
||||
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
|
||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
|
||||
</div>
|
||||
</header>
|
||||
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
|
||||
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
|
||||
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
|
||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>` : null}
|
||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
|
||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||
|
||||
@@ -309,25 +309,28 @@ export function LobbyScreen() {
|
||||
<div class="topbar__left">
|
||||
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<span class="topbar__sep" aria-hidden="true"></span>
|
||||
<div class="topbar__tools">
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" square=${true} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" square=${true} />
|
||||
<${PwaInstallButton} class="lobby-pwa" square=${true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} class="lobby-pwa" />
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
<div class="me-chip__text">
|
||||
<span class="me-chip__text">
|
||||
<span class="me-chip__name">${me.name || t('博士')}</span>
|
||||
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<${Icon} name="chart" class="me-chip__stats" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ export function ResultView({ res, pub = null, myId = null, backLabel, onBack, qu
|
||||
<div class="result__grid" aria-hidden="true"></div>
|
||||
<main class="result__main">
|
||||
<section class="result__hero">
|
||||
<div class="result__summary">
|
||||
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
|
||||
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
|
||||
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
|
||||
@@ -158,6 +159,7 @@ export function ResultView({ res, pub = null, myId = null, backLabel, onBack, qu
|
||||
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
|
||||
</div>
|
||||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||||
</div>
|
||||
<footer class="result__foot">
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||||
</footer>
|
||||
|
||||
@@ -91,13 +91,13 @@ export function pickBusy(busyIdx, card, mine) {
|
||||
|
||||
/**
|
||||
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null, soldOut?: boolean } | null | undefined} card
|
||||
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
|
||||
if (!sp || !card) return false;
|
||||
const myTurn = solo || sp.turnPid === myId;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && !card.soldOut && busyIdx == null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +218,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
|
||||
const busy = pickBusy(busyIdx, card, mine);
|
||||
const isArmed = can && armed === card.idx;
|
||||
const takerName = taker ? (card.takenBy === myId ? t('你') : taker.name) : null;
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, (card.takenBy || card.soldOut) && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
|
||||
aria-label=${isArmed ? t('{name},已选中,再次点击确认', { name: r.name }) : takerName ? t('{name},{takerName}已选择', { name: r.name, takerName }) : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
<span class="spcard__glow" aria-hidden="true"></span>
|
||||
@@ -233,6 +233,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
|
||||
</span>
|
||||
</span>
|
||||
<${RichText} text=${r.desc} class="spcard__desc" />
|
||||
${card.soldOut && !taker ? html`<span class="spcard__busy" role="status">${t('库存不足')}</span>` : null}
|
||||
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
|
||||
${taker ? html`<span class="spcard__taker" title=${t('{name} 已选择', { name: taker.name })}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
|
||||
${isArmed ? html`<span class="spcard__confirm" role="status"><b>${t('确认选择')}</b><small>${t('再次点击')}</small></span>` : null}
|
||||
|
||||
@@ -76,6 +76,8 @@ export const ICONS = {
|
||||
wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' },
|
||||
expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' },
|
||||
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
|
||||
// 添加到桌面 (add to home screen): a house outline with a + inside
|
||||
homePlus: { d: 'M12 2 22 10.5V21H2V10.5zm0 2.6-8 6.8V19h16v-7.6zM11 10h2v2.5h2.5v2H13V17h-2v-2.5H8.5v-2H11z', eo: true },
|
||||
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
|
||||
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
|
||||
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
|
||||
|
||||
@@ -163,13 +163,17 @@ export function FullscreenButton({ class: cls = '' }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
|
||||
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
|
||||
/**
|
||||
* Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet.
|
||||
* `square`: the glyph alone (the lobby's top bar, beside ⚙ / 📖), the name stays title / aria-label.
|
||||
*/
|
||||
export function PwaInstallButton({ class: cls = '', size = 'sm', square = false }) {
|
||||
const [available, setAvailable] = useState(pwaInstall.available);
|
||||
useEffect(() => pwaInstall.subscribe(setAvailable), []);
|
||||
if (!available) return null;
|
||||
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
|
||||
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
|
||||
return html`<${Button} variant="secondary" size=${size} icon="homePlus" square=${square} class=${cls}
|
||||
title=${t('添加到桌面')} aria-label=${t('添加到桌面')}
|
||||
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${square ? null : t('添加到桌面')}<//>`;
|
||||
}
|
||||
|
||||
export function updateRotateHintPwaI18n(doc = globalThis.document) {
|
||||
|
||||
@@ -147,9 +147,9 @@ export function pickOptions(slotId, picks, data, kitted) {
|
||||
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
|
||||
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
|
||||
*/
|
||||
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
|
||||
export function slotRecord(slotId, pick, data, { elite = false, potential = null } = {}) {
|
||||
if (!isObj(pick)) return null;
|
||||
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
|
||||
try { return diyRecord(slotId, pick, { elite, data, potential }); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
|
||||
|
||||
@@ -332,6 +332,16 @@ export function createFallbackView(host, opts = {}) {
|
||||
const now = performance.now();
|
||||
if (st.mode === 'prep' && st.priv) {
|
||||
const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; };
|
||||
for (const u of Array.isArray(st.priv.prepMapChars) ? st.priv.prepMapChars : []) {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y) || typeof u.defId !== 'string') continue;
|
||||
const pos = tilePos(L, u.y, u.x);
|
||||
const src = tokenAvatarUrl(m(), u.defId);
|
||||
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
|
||||
title=${u.name || u.defId}>
|
||||
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
|
||||
</div>`);
|
||||
}
|
||||
for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) {
|
||||
if (!p || !Number.isInteger(p.row)) continue;
|
||||
const pos = heldPos(p) || tilePos(L, p.row, p.col);
|
||||
|
||||
@@ -268,31 +268,46 @@ export async function mountFieldView(host, { signal } = {}) {
|
||||
*/
|
||||
export function useFieldView(hostRef) {
|
||||
const [state, setState] = useState({ view: null, kind: 'loading' });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retries = useRef(0);
|
||||
const viewRef = useRef(null);
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
let dead = false, retrying = false;
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
setState({ view: null, kind: 'loading' });
|
||||
const controller = new AbortController();
|
||||
mountFieldView(host, { signal: controller.signal }).then((view) => {
|
||||
if (dead) { view.destroy(); return; }
|
||||
viewRef.current = view;
|
||||
if (view.kind === 'engine') retries.current = 0;
|
||||
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
|
||||
setState({ view, kind: view.kind });
|
||||
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
|
||||
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
|
||||
const onResize = () => viewRef.current?.resize();
|
||||
// A temporary network/vendor failure must not pin this mounted match to fallback forever. Retry on a useful
|
||||
// user event, at most twice in succession; the effect cleanup disposes the old view before creating its successor.
|
||||
const onRetry = () => {
|
||||
if (dead || retrying || viewRef.current?.kind !== 'fallback' || renderPref() === 'fallback'
|
||||
|| document.visibilityState !== 'visible' || globalThis.navigator?.onLine === false || retries.current >= 2) return;
|
||||
retrying = true; retries.current++; setAttempt((n) => n + 1);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('online', onRetry);
|
||||
document.addEventListener('visibilitychange', onRetry);
|
||||
return () => {
|
||||
dead = true;
|
||||
controller.abort();
|
||||
unsub();
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('online', onRetry);
|
||||
document.removeEventListener('visibilitychange', onRetry);
|
||||
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
|
||||
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [attempt]);
|
||||
return state;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
|
||||
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
||||
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
|
||||
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
||||
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
|
||||
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry } from './gameLogic/terrain.js';
|
||||
export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../../shared/highGround.js';
|
||||
import { summonPlacementGrid } from '../../../../shared/summonPlacement.js';
|
||||
import { pieceDir, rangeTiles } from '../facing.js';
|
||||
import { isObj, tileKey } from './shared.js';
|
||||
import { boardTileOf, fieldTile } from './camera.js';
|
||||
@@ -250,15 +251,18 @@ export function tileAllows(ctx, piece, row, col) {
|
||||
/**
|
||||
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
|
||||
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
|
||||
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when
|
||||
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile).
|
||||
* Jessica's shield uses the four-adjacent-tile grid instead, independent of facing/skill/module. Null when
|
||||
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
|
||||
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
export function summonRange(ctx, piece, owner = null) {
|
||||
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
||||
return ownerRangeOf(ctx, piece, owner);
|
||||
if (!isObj(piece) || piece.kind !== 'token') return null;
|
||||
const grid = summonPlacementGrid(piece.id);
|
||||
if (!grid && ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
||||
return ownerRangeOf(ctx, piece, owner, grid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,14 +277,14 @@ export function summonExcluded(ctx, piece, owner = null) {
|
||||
return ownerRangeOf(ctx, piece, owner);
|
||||
}
|
||||
|
||||
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
|
||||
function ownerRangeOf(ctx, piece, owner = null) {
|
||||
/** The attack or fixed placement range of a summon piece's owner on the board, or null. */
|
||||
function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) {
|
||||
let at = owner;
|
||||
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
|
||||
const rec = at && ctx.getChess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
let grid = null;
|
||||
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
try { grid = placementGrid || attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
|
||||
}
|
||||
|
||||
@@ -367,7 +371,7 @@ export function canPlace(ctx, uid, target) {
|
||||
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
|
||||
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
|
||||
const range = summonRange(ctx, piece, ownerSwap);
|
||||
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内'));
|
||||
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', summonPlacementGrid(piece.id) ? t('只能部署在召唤者相邻四格') : t('只能部署在召唤者攻击范围内'));
|
||||
const out = summonExcluded(ctx, piece, ownerSwap);
|
||||
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
|
||||
if (src.area === 'board') {
|
||||
|
||||
@@ -296,3 +296,16 @@ export function noteDeviceUnits(map, { units = null, events = null } = {}) {
|
||||
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-unit map after entering a field. A refresh of the same field whose new meta names no device
|
||||
* (the start-of-battle list: crates and turrets arrive as `spawn` and are not repeated) keeps the units
|
||||
* already noted. A meta that names devices replaces the map. A different field does not keep the previous one.
|
||||
* @param {Map<number, any>|null|undefined} previous
|
||||
* @param {Map<number, any>} noted
|
||||
* @param {boolean} sameField
|
||||
*/
|
||||
export function deviceUnitsForEntry(previous, noted, sameField) {
|
||||
if (!(noted instanceof Map) || noted.size > 0 || !sameField) return noted instanceof Map ? noted : new Map();
|
||||
return previous instanceof Map && previous.size > 0 ? previous : noted;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared operator stat preview used by roster loadout and DIY selection.
|
||||
import { html, MicroLabel } from './components.js';
|
||||
import { RichText } from './gameComponents.js';
|
||||
import { chessStatsBlock, traitText, chessTalents } from './detailPanel.js';
|
||||
import { chessLoadout } from './gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
|
||||
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
|
||||
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
|
||||
* stats. Nothing is recomputed here.
|
||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||
* @param {(id: string) => any} getChess
|
||||
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
|
||||
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
|
||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||
*/
|
||||
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const chess = elite ? golden : base;
|
||||
if (!chess) return null;
|
||||
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
|
||||
const record = lo?.record || chess;
|
||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
|
||||
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
|
||||
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
|
||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||
*/
|
||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
|
||||
const pv = statsPreview(base, golden, entries, level, getChess, ops);
|
||||
if (!pv) return null;
|
||||
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
|
||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
|
||||
</div>
|
||||
</header>
|
||||
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
|
||||
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
|
||||
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
|
||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>` : null}
|
||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||
</section>`;
|
||||
}
|
||||
@@ -289,11 +289,12 @@ export function SettingsModal({ open, onClose }) {
|
||||
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
|
||||
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
|
||||
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
|
||||
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props
|
||||
* `square`: the gear alone (the lobby's top bar), the name stays title / aria-label — like GuideButton's.
|
||||
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string, square?: boolean }} props
|
||||
*/
|
||||
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) {
|
||||
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置'), square = false }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label}<//>
|
||||
return html`<${Button} variant=${variant} size=${size} square=${square} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${square ? null : label}<//>
|
||||
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
|
||||
}
|
||||
Reference in new issue
Block a user