Merge branch 'master' into fix/spine-start-anim

This commit is contained in:
Cromemadnd Lancity authored and GitHub committed 2026-10-11 20:27:12 +08:00
commit e2b0aab534
577 files changed
+43650 -10558

No files matched your search

+70 -41
View File
@@ -27,7 +27,7 @@
border: 1px solid var(--btn-bd);
background: var(--btn-bg);
color: var(--btn-fg);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
letter-spacing: .06em;
line-height: 1;
@@ -164,9 +164,9 @@
.btn.is-loading { cursor: default; --btn-fg: var(--text-lo); }
.btn--primary.is-loading { --btn-bg: var(--mint-700); --btn-fg: var(--text-on-mint); }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: .15rem; gap: .07rem; }
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: .22rem; gap: .12rem; }
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: .28rem; letter-spacing: .24em; gap: .16rem; }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: calc(.15 * var(--t)); gap: .07rem; }
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: calc(.22 * var(--t)); gap: .12rem; }
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: calc(.28 * var(--t)); letter-spacing: .24em; gap: .16rem; }
.btn--block { display: flex; width: 100%; }
.btn--square { padding: 0; width: var(--sq, .5rem); }
.btn--square.btn--sm { --sq: .36rem; }
@@ -204,7 +204,7 @@
border-bottom: 1px solid var(--line);
}
.panel__titles { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.panel__title { font-size: .21rem; font-weight: 700; letter-spacing: .04em; }
.panel__title { font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .04em; }
.panel__actions { display: flex; gap: .08rem; align-items: center; }
.panel__body { position: relative; padding: .22rem; min-height: 0; }
.panel--flush .panel__body { padding: 0; }
@@ -472,9 +472,9 @@
opacity: .55;
}
.modal__head { display: flex; flex-direction: column; gap: .04rem; padding: .22rem .3rem .04rem; }
.modal__title { font-size: .26rem; font-weight: 700; letter-spacing: .04em; }
.modal__title { font-size: calc(.26 * var(--t)); font-weight: 700; letter-spacing: .04em; }
.modal__body { padding: .14rem .3rem .26rem; color: var(--text-md); overflow: auto; }
.modal__text { font-size: .18rem; line-height: 1.7; }
.modal__text { font-size: calc(.18 * var(--t)); line-height: 1.7; }
.modal__actions {
flex: none;
display: flex;
@@ -500,7 +500,7 @@
border-left: 2px solid var(--mint-500);
box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .55);
color: var(--text-md);
font-size: .15rem;
font-size: calc(.15 * var(--t));
line-height: 1.55;
pointer-events: none;
opacity: 0;
@@ -515,8 +515,8 @@
/* ---- progress bar ----------------------------------------------------------------------------------- */
.pbar { --pc: var(--mint-500); --ph: .1rem; display: flex; flex-direction: column; gap: .05rem; min-width: .8rem; }
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: .16rem; }
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: calc(.16 * var(--t)); }
.pbar__value small { color: var(--text-dim); font-size: .8em; }
.pbar__track { position: relative; height: var(--ph); background: #080a09; border: 1px solid var(--line); overflow: hidden; }
.pbar__fill {
@@ -549,7 +549,7 @@
border: 0;
background: none;
color: var(--text-lo);
font-size: .17rem;
font-size: calc(.17 * var(--t));
font-weight: 500;
letter-spacing: .04em;
transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast);
@@ -570,11 +570,11 @@
}
.tabs__tab.is-active { color: var(--mint-400); }
.tabs__tab.is-active::after { transform: scaleX(1); }
.tabs__badge { font-family: var(--font-num); font-size: .13rem; padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
.tabs--sm .tabs__tab { font-size: .15rem; padding: .07rem .16rem .09rem; }
.tabs__badge { font-family: var(--font-num); font-size: calc(.13 * var(--t)); padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
.tabs--sm .tabs__tab { font-size: calc(.15 * var(--t)); padding: .07rem .16rem .09rem; }
/* ---- spinner -------------------------------------------------------------------------------------- */
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: .15rem; }
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); }
.spinner svg { width: var(--sp); height: var(--sp); animation: spin 1.6s linear infinite; overflow: visible; }
.spinner__track { fill: none; stroke: var(--line-2); stroke-width: 3; }
.spinner__arc { fill: none; stroke: var(--sc); stroke-width: 3; stroke-dasharray: 34 98; animation: spinner-dash 1.1s var(--ease-in-out) infinite; filter: drop-shadow(0 0 3px var(--sc)); }
@@ -582,7 +582,7 @@
.spinner--lg { --sp: .56rem; }
.spinner--gold { --sc: var(--gold); }
.spinner--amber { --sc: var(--amber); }
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: .12rem; text-transform: uppercase; }
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: calc(.12 * var(--t)); text-transform: uppercase; }
@keyframes spinner-dash { 0% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -132; } }
/* ---- avatar frame ---------------------------------------------------------------------------------- */
@@ -710,9 +710,9 @@
animation: pb-sweep 700ms 250ms var(--ease-out) forwards;
opacity: 0;
}
.pbanner__micro { font-family: var(--font-display); font-size: .12rem; letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
.pbanner__micro { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
.pbanner__title {
font-size: .56rem;
font-size: calc(.56 * var(--t));
font-weight: 900;
letter-spacing: .16em;
padding-left: .16em;
@@ -720,7 +720,7 @@
color: #fff;
text-shadow: 0 0 .22rem var(--pb-glow), 0 2px 0 rgba(0, 0, 0, .5);
}
.pbanner__sub { font-size: .16rem; color: var(--text-lo); letter-spacing: .12em; }
.pbanner__sub { font-size: calc(.16 * var(--t)); color: var(--text-lo); letter-spacing: .12em; }
.pbanner__chev { --ch-h: .42rem; height: .42rem; flex: none; }
.pbanner--inline .pbanner__band { animation-duration: var(--t-slow); }
.pbanner.is-leaving .pbanner__band { animation: pb-close 240ms var(--ease-in-out) forwards; }
@@ -783,7 +783,7 @@
.rdialog__tick--br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
.rdialog__micro {
font-family: var(--font-display);
font-size: .13rem;
font-size: calc(.13 * var(--t));
letter-spacing: .48em;
padding-left: .48em;
color: var(--rd);
@@ -811,7 +811,7 @@
animation: rd-sweep 780ms 300ms var(--ease-out) forwards;
}
.rdialog__title {
font-size: .62rem;
font-size: calc(.62 * var(--t));
font-weight: 900;
letter-spacing: .14em;
padding-left: .14em;
@@ -821,7 +821,7 @@
}
.rdialog__chev { --ch-h: .46rem; height: .46rem; flex: none; }
.rdialog__sub {
font-size: .18rem;
font-size: calc(.18 * var(--t));
letter-spacing: .1em;
color: var(--text-lo);
text-align: center;
@@ -834,7 +834,7 @@
@keyframes rd-sweep { 0% { left: -25%; opacity: 1; } 100% { left: 115%; opacity: 0; } }
@media (max-width: 720px) {
.rdialog__box { min-width: 0; width: 92vw; padding: .26rem .3rem .22rem; }
.rdialog__title { font-size: .42rem; }
.rdialog__title { font-size: calc(.42 * var(--t)); }
.rdialog__chev { --ch-h: .34rem; height: .34rem; }
}
@media (prefers-reduced-motion: reduce) {
@@ -854,29 +854,29 @@
border: 1px solid var(--line);
color: var(--pc);
font-family: var(--font-num);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.ping__icon { width: .15rem; height: .15rem; margin-right: .03rem; }
.ping__unit { font-size: .13rem; color: var(--text-lo); font-weight: 500; }
.ping__unit { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 500; }
.ping--low { --pc: var(--mint-400); }
.ping--medium { --pc: var(--amber); }
.ping--high { --pc: var(--red-premium); }
.ping--off { --pc: var(--text-dim); }
/* ---- difficulty tag ---------------------------------------------------------------------------------- */
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: .18rem; letter-spacing: .04em; white-space: nowrap; }
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: calc(.18 * var(--t)); letter-spacing: .04em; white-space: nowrap; }
.dtag__icon { width: 1.15em; height: 1.15em; filter: drop-shadow(0 0 4px var(--d-color)); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--d-color) 65%, transparent)); }
.dicon { object-fit: contain; image-rendering: auto; }
.dtag__code { font-family: var(--font-num); font-size: .72em; font-weight: 500; color: var(--text-dim); letter-spacing: .1em; margin-left: .03rem; }
.dtag--sm { font-size: .15rem; }
.dtag--lg { font-size: .24rem; }
.dtag--sm { font-size: calc(.15 * var(--t)); }
.dtag--lg { font-size: calc(.24 * var(--t)); }
/* ---- text field ------------------------------------------------------------------------------------ */
.field { display: flex; flex-direction: column; gap: .07rem; min-width: 0; }
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: .15rem; color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
.field__box {
--bk-c: var(--line-3);
--bk-l: .1rem;
@@ -899,15 +899,15 @@
border: 0;
outline: none;
color: var(--text-hi);
font-size: .2rem;
font-size: calc(.2 * var(--t));
letter-spacing: .04em;
}
.field__input::placeholder { color: var(--text-dim); font-weight: 400; letter-spacing: .04em; }
.field__icon { width: .22rem; height: .22rem; color: var(--text-dim); }
.field__box:focus-within .field__icon { color: var(--mint-500); }
.field__hint { font-size: .13rem; color: var(--text-dim); }
.field__hint { font-size: calc(.13 * var(--t)); color: var(--text-dim); }
.field--lg .field__box { height: .68rem; }
.field--lg .field__input { font-size: .26rem; font-weight: 700; }
.field--lg .field__input { font-size: calc(.26 * var(--t)); font-weight: 700; }
.field--code .field__box { height: .64rem; }
.field--code .field__input { font-family: var(--font-num); font-size: .34rem; font-weight: 700; letter-spacing: .42em; text-transform: uppercase; }
.field--code .field__input::placeholder { font-family: var(--font-cjk); font-size: .18rem; letter-spacing: .06em; font-weight: 400; text-transform: none; }
@@ -943,14 +943,14 @@
border-left: 3px solid var(--tc);
box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem transparent; box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem color-mix(in srgb, var(--tc) 25%, transparent);
color: var(--text-hi);
font-size: .17rem;
font-size: calc(.17 * var(--t));
line-height: 1.45;
cursor: pointer;
animation: toast-in var(--t-med) var(--ease-out);
}
.toast__icon { width: .2rem; height: .2rem; fill: var(--tc); flex: none; }
.toast__text { flex: 1; }
.toast__count { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); }
.toast__count { font-family: var(--font-num); font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.toast--success { --tc: var(--mint-400); }
.toast--warn { --tc: var(--amber); }
.toast--error { --tc: var(--red-premium); }
@@ -973,7 +973,7 @@
border: 1px solid rgba(232, 90, 26, .55);
box-shadow: 0 .08rem .3rem rgba(0, 0, 0, .6), 0 0 .2rem rgba(232, 90, 26, .15);
color: var(--text-hi);
font-size: .16rem;
font-size: calc(.16 * var(--t));
/* own keyframes: toast-in's transform would drop the translateX(-50%) centring mid-animation */
animation: conn-in var(--t-med) var(--ease-out);
}
@@ -990,11 +990,11 @@
.sp-in-match .conn-banner { top: calc(1.34rem + var(--sa-t, 0px)); bottom: auto; }
.sp-in-match.sp-conn .toast-host { top: calc(2.0rem + var(--sa-t, 0px)); }
.conn-banner .icon { width: .22rem; height: .22rem; color: var(--orange-hi); }
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: .15rem; }
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: calc(.15 * var(--t)); }
/* ---- misc shared bits ------------------------------------------------------------------------------ */
.kv { display: flex; align-items: baseline; gap: .08rem; }
.kv__k { color: var(--text-lo); font-size: .14rem; }
.kv__k { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
.kv__v { font-family: var(--font-num); font-weight: 700; }
.divider { height: 1px; background: linear-gradient(90deg, transparent, var(--line-2) 15%, var(--line-2) 85%, transparent); }
.status-dot { width: .09rem; height: .09rem; border-radius: 50%; background: var(--text-dim); flex: none; display: inline-block; }
@@ -1028,16 +1028,45 @@
}
.topbar__left { justify-self: start; display: flex; align-items: center; gap: .18rem; min-width: 0; }
.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: .3rem; font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
.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; }
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: .2rem; font-weight: 700; letter-spacing: .08em; }
.section-label__idx { font-size: .15rem; color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
/* 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: .18rem; max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 {
@@ -1051,4 +1080,4 @@
border: 1px solid var(--line-2);
--bk-c: var(--red-premium);
}
.crash__box h2 { font-size: .3rem; }
.crash__box h2 { font-size: calc(.3 * var(--t)); }
+4 -1
View File
@@ -146,10 +146,13 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.mode-cards { margin-bottom: .2rem; }
.mode-card { min-height: 0; gap: .12rem; padding: .22rem .26rem .2rem; }
.mode-card__icon { width: .66rem; height: .66rem; }
.mode-card__name { font-size: .4rem; }
.mode-card__name { font-size: calc(.4 * var(--t)); }
.mode-card__points, .diff-card__effects, .tips, .create-box__hint { display: none; }
.diff-list { gap: .1rem; }
.diff-card { padding-top: .12rem; padding-bottom: .12rem; }
.create-box { margin-top: .18rem; }
.create-box .btn--xl { height: .8rem; }
}
.rotate-hint__pwa { pointer-events: auto; margin-top: 6px; }
.rotate-hint__pwa[hidden] { display: none; }
+43 -23
View File
@@ -23,18 +23,19 @@
.stephead__left { display: flex; align-items: center; gap: .16rem; }
.stephead__meta { display: flex; flex-direction: column; gap: .06rem; align-items: flex-start; }
.stephead__center { display: flex; flex-direction: column; align-items: center; gap: .04rem; }
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: .34rem; font-weight: 900; letter-spacing: .12em; }
.stephead__title b { font-size: .56rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
.stephead__of { font-size: .26rem; color: var(--text-dim); margin-right: .18rem; }
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .12em; }
.stephead__title b { font-size: calc(.56 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
.stephead__of { font-size: calc(.26 * var(--t)); color: var(--text-dim); margin-right: .18rem; }
.stephead__right { justify-self: end; display: flex; align-items: center; gap: .24rem; }
.stephead__right .countdown { --cd-h: .64rem; }
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: .21rem; font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
.brief-h::before { content: ''; width: .06rem; height: .2rem; background: var(--mint-500); align-self: center; }
/* ---- briefing layout ---- */
.brief__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 6.2rem 1fr; gap: .36rem; padding: .3rem .5rem .1rem; }
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; }
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief__left > * { flex-shrink: 0; }
.brief__right { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief-boss {
@@ -54,14 +55,14 @@
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 2px, transparent 2px 4px), radial-gradient(circle, transparent 45%, rgba(40, 8, 2, .75) 100%); }
.brief-boss__q { font-size: 1rem; font-weight: 900; color: rgba(255, 110, 40, .6); }
.brief-boss__text { display: flex; flex-direction: column; gap: .08rem; padding-top: .2rem; min-width: 0; }
.brief-boss__name { font-size: .34rem; font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
.brief-boss__when { font-size: .15rem; color: var(--orange-hi); }
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.brief-boss__name { font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
.brief-boss__when { font-size: calc(.15 * var(--t)); color: var(--orange-hi); }
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
.brief-stage { display: flex; align-items: center; justify-content: space-between; gap: .2rem; padding: .12rem .18rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-stage__k { display: flex; align-items: baseline; gap: .12rem; min-width: 0; }
.brief-stage__pool { font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: .19rem; font-weight: 700; }
.brief-stage__pool { font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: calc(.19 * var(--t)); font-weight: 700; }
.brief-stage__name .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.brief-factions { display: flex; flex-direction: column; gap: .1rem; min-height: 0; }
@@ -69,38 +70,57 @@
.brief-faction__icon { width: .72rem; height: .72rem; flex: none; display: grid; place-items: center; background: radial-gradient(circle, rgba(246, 163, 41, .16), transparent 70%); }
.brief-faction__icon img { width: 100%; height: 100%; object-fit: contain; }
.brief-faction__text { display: flex; flex-direction: column; gap: .03rem; }
.brief-faction__text b { font-size: .2rem; color: var(--amber); }
.brief-faction__text span { font-size: .15rem; color: var(--text-lo); }
.brief-faction__text b { font-size: calc(.2 * var(--t)); color: var(--amber); }
.brief-faction__text span { font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.brief-bonds { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-bonds__row { display: flex; flex-wrap: wrap; gap: .16rem .1rem; }
.brief-bond { position: relative; width: 1rem; display: flex; justify-content: center; }
.brief-bond .bond { --disc: .66rem; }
.brief-bond .bond__name { font-size: .16rem; }
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: .12rem; font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond { position: relative; min-width: 1rem; width: max-content; max-width: 100%; display: flex; justify-content: center; }
.brief-bond .bond { --disc: .66rem; max-width: 100%; }
.brief-bond .bond__name { font-size: calc(.16 * var(--t)); max-width: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; }
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: .14rem; color: var(--text-dim); margin-top: -.06rem; }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
.brief-legend__ban .icon { width: .12rem; height: .12rem; }
.brief-banned { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); }
.brief-banned__n { margin-left: auto; font-size: calc(.22 * var(--t)); color: var(--red-premium); }
.brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; }
.brief-banned__grid .uthumb { --ut: .6rem; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine; m.private.diyBanned) */
.brief-banned__diy { margin: .1rem 0 0; font-size: .14rem; line-height: 1.5; color: var(--text-dim, #b8c2bf); }
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: .12rem; font-weight: 700;
.brief-banned__diy { margin: .1rem 0 0; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--text-dim, #b8c2bf); }
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: calc(.12 * var(--t)); font-weight: 700;
letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); vertical-align: .01rem; }
.brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; }
.brief-ready { display: flex; align-items: center; gap: .16rem; }
.brief-ready__txt { font-size: .2rem; color: var(--text-lo); }
.brief-ready__txt b { font-size: .4rem; color: var(--mint-400); margin: 0 .02rem; }
.brief-ready__txt span { font-size: .24rem; color: var(--text-dim); }
.brief-ready__txt { font-size: calc(.2 * var(--t)); color: var(--text-lo); }
.brief-ready__txt b { font-size: calc(.4 * var(--t)); color: var(--mint-400); margin: 0 .02rem; }
.brief-ready__txt span { font-size: calc(.24 * var(--t)); color: var(--text-dim); }
.brief-ready__pips { display: flex; gap: .06rem; }
.brief-ready__pips i { width: .3rem; height: .3rem; display: grid; place-items: center; color: var(--text-dim); }
.brief-ready__pips i .icon { width: 100%; height: 100%; }
.brief-ready__pips i.on { color: var(--mint-400); filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .6)); }
.brief-ready__pips i.me { border-bottom: 2px solid var(--mint-700); }
/* Full setup reroll: consent stays visible alongside the information being confirmed. */
.brief-reroll {
position: relative; z-index: 2; flex: none;
display: flex; align-items: center; justify-content: space-between; gap: .2rem;
margin: .1rem .5rem 0; padding: .14rem .2rem;
background: rgba(12, 15, 14, .9); border: 1px solid var(--mint-700);
}
.brief-reroll__text { display: flex; flex-direction: column; gap: .05rem; min-width: 0; font-size: calc(.16 * var(--t)); }
.brief-reroll__text strong { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.brief-reroll__text > span { color: var(--text-lo); }
.brief-reroll__players { display: flex; flex-wrap: wrap; gap: .08rem .2rem; color: var(--text-dim); }
.brief-reroll__players .is-agreed { color: var(--mint-400); }
.brief-reroll__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .1rem; flex-shrink: 0; }
@media (max-width: 900px) {
.brief-reroll { margin-inline: .3rem; gap: .1rem; padding: .1rem .14rem; }
.brief-reroll__actions { flex-shrink: 1; }
.brief-reroll__actions .btn { min-height: .44rem; padding-inline: .12rem; }
}
+18 -18
View File
@@ -13,11 +13,11 @@
transition: border-color var(--t-med), background var(--t-med);
}
.dorder .pavatar { --pa: .66rem; }
.dorder__idx { font-size: .22rem; color: var(--text-dim); width: .26rem; text-align: center; }
.dorder__idx { font-size: calc(.22 * var(--t)); color: var(--text-dim); width: .26rem; text-align: center; }
.dorder__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.dorder__name { font-size: .18rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
.dorder__ai { font-family: var(--font-num); font-size: .11rem; padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.dorder__state { font-size: .14rem; }
.dorder__name { font-size: calc(.18 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
.dorder__ai { font-family: var(--font-num); font-size: calc(.11 * var(--t)); padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.dorder__state { font-size: calc(.14 * var(--t)); }
.dorder__state span { display: inline-flex; align-items: center; gap: .04rem; }
.dorder__state .icon { width: .16rem; height: .16rem; }
.dorder__box { position: relative; width: .6rem; height: .6rem; display: grid; place-items: center; border: 1px dashed var(--line-2); }
@@ -28,7 +28,7 @@
.dorder.is-cur { border-color: var(--gold); background: linear-gradient(90deg, rgba(255, 198, 0, .12), rgba(12, 15, 14, .82)); box-shadow: 0 0 .2rem rgba(255, 198, 0, .15); }
.dorder.is-done { border-color: var(--mint-800); }
.dorder.is-self .dorder__name { color: var(--mint-400); }
.draft-order__tip { margin-top: auto; font-size: .13rem; line-height: 1.6; color: var(--text-dim); }
.draft-order__tip { margin-top: auto; font-size: calc(.13 * var(--t)); line-height: 1.6; color: var(--text-dim); }
.draft-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(1.36rem, 1fr)); grid-auto-rows: min-content; gap: .12rem;
@@ -41,13 +41,13 @@
}
.dband:hover { transform: translateY(-.03rem); border-color: var(--line-3); }
.dband .bandicon { --bi: 1.1rem; border: 1px solid var(--line-2); }
.dband__name { font-size: .16rem; font-weight: 700; color: var(--text-hi); white-space: nowrap; }
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: .15rem; color: var(--mint-400); }
.dband__name { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-hi); white-space: nowrap; }
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
.dband__lp i { width: .12rem; height: .14rem; background: var(--mint-500); clip-path: polygon(0 0, 30% 0, 30% 20%, 45% 20%, 45% 0, 55% 0, 55% 20%, 70% 20%, 70% 0, 100% 0, 100% 35%, 85% 50%, 85% 80%, 100% 90%, 100% 100%, 0 100%, 0 90%, 15% 80%, 15% 50%, 0 35%); }
.dband__who { position: absolute; right: .04rem; top: .04rem; display: flex; flex-direction: column; gap: .02rem; }
.dband__who .pavatar { --pa: .3rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.dband.is-sel { border-color: var(--mint-400); background: linear-gradient(180deg, rgba(78, 216, 175, .18), rgba(12, 15, 14, .85)); box-shadow: 0 0 0 1px var(--mint-400), 0 0 .24rem rgba(23, 249, 183, .2); }
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: .11rem; font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: calc(.11 * var(--t)); font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
.draft-detail {
display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .26rem .26rem .22rem; min-height: 0;
@@ -55,33 +55,33 @@
}
.draft-detail__art { position: relative; padding: .06rem; border: 1px solid var(--mint-800); background: radial-gradient(circle, rgba(78, 216, 175, .14), transparent 70%); }
.draft-detail__art .bandicon { --bi: 2rem; }
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: .16rem; color: var(--text-lo); }
.draft-detail__name { font-size: .42rem; font-weight: 900; letter-spacing: .1em; }
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
.draft-detail__name { font-size: calc(.42 * var(--t)); font-weight: 900; letter-spacing: .1em; }
.draft-detail__eff { align-self: stretch; display: flex; flex-direction: column; gap: .06rem; padding: .12rem .14rem; background: rgba(0, 0, 0, .3); border-left: 3px solid var(--mint-500); flex: 1; min-height: 0; overflow: auto; }
.draft-detail__eff b { font-size: .2rem; color: var(--mint-400); }
.draft-detail__desc { font-size: .16rem; line-height: 1.7; color: var(--text-md); }
.draft-detail__eff b { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.draft-detail__desc { font-size: calc(.16 * var(--t)); line-height: 1.7; color: var(--text-md); }
.draft-detail__actions { align-self: stretch; display: flex; flex-direction: column; gap: .1rem; }
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.draft-detail__status .icon { width: .18rem; height: .18rem; }
.draft-detail__btns { display: flex; gap: .1rem; }
.draft-detail__btns .btn { flex: 1; }
.dorder__secs { margin-left: .06rem; font-size: .15rem; color: var(--gold); }
.dorder__secs { margin-left: .06rem; font-size: calc(.15 * var(--t)); color: var(--gold); }
/* 队友已选 (research 09 §5): a strategy a teammate picked cannot be chosen again */
.dband.is-taken { cursor: not-allowed; border-color: rgba(246, 163, 41, .35); }
.dband.is-taken .bandicon, .dband.is-taken .dband__name, .dband.is-taken .dband__lp { filter: grayscale(.8) brightness(.62); }
.dband.is-taken:hover { transform: none; }
.dband__taken {
position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) rotate(-8deg); white-space: nowrap; pointer-events: none;
padding: .02rem .12rem; font-size: .15rem; font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
padding: .02rem .12rem; font-size: calc(.15 * var(--t)); font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
}
.draft-detail__status--taken { color: var(--amber); }
/* a strategy built around a bond this mode switches off (bandDraft.js BandOffTag / BandOffNote, DESIGN §21.26 — the
§21.7 本局禁用 look: dim, dashed, the bond struck through): information only, it stays selectable. Px floors keep it
readable on phones (1rem = 40 px). */
.dband__off { font-size: max(.12rem, 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
.dband__off { font-size: max(calc(.12 * var(--t)), 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
.draft-detail__off { align-self: stretch; display: flex; align-items: flex-start; gap: .06rem; margin: 0; padding: .06rem .1rem;
font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
.draft-detail__off .icon { width: max(.16rem, 10px); height: max(.16rem, 10px); flex: none; margin-top: .02rem; }
.draft-detail__offname { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
@@ -92,7 +92,7 @@
footer (the turn's countdown, 关闭) stay in view. */
.draft-order__info {
margin-top: .04rem; justify-content: flex-start; padding: 0 .16rem;
height: max(.5rem, 28px); font-size: max(.18rem, 10px);
height: max(.5rem, 28px); font-size: max(calc(.18 * var(--t)), 10px);
--btn-bd: var(--mint-800); --btn-bg: rgba(78, 216, 175, .06); --btn-bg-hover: rgba(78, 216, 175, .14);
}
.draft-order__info .btn__icon { color: var(--mint-400); }
+97 -95
View File
@@ -44,7 +44,7 @@
.bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; }
.bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); }
.bglyph__txt { font-weight: 900; font-size: .15rem; color: var(--mint-400); }
.bglyph__txt { font-weight: 900; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
.bandicon { --bi: .6rem; display: inline-grid; place-items: center; width: var(--bi); height: var(--bi); flex: none; overflow: hidden; background: #111513; }
.bandicon img { width: 100%; height: 100%; object-fit: cover; }
.bandicon__txt { font-weight: 900; font-size: calc(var(--bi) * .44); color: var(--mint-400); }
@@ -83,23 +83,23 @@
.dhead__icon--enemy.is-elite { border-color: var(--gold-2); }
.dhead__info { display: flex; flex-direction: column; gap: .05rem; min-width: 0; padding-top: .02rem; flex: 1; }
.dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
.dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
.dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
.dhead__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
.dhead__en { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
/* 0.2.0 补位: the replaced operator under a stand-in's name (「银灰的替补」, ui/detailPanel.js ChessDetail) */
.dhead__for { display: block; font-size: max(.13rem, 9px); letter-spacing: .04em; color: var(--ice); }
.dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; }
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; }
.dhead__for { display: block; font-size: max(calc(.13 * var(--t)), 9px); letter-spacing: .04em; color: var(--ice); }
.dhead__flavor { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-style: italic; }
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); margin-top: .06rem; }
.dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); }
.dhead__prof, .dhead__sub { width: .24rem; height: .24rem; object-fit: contain; }
.dhead__pos { font-size: .12rem; padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
.dhead__pos { font-size: calc(.12 * var(--t)); padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
.dtag-elite, .dtag-token, .dtag-kind { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
.dtag-elite { background: var(--gold); color: #1d1400; }
.dtag-token { background: #a9c3d8; color: #0b1520; }
/* 0.2.0 自选编队 / 补位: the card of a 自选 piece, of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
.dtag-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
.dtag-diy { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
.dtag-standin { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
.drank { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; }
.drank--normal { background: var(--bg-4); color: var(--text-md); }
.drank--elite { background: var(--gold-2); color: #1d1400; }
.drank--boss { background: var(--orange); color: #fff; }
@@ -107,7 +107,7 @@
.dhp i { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #2bb65a, #56e08a); }
.dhp--enemy i { background: linear-gradient(90deg, #b5261a, #ff5a4a); }
.dhp span { position: absolute; inset: 0; display: grid; place-items: center; font-size: .12rem; color: #fff; text-shadow: 0 1px 2px #000; }
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: .14rem; line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait .icon { width: .16rem; height: .16rem; margin-top: .03rem; color: var(--text-lo); flex: none; }
.dstats-wrap { display: flex; gap: .12rem; align-items: stretch; }
/* compact 4 × 2 grid (label over value): the skill stays close under the 特质 block (user playtest #3 item 8) */
@@ -115,10 +115,10 @@
.dstats { flex: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.dstats-wrap > .dstats { flex: 1; }
.dstat { display: flex; flex-direction: column; justify-content: center; gap: .01rem; min-width: 0; padding: .04rem .07rem; background: #0f1311; }
.dstat__k { font-size: .12rem; color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dstat__k { font-size: calc(.12 * var(--t)); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
.dstat__v { font-size: calc(.17 * var(--t)); line-height: 1.15; color: #fff; }
.dstat small { font-size: calc(.11 * var(--t)); color: var(--text-dim); margin-left: .04rem; }
/* live stats (user playtest #4 item 7): buffed values green, debuffed red (the official card), the difference beside
them; a 实时 (battle) / 开战时 (prep preview) tag over the grid's right edge */
.dstats.is-live, .dstats-wrap.is-live { margin-top: .14rem; }
@@ -128,7 +128,7 @@
.dstat.is-down .dstat__v { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 84, 84, .3); }
.dstat.is-down small { color: #ff8a80; }
.dstats__tag {
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: .11rem; font-weight: 700; letter-spacing: .1em;
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .1em;
color: var(--text-on-mint); background: var(--mint-500); pointer-events: auto; cursor: help;
}
.dstats__tag.is-battle { background: var(--orange); color: #1d0c00; }
@@ -137,47 +137,47 @@
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
.rgrid i.on { background: rgba(246, 163, 41, .25); border-color: var(--amber); }
.rgrid i.self { background: var(--mint-500); border-color: var(--mint-400); }
.rgrid-all { font-size: .16rem; font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
.rgrid-all { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
.dsec { display: flex; flex-direction: column; gap: .08rem; }
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: .17rem; font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
.dtext { font-size: .15rem; line-height: 1.65; color: var(--text-md); }
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
.dtext { font-size: calc(.15 * var(--t)); line-height: 1.65; color: var(--text-md); }
.dskill { display: flex; gap: .12rem; align-items: center; }
.dskill__icon { width: .62rem; height: .62rem; flex: none; object-fit: cover; border: 1px solid var(--line-3); background: #0a0d0c; }
.dskill__icon--empty { display: grid; place-items: center; }
.dskill__icon--empty b { font-family: var(--font-display); font-size: .2rem; color: var(--mint-400); letter-spacing: .02em; }
.dskill__icon--empty b { font-family: var(--font-display); font-size: calc(.2 * var(--t)); color: var(--mint-400); letter-spacing: .02em; }
.dskill__slot { display: inline-block; margin-right: .06rem; padding: 0 .05rem; vertical-align: .02rem; font-family: var(--font-display);
font-size: .12rem; line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
.dskill__meta { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
.dskill__name { font-size: .19rem; }
.dskill__name { font-size: calc(.19 * var(--t)); }
.dskill__tags { display: flex; flex-wrap: wrap; gap: .06rem; align-items: center; }
.dsp { font-size: .12rem; padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
.dsp { font-size: calc(.12 * var(--t)); padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
.dsp--time { background: #7fd37a; color: #0b1a0a; }
.dsp--atk { background: #ff8a3d; color: #1f0c00; }
.dsp--def { background: #f2bd3e; color: #1f1400; }
.dsp--trig { border: 1px solid var(--line-3); color: var(--text-lo); font-weight: 500; }
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--text-lo); }
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: calc(.13 * var(--t)); color: var(--text-lo); }
.dsp__num b { color: #fff; }
.dsp__num .icon { width: .14rem; height: .14rem; color: var(--gold); }
.dtalent { display: flex; flex-direction: column; gap: .02rem; }
.dtalent b { font-size: .15rem; color: var(--text-hi); }
.dtalent b { font-size: calc(.15 * var(--t)); color: var(--text-hi); }
/* the operator's own effect (特质, user playtest #3 item 8): compact, right under the header, gold accent */
.dgarrison {
display: flex; flex-direction: column; gap: .04rem; padding: .07rem .1rem .08rem; flex: none;
background: linear-gradient(90deg, rgba(242, 189, 62, .1), rgba(242, 189, 62, .02) 70%); border-left: 3px solid var(--gold-2);
}
.dgarrison__head { display: flex; align-items: center; gap: .08rem; }
.dgarrison__k { font-size: .14rem; font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: .12rem; line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__k { font-size: calc(.14 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__icon { width: .18rem; height: .18rem; object-fit: contain; }
.dgarrison__text { font-size: .15rem; line-height: 1.55; color: var(--text-hi); }
.dgarrison__text { font-size: calc(.15 * var(--t)); line-height: 1.55; color: var(--text-hi); }
.dbonds { display: flex; flex-wrap: wrap; gap: .08rem; }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: .15rem; background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: calc(.15 * var(--t)); background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
/* the unit's bonds in the header's right column, right under name / class (user playtest #2 item 9): icon, name,
count / next threshold, tier pips — visible as soon as the card opens */
.dbonds--top { gap: .05rem; margin-top: .06rem; }
.dhead__info .dbonds--top .dbond { font-size: .14rem; padding: .02rem .07rem .02rem .03rem; }
.dhead__info .dbonds--top .dbond { font-size: calc(.14 * var(--t)); padding: .02rem .07rem .02rem .03rem; }
.dhead__info .dbond__icon { width: .24rem; height: .24rem; }
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: .15rem; color: var(--text-md);
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: calc(.15 * var(--t)); color: var(--text-md);
background: rgba(255, 255, 255, .03); border: 1px solid var(--line-2); cursor: default; }
.dbonds--top button.dbond { cursor: pointer; }
.dbonds--top button.dbond:hover { border-color: var(--mint-500); color: #fff; }
@@ -185,8 +185,8 @@
.dbonds--top .dbond.is-core .dbond__name { font-weight: 700; }
.dbond__icon { width: .26rem; height: .26rem; }
.dbond:not(.is-active) .dbond__icon img { filter: brightness(0) invert(.6); }
.dbond__count { font-size: .15rem; color: #fff; }
.dbond__count small { font-size: .11rem; color: var(--text-dim); }
.dbond__count { font-size: calc(.15 * var(--t)); color: #fff; }
.dbond__count small { font-size: calc(.11 * var(--t)); color: var(--text-dim); }
.dbond__count.is-max { color: var(--gold-2); }
.dbond__tiers { display: inline-flex; gap: 2px; margin-left: .02rem; }
.dbond__tiers i { width: .05rem; height: .12rem; background: rgba(255, 255, 255, .16); transform: skewX(-18deg); }
@@ -195,23 +195,23 @@
.dbonds--top .dbond.is-off { border-style: dashed; border-color: rgba(255, 255, 255, .16); background: transparent; color: var(--text-dim); }
.dbond.is-off .dbond__name { text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.dbond.is-off .dbond__icon img { filter: grayscale(1) brightness(.5); }
.dbond__off { font-size: .12rem; color: var(--text-dim); white-space: nowrap; }
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: .11rem; font-weight: 700; letter-spacing: .06em;
.dbond__off { font-size: calc(.12 * var(--t)); color: var(--text-dim); white-space: nowrap; }
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .06em;
padding: 0 .06rem; line-height: 1.6; background: var(--mint-500); color: #062019; }
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: .15rem; }
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: calc(.15 * var(--t)); }
.dmodule__name { color: var(--text-hi); }
/* official module type icon (local-client art, white glyph) on a dark tile; the type letter without it */
.dmodule__icon { flex: none; display: inline-grid; place-items: center; width: .4rem; height: .4rem; background: linear-gradient(180deg, #2a322f, #151a18); border: 1px solid var(--gold-2); border: 1px solid color-mix(in srgb, var(--gold-2) 60%, transparent); }
.dmodule__icon img { width: 80%; height: 80%; object-fit: contain; }
.dmodule__icon b { font-size: .2rem; font-weight: 700; line-height: 1; color: var(--gold-2); }
.dmodule__type { font-family: var(--font-display); font-size: .12rem; letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
.dmodule__icon b { font-size: calc(.2 * var(--t)); font-weight: 700; line-height: 1; color: var(--gold-2); }
.dmodule__type { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
.dmodule.is-none .dmodule__name { color: var(--text-dim); font-weight: 500; }
.ditem { display: flex; gap: .1rem; align-items: flex-start; }
.ditem__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.ditem__text b { font-size: .15rem; }
.ditem__desc { font-size: .13rem; line-height: 1.55; color: var(--text-lo); }
.dempty { font-size: .14rem; }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
.ditem__text b { font-size: calc(.15 * var(--t)); }
.ditem__desc { font-size: calc(.13 * var(--t)); line-height: 1.55; color: var(--text-lo); }
.dempty { font-size: calc(.14 * var(--t)); }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); line-height: 1.6; }
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
.dhint--merge { color: var(--gold); }
@@ -219,7 +219,7 @@
.dhint--rule { color: var(--text-md); }
/* 变形同构体's 天赋栏 (detailPanel MorphPairings, DESIGN §21.26): one line per bond — 【bond】 | the items that pair with it;
本局禁用 struck through, the pairing a wearer wears highlighted. Px floors keep it readable on phones (1rem = 40 px). */
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-md); }
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-md); }
.dmorph__lead { margin: 0; color: var(--text-hi); }
.dmorph__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 1px; }
.dmorph__row { display: grid; grid-template-columns: 6.2em minmax(0, 1fr); gap: .06rem; align-items: baseline; padding: .02rem .06rem;
@@ -230,20 +230,20 @@
.dmorph__row.is-off .dmorph__items { color: var(--text-dim); }
.dmorph__row.is-worn { background: rgba(78, 216, 175, .14); border-left-color: var(--mint-400); }
.dmorph__item.is-worn { color: #fff; font-weight: 700; }
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(.11rem, 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(calc(.11 * var(--t)), 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
.dmorph__tag.is-on { background: var(--mint-500); color: #062019; }
.dmorph__tag.is-off { border: 1px dashed rgba(255, 255, 255, .25); color: var(--text-dim); font-weight: 500; }
.dmorph__none { margin: 0; color: var(--gold-2); }
/* a bond item's line: what it gives a 变形同构体 wearer (MorphGrantLine) */
.dhint--morph { font-size: max(.14rem, 9px); color: var(--text-md); }
.dhint--morph { font-size: max(calc(.14 * var(--t)), 9px); color: var(--text-md); }
.dhint--morph.is-worn { color: var(--mint-400); }
.dhint--morph.is-off { color: var(--text-dim); }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.13 * var(--t)); color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
.dfaction img { width: .22rem; height: .22rem; object-fit: contain; }
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); }
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: calc(.15 * var(--t)); line-height: 1.6; color: var(--text-md); }
.dabil li.is-off { color: var(--text-dim); }
.dabil li.is-off .rt-term { color: var(--text-dim); border-bottom-color: transparent; }
@@ -270,44 +270,44 @@
.bpop__disc.is-active { background: var(--mint-500); border-color: var(--mint-400); box-shadow: 0 0 .24rem rgba(23, 249, 183, .45); }
.bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); }
.bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; }
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: .14rem; color: var(--amber); }
.bpop__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .08em; }
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--amber); }
.bpop__owner b { color: #fff; font-weight: 700; max-width: 2.4rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.bpop.is-other .bpop__disc { box-shadow: 0 0 0 1px rgba(246, 163, 41, .55); }
.bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: .18rem; }
.bpop__facts small { font-size: .12rem; }
.bpop__off { margin: .08rem 0 0; font-size: .14rem; color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
.bpop__facts { display: flex; gap: .16rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: calc(.18 * var(--t)); }
.bpop__facts small { font-size: calc(.12 * var(--t)); }
.bpop__off { margin: .08rem 0 0; font-size: calc(.14 * var(--t)); color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
.bpop__tiers { display: flex; gap: .06rem; }
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: .13rem; }
.bpop__tier b { font-size: .2rem; }
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: calc(.13 * var(--t)); }
.bpop__tier b { font-size: calc(.2 * var(--t)); }
.bpop__tier.is-on { background: var(--mint-500); color: var(--text-on-mint); border-color: var(--mint-400); }
.bpop__sec { display: flex; flex-direction: column; gap: .06rem; }
.bpop__sec h4 { font-size: .16rem; color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
.bpop__sec h4 small { font-size: .13rem; color: var(--text-lo); font-weight: 400; }
.bpop__sec h4 { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
.bpop__sec h4 small { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 400; }
.bpop__sec--now { padding: .1rem .12rem; background: rgba(78, 216, 175, .06); border-left: 3px solid var(--mint-500); }
.bpop__desc { font-size: .15rem; line-height: 1.7; color: var(--text-md); }
.bpop__desc { font-size: calc(.15 * var(--t)); line-height: 1.7; color: var(--text-md); }
.bpop__members { display: grid; grid-template-columns: repeat(5, 1fr); gap: .08rem; }
.bpop__member { position: relative; display: flex; flex-direction: column; align-items: center; gap: .03rem; padding: .04rem 0; background: none; border: 1px solid transparent; }
.bpop__member:hover { border-color: var(--line-2); background: rgba(255, 255, 255, .03); }
.bpop__member.is-on .uthumb__art { border-color: var(--mint-400); box-shadow: 0 0 .1rem rgba(23, 249, 183, .45); }
.bpop__mname { font-size: .12rem; color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bpop__mname { font-size: calc(.12 * var(--t)); color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bpop__member.is-on .bpop__mname { color: var(--mint-400); }
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); }
.bpop__ban { position: absolute; top: .02rem; right: .1rem; width: .2rem; height: .2rem; display: grid; place-items: center; background: var(--red); color: #fff; border-radius: 50%; }
.bpop__ban .icon { width: .14rem; height: .14rem; }
/* a member through 变形同构体 (the item pairing, not the operator's own bond): a gold 同构 tag on the portrait */
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: .11rem; line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: calc(.11 * var(--t)); line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
/* a 自选 pick all of whose bonds are off this match (m.private.diyBanned): its 「自选」 tag moves down to the portrait's
bottom corner (the 替补 mark's place), clear of the red ✕ and the tier chip */
.bpop__member.is-banned .bpop__diy { top: .42rem; }
.dbonds--top .dbond.is-granted { border-style: dashed; }
.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
.dbond__iso { font-size: calc(.11 * var(--t)); font-weight: 700; color: var(--gold-2); }
/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
heads the member list, naming the 调和 operators on the board (px floors: readable on phones) */
.bpop__facts small.bpop__hnote { font-size: max(.12rem, 8px); color: var(--gold-2); }
.bpop__facts small.bpop__hnote { font-size: max(calc(.12 * var(--t)), 8px); color: var(--gold-2); }
.bpop__harmony { display: flex; align-items: center; gap: .08rem; width: 100%; margin: 0; padding: .05rem .1rem; text-align: left;
font: inherit; font-size: max(.14rem, 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
font: inherit; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
button.bpop__harmony { cursor: pointer; }
button.bpop__harmony:hover { border-style: solid; color: #fff; }
.bpop__harmony b { flex: none; color: var(--gold-2); font-weight: 700; white-space: nowrap; }
@@ -328,12 +328,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.edrawer__close { width: .34rem; height: .34rem; margin-bottom: .06rem; display: grid; place-items: center; background: none; border: 1px solid var(--line-2); color: var(--text-lo); }
.edrawer__close .icon { width: .18rem; height: .18rem; }
.edrawer__body { overflow: auto; padding: .14rem .18rem .18rem; display: flex; flex-direction: column; gap: .12rem; }
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); }
.edrawer__sum b { font-size: .19rem; }
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: .16rem; }
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); }
.edrawer__sum b { font-size: calc(.19 * var(--t)); }
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: calc(.16 * var(--t)); }
.edrawer__empty .icon { width: .24rem; height: .24rem; }
.fchips { display: flex; gap: .06rem; flex-wrap: wrap; }
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: .13rem; color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: calc(.13 * var(--t)); color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
.fchip__icon { width: .26rem; height: .26rem; object-fit: contain; }
.elist { display: grid; grid-template-columns: 1fr 1fr; gap: .08rem; }
.erow { display: flex; align-items: center; gap: .1rem; padding: .06rem .12rem .06rem .06rem; background: rgba(255, 255, 255, .025); border: 1px solid var(--line); text-align: left; color: inherit; transition: border-color var(--t-fast), background var(--t-fast); }
@@ -341,10 +341,10 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.erow--boss { border-color: rgba(255, 110, 40, .6); background: rgba(232, 90, 26, .1); }
.erow--bounty { border-color: rgba(255, 198, 0, .45); }
.erow__text { display: flex; flex-direction: column; gap: .03rem; min-width: 0; flex: 1; }
.erow__name { font-size: .16rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.erow__name { font-size: calc(.16 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.erow__tags { display: flex; gap: .04rem; flex-wrap: wrap; }
.erow__count { font-size: .26rem; color: #fff; }
.etag { font-size: .11rem; line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
.erow__count { font-size: calc(.26 * var(--t)); color: #fff; }
.etag { font-size: calc(.11 * var(--t)); line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
.etag--boss { background: var(--orange); color: #fff; }
.etag--bounty { background: var(--gold); color: #1d1400; }
.etag--escort { background: var(--bg-4); color: var(--text-md); }
@@ -353,29 +353,29 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.etag--faction { border: 1px solid rgba(246, 163, 41, .5); color: var(--amber); font-weight: 500; }
.eboss { display: flex; gap: .12rem; align-items: center; padding: .1rem .12rem; border: 1px solid rgba(255, 110, 40, .45); background: linear-gradient(90deg, rgba(232, 90, 26, .14), transparent); }
.eboss > div { display: flex; flex-direction: column; gap: .02rem; }
.eboss b { font-size: .19rem; }
.eboss .t-lo { font-size: .13rem; }
.eboss b { font-size: calc(.19 * var(--t)); }
.eboss .t-lo { font-size: calc(.13 * var(--t)); }
.iband { display: flex; gap: .12rem; align-items: flex-start; padding: .1rem; background: rgba(78, 216, 175, .05); border: 1px solid var(--mint-800); }
.iband > div { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.iband b { font-size: .19rem; }
.iband b small { font-size: .14rem; font-weight: 400; margin-left: .06rem; }
.iband__desc { font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.istage { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.iband b { font-size: calc(.19 * var(--t)); }
.iband b small { font-size: calc(.14 * var(--t)); font-weight: 400; margin-left: .06rem; }
.iband__desc { font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
.istage { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.istage .icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.istage b { color: var(--text-hi); }
.ihead { font-size: .16rem; color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
.ihead small { color: var(--text-lo); font-size: .14rem; }
.ihead { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
.ihead small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
.ibonds { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: .14rem; border: 1px solid var(--line-2); color: var(--text-md); }
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: calc(.14 * var(--t)); border: 1px solid var(--line-2); color: var(--text-md); }
.ibond.is-off { color: var(--text-dim); background: rgba(255, 255, 255, .02); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.ibond.is-off .bglyph img { filter: grayscale(1) brightness(.5); }
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: .12rem; padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: calc(.12 * var(--t)); padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
.ibond__ban .icon { width: .12rem; height: .12rem; }
.ibanned { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibanned__one { padding: 0; background: none; border: 0; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine) */
.idiybanned { margin: .08rem 0 0; font-size: .13rem; line-height: 1.5; }
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: .11rem; font-weight: 700;
.idiybanned { margin: .08rem 0 0; font-size: calc(.13 * var(--t)); line-height: 1.5; }
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: calc(.11 * var(--t)); font-weight: 700;
background: var(--mint-500); color: var(--text-on-mint); }
/* ---- 机变 overlay ---- */
@@ -385,13 +385,13 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.spov__inner { position: absolute; left: .3rem; right: .5rem; top: 1.14rem; bottom: .5rem; display: flex; flex-direction: column; gap: .16rem; animation: screen-in var(--t-slow) var(--ease-out); }
.spov__head { display: flex; align-items: flex-end; justify-content: space-between; gap: .3rem; }
.spov__titles { display: flex; flex-direction: column; gap: .04rem; }
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: .5rem; font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
.spov__title.is-special { color: var(--gold-2); text-shadow: 0 0 .2rem rgba(242, 189, 62, .35); }
.spov__bar { color: var(--mint-700); font-weight: 400; }
.spov__desc { font-size: .22rem; font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
.spov__sub { font-size: .15rem; color: var(--text-dim); }
.spov__desc { font-size: calc(.22 * var(--t)); font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
.spov__sub { font-size: calc(.15 * var(--t)); color: var(--text-dim); }
.spov__turn { display: flex; align-items: center; gap: .24rem; }
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: .24rem; font-weight: 700; color: var(--text-md); }
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: calc(.24 * var(--t)); font-weight: 700; color: var(--text-md); }
.spov__turntxt .icon { width: .26rem; height: .26rem; }
.spov__turntxt.is-mine { color: var(--gold); text-shadow: 0 0 .16rem rgba(255, 198, 0, .45); animation: blink 1.2s ease-in-out infinite; }
.spov__turntxt.is-done { color: var(--mint-400); }
@@ -400,8 +400,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.spov__who.is-cur { border-color: var(--gold); background: rgba(255, 198, 0, .08); }
.spov__who.is-done { border-color: var(--mint-800); }
.spov__who.is-self .spov__wname { color: var(--mint-400); }
.spov__idx { font-size: .16rem; color: var(--text-dim); width: .2rem; text-align: center; }
.spov__wname { font-size: .15rem; max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spov__idx { font-size: calc(.16 * var(--t)); color: var(--text-dim); width: .2rem; text-align: center; }
.spov__wname { font-size: calc(.15 * var(--t)); max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spov__wstate { display: grid; }
.spov__wstate .icon { width: .2rem; height: .2rem; }
.spov__who.is-done .spov__wstate { color: var(--mint-400); }
@@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
/* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */
.fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; }
/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */
.fwheel:focus-visible { outline: none; }
.fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); }
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
@@ -622,7 +624,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
border-color: rgba(255, 84, 84, .35);
}
.eqr .modal__body { display: flex; flex-direction: column; align-items: stretch; gap: .1rem; padding-bottom: .16rem; }
.eqr__lead { font-size: .17rem; line-height: 1.6; color: var(--text-md); }
.eqr__lead { font-size: calc(.17 * var(--t)); line-height: 1.6; color: var(--text-md); }
.eqr__lead b { color: var(--text-hi); }
.eqr__lead .t-red { color: var(--red-premium); }
.eqr__top { display: flex; align-items: center; justify-content: center; gap: .22rem; margin-top: .06rem; }
@@ -633,7 +635,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
}
.eqr__ava::after { content: ''; position: absolute; inset: .1rem; background: var(--eqr-avaf, none) center / 100% 100% no-repeat; pointer-events: none; }
.eqr__ava .uthumb { --ut: .72rem; }
.eqr__opname { font-size: .15rem; font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eqr__opname { font-size: calc(.15 * var(--t)); font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eqr__swap { display: grid; place-items: center; width: .4rem; height: .32rem; color: var(--mint-400); flex: none; }
.eqr__swap img, .eqr__swap .icon { width: 100%; height: 100%; object-fit: contain; }
.eqr__incoming { display: flex; flex-direction: column; gap: .04rem; min-width: 0; flex: 0 1 4.2rem; }
@@ -647,9 +649,9 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.eqr__card--new .eqr__icon .uthumb { --ut: .56rem; }
.eqr__meta { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.eqr__chips { display: flex; align-items: center; gap: .06rem; min-height: .2rem; }
.eqr__elite { font-size: .12rem; padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
.eqr__name { font-size: .19rem; color: var(--text-hi); line-height: 1.2; }
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: .145rem; line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
.eqr__elite { font-size: calc(.12 * var(--t)); padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
.eqr__name { font-size: calc(.19 * var(--t)); color: var(--text-hi); line-height: 1.2; }
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: calc(.145 * var(--t)); line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
.eqr__arrow { position: relative; height: .5rem; margin: 0 auto; width: 50%; }
.eqr__aline { position: absolute; left: 0; right: 0; top: .2rem; height: .2rem; border: .045rem solid rgba(242, 242, 242, .82); border-bottom: 0; }
.eqr__aline::before { content: ''; position: absolute; left: 50%; top: -.25rem; width: .045rem; height: .21rem; margin-left: -.0225rem; background: rgba(242, 242, 242, .82); }
@@ -677,12 +679,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
}
.eqr__flag {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: .44rem; display: flex; align-items: center; justify-content: center; gap: .08rem;
font-size: .17rem; font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
font-size: calc(.17 * var(--t)); font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
background: var(--eqr-selbar, linear-gradient(90deg, #a3141a, #d42a2a)) center / 100% 100% no-repeat;
opacity: 0; transform: translateY(100%); transition: opacity var(--t-fast), transform var(--t-med) var(--ease-out);
}
.eqr__flag-icon { width: .2rem; height: .2rem; }
.eqr__opt.is-sel .eqr__flag { opacity: 1; transform: none; }
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: .15rem; color: var(--text-lo); min-height: .24rem; }
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: calc(.15 * var(--t)); color: var(--text-lo); min-height: .24rem; }
.eqr__hint b { color: var(--text-hi); }
.eqr__ok-icon { width: .2rem; height: .2rem; margin-right: .06rem; vertical-align: -.03rem; filter: brightness(0) invert(1); }
+3
View File
@@ -156,6 +156,9 @@
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
/* a slot the 调度中心's upgrade opened and nothing filled yet (ui/shopBar.js EmptyCard, also .scard--sold): the bare frame — no hatching,
no SOLD OUT text */
.scard--sold.scard--empty { background: rgba(8, 11, 10, .4); border-color: var(--line); }
/* ---- funds card ---- */
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
+67 -19
View File
@@ -30,6 +30,21 @@
.gtop > * { pointer-events: auto; }
.gtop__left { display: flex; align-items: center; gap: .16rem; }
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */
.gtop__net { display: flex; align-items: center; gap: .08rem; }
.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; }
.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); }
.specpill.is-me { --btn-bd: var(--mint-600); }
/* the roster inside its modal */
.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; }
.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; }
.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); }
.spec__row.is-offline .spec__name { color: var(--text-dim); }
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(.19 * var(--t)); color: var(--text-hi); }
.spec__hint { margin: .04rem 0 0; font-size: calc(.15 * var(--t)); line-height: 1.45; }
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
.gtop__center {
--bk-c: var(--mint-700);
@@ -501,48 +516,81 @@
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
/* the 设置 trigger of the lobby and the room (ui/settings.js SettingsButton, GitHub #238): the 玩法说明 button's twin; its gear sits
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: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
.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 设置 →「文字大小」
大 / 特大 a phone's label (a Chinese name plus its Latin micro label) is wider than 3.3rem at 1rem = 40 px. */
.set-row__label { display: flex; flex-wrap: wrap; align-items: center; gap: .1rem; font-size: calc(.18 * var(--t)); color: var(--text-hi); }
.set-row__label .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.set-row__label .micro { margin-left: .04rem; }
.set-row__val { width: .5rem; text-align: right; font-size: .2rem; color: var(--mint-400); }
/* a floor, not a fixed width: the %. value grows with the text step (20 px on a phone, 3 digits at 特大) */
.set-row__val { min-width: .5rem; text-align: right; font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.set-range {
-webkit-appearance: none; appearance: none; width: 100%; height: .08rem; background: linear-gradient(90deg, var(--mint-500) var(--pct), #0a0d0c var(--pct));
border: 1px solid var(--line-2); outline: none; cursor: pointer;
}
.set-range::-webkit-slider-thumb { -webkit-appearance: none; width: .2rem; height: .26rem; background: var(--mint-400); clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%); cursor: grab; }
.set-range::-moz-range-thumb { width: .2rem; height: .26rem; background: var(--mint-400); border: 0; }
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: .15rem; }
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.15 * var(--t)); }
.set-toggle i { width: .46rem; height: .24rem; background: var(--bg-3); position: relative; transition: background var(--t-fast); }
.set-toggle i::after { content: ''; position: absolute; left: .03rem; top: .03rem; width: .18rem; height: .18rem; background: var(--text-lo); transition: transform var(--t-fast), background var(--t-fast); }
.set-toggle.is-on { color: var(--mint-400); border-color: var(--mint-700); }
.set-toggle.is-on i { background: var(--mint-800); }
.set-toggle.is-on i::after { transform: translateX(.22rem); background: var(--mint-400); }
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; border: 1px solid var(--line-2); }
.set-seg button { min-width: .8rem; height: .38rem; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: .16rem; }
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--line-2); }
.set-seg button { flex: 1 1 auto; min-width: .8rem; min-height: .38rem; height: auto; white-space: normal; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.16 * var(--t)); }
/* 文字大小 (ui/settings.js): four steps, so its buttons are narrower than the two- and three-step groups' — at .8rem each
the group would be wider than the row's 1fr column on a phone (1rem = 40 px) */
.set-seg.set-textsize button { min-width: .62rem; }
.set-seg button:last-child { border-right: 0; }
.set-seg button.is-on { background: var(--mint-500); color: var(--text-on-mint); font-weight: 700; }
.set-hint { margin-top: .06rem; font-size: .14rem; color: var(--text-dim); line-height: 1.8; }
.set-hint { margin-top: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-dim); line-height: 1.8; }
.set-hint kbd, .lvcard__key, .toolbtn kbd { font-family: var(--font-num); font-size: .11rem; border: 1px solid var(--line-2); padding: 0 .04rem; color: var(--text-lo); background: rgba(0, 0, 0, .3); }
/* 快捷键 (ui/settings.js HotkeySection): each shortcut with its key — click the key, press the new one; two columns,
one under 768 px (phones in landscape) */
.set-keys { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-keys__head { display: flex; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
.set-keys__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
.set-keys__list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .06rem .14rem; margin: 0; padding: 0; list-style: none; }
.set-keys__row { display: flex; align-items: center; justify-content: space-between; gap: .1rem; min-height: .42rem; padding: .03rem .03rem .03rem .12rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); }
.set-keys__name { min-width: 0; font-size: .15rem; line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: .15rem; cursor: pointer; }
.set-keys__name { min-width: 0; font-size: calc(.15 * var(--t)); line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: calc(.15 * var(--t)); cursor: pointer; }
.set-key kbd { font: inherit; }
.set-key:hover, .set-key:focus-visible { border-color: var(--mint-700); }
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: .13rem; animation: set-key-wait 1s ease-in-out infinite alternate; }
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: calc(.13 * var(--t)); animation: set-key-wait 1s ease-in-out infinite alternate; }
.set-key.is-fixed { cursor: default; color: var(--text-lo); border-style: dashed; }
.set-key.is-fixed small { font-family: inherit; font-size: .12rem; color: var(--text-dim); }
.set-keys__note { min-height: 1.5em; font-size: .14rem; line-height: 1.5; color: var(--mint-400); }
.set-key.is-fixed small { font-family: inherit; font-size: calc(.12 * var(--t)); color: var(--text-dim); }
.set-keys__note { min-height: 1.5em; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--mint-400); }
.set-keys__note.is-warn { color: var(--amber); }
.set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; }
.set-keys__more > summary { padding: .06rem 0; font-size: calc(.15 * var(--t)); color: var(--mint-400); cursor: pointer; }
.set-keys__more[open] > summary { margin-bottom: .08rem; }
@keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } }
/* 问题反馈 (ui/settings.js DiagSection): laid out like 快捷键 — a head with its button, a hint, the result line; the
recorded-error count in the figure face of the slider values (amber once there is one); the report box only when the
clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */
.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; }
.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.set-diag__count { font-family: var(--font-num); font-size: calc(.2 * var(--t)); line-height: 1; color: var(--mint-400); }
.set-diag__count.is-warn { color: var(--amber); }
.set-diag__url { min-width: 0; font-size: calc(.13 * var(--t)); color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
.set-diag__battle-hint { margin-top: -.06rem; }
.set-diag__text {
box-sizing: border-box; width: 100%; height: max(2rem, 140px); min-height: 120px; resize: vertical;
padding: max(.1rem, 8px) max(.12rem, 10px);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: max(.13rem, 11px); line-height: 1.5;
color: var(--text-hi); background: rgba(4, 10, 8, .55); border: 1px solid var(--line-2); border-radius: 2px; outline: none;
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto;
}
.set-diag__text:focus { border-color: var(--mint-700); }
/* touch: 16 px, below which iOS zooms into the focused box (as 干员调配's 导出 box and ui/clipboard.js) */
.sp-coarse .set-diag__text { font-size: 16px; }
@media (max-width: 767px) { .set-keys__list { grid-template-columns: minmax(0, 1fr); } }
@media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) {
.set-key { height: max(.38rem, 30px); }
@@ -550,11 +598,11 @@
}
/* ---- exit / away ------------------------------------------------------------------------------------------------------ */
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: .17rem; line-height: 1.7; }
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: calc(.17 * var(--t)); line-height: 1.7; }
.awayov { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgba(4, 7, 6, .78); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: screen-in var(--t-slow) var(--ease-out); }
.awayov__box { display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .44rem .7rem; background: rgba(12, 16, 14, .96); border: 1px solid var(--line-2); }
.awayov__icon { width: .8rem; height: .8rem; color: var(--ice); filter: drop-shadow(0 0 .2rem rgba(159, 212, 255, .5)); }
.awayov__box h2 { font-size: .34rem; letter-spacing: .12em; }
.awayov__box h2 { font-size: calc(.34 * var(--t)); letter-spacing: .12em; }
/* ---- simplified (DOM) field view ---------------------------------------------------------------------------------------- */
.ff { position: absolute; inset: 0; overflow: hidden; user-select: none; }
@@ -664,7 +712,7 @@
display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 3.6rem; padding: .16rem .6rem .2rem;
background: rgba(20, 22, 22, .9); background: var(--pause-plate, linear-gradient(90deg, transparent, rgba(12, 14, 14, .95) 18%, rgba(12, 14, 14, .95) 82%, transparent)) center / 100% 100% no-repeat;
}
.pauseov__micro { font-family: var(--font-num); font-size: .16rem; letter-spacing: .5em; color: var(--mint-400); }
.pauseov__plate h2 { font-size: .5rem; font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
.pauseov__note { font-size: .17rem; color: var(--text-md); text-shadow: 0 1px 2px #000; }
.pauseov__micro { font-family: var(--font-num); font-size: calc(.16 * var(--t)); letter-spacing: .5em; color: var(--mint-400); }
.pauseov__plate h2 { font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
.pauseov__note { font-size: calc(.17 * var(--t)); color: var(--text-md); text-shadow: 0 1px 2px #000; }
.pauseov__btns { display: flex; gap: .2rem; }
+7 -7
View File
@@ -27,7 +27,7 @@
/* header: title | chapter tabs | close */
.guide__head { display: flex; align-items: center; gap: .3rem; min-height: .62rem; }
.guide__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 2.4rem; }
.guide__title { margin: 0; font-size: .3rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
.guide__title { margin: 0; font-size: calc(.3 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
.guide__chapters { display: flex; gap: .06rem; margin: 0 auto; }
.guide__chapter {
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .01rem;
@@ -38,8 +38,8 @@
}
.guide__chapter:hover { color: var(--text-hi); background: rgba(78, 216, 175, .06); }
.guide__chapter.is-on { color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
.guide__chname { font-size: .19rem; font-weight: 700; letter-spacing: .1em; }
.guide__chmicro { font-family: var(--font-display); font-size: .1rem; letter-spacing: .24em; opacity: .75; }
.guide__chname { font-size: calc(.19 * var(--t)); font-weight: 700; letter-spacing: .1em; }
.guide__chmicro { font-family: var(--font-display); font-size: calc(.1 * var(--t)); letter-spacing: .24em; opacity: .75; }
.guide__close {
width: .56rem; height: .56rem; display: grid; place-items: center; flex: none;
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); color: var(--text-md);
@@ -77,10 +77,10 @@
.guide__foot { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .3rem; min-height: .44rem; }
.guide__label { display: flex; align-items: center; gap: .14rem; min-width: 0; }
.guide__chtag {
flex: none; padding: .02rem .12rem; font-size: .14rem; font-weight: 700; letter-spacing: .1em;
flex: none; padding: .02rem .12rem; font-size: calc(.14 * var(--t)); font-weight: 700; letter-spacing: .1em;
color: var(--mint-400); border: 1px solid var(--mint-800); background: var(--mint-a10);
}
.guide__ptitle { font-size: .22rem; font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.guide__ptitle { font-size: calc(.22 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.guide__dots { display: flex; align-items: center; gap: .05rem; }
.guide__dot {
width: .2rem; height: .08rem; padding: 0; border: 0; background: var(--line-2);
@@ -89,12 +89,12 @@
.guide__dot.is-first { margin-left: .14rem; }
.guide__dot:hover { background: var(--line-3); }
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
.guide__count { justify-self: end; font-size: calc(.24 * var(--t)); color: var(--text-md); letter-spacing: .06em; }
/* text fallback: the official tips when no tutorial page can be shown (none listed, or none of a page's copies loads) */
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
.guide__tips li { font-size: calc(.18 * var(--t)); line-height: 1.6; color: var(--text-md); }
.guide__tips li::marker { color: var(--mint-500); font-family: var(--font-num); }
/* standard trigger */
+148 -75
View File
@@ -138,56 +138,93 @@
.lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); }
.lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; }
/* ---- roster grid ------------------------------------------------------------------------------------------------------------ */
.lo-grid {
/* ---- roster: one list, one row per operator (0.2.2) ------------------------------------------------------------------------ */
/* The quick choices of PR #301 (by @farridge) as ONE list instead of three columns of cards (the owner's decision of
2026-10-08: 「三个竖排有点乱影响观感」): every row has the same four columns — the operator (portrait, name, bonds), the
skills, the elite's modules, 潜能 / 练度 — aligned under a sticky head row, so the eye runs down a column. Sizes: --q a
quick button (a skill / module), --art the portrait; a coarse pointer gets 40 px targets. */
.lo-list {
--q: max(.4rem, 32px); --qg: max(.05rem, 4px); --art: max(.52rem, 40px);
--cols: minmax(max(2.2rem, 110px), max(3.2rem, 260px)) calc(3 * var(--q) + 2 * var(--qg)) calc(4 * var(--q) + 3 * var(--qg)) max(2.2rem, 160px);
flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.16rem, 64px), 1fr)); grid-auto-rows: max-content;
align-content: start; gap: .1rem; padding: .06rem .08rem .2rem .02rem;
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
padding: 0 .06rem .2rem 0; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
}
.lo-empty { grid-column: 1 / -1; padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
.sp-coarse .lo-list { --q: max(.44rem, 40px); }
.lo-list__head, .lo .lo-card { display: grid; grid-template-columns: var(--cols); justify-content: start; align-items: center; column-gap: max(.12rem, 8px); }
/* (the head has the rows' box — the same borders, transparent, and padding — so its columns are the rows' to the pixel) */
.lo-list__head {
position: sticky; top: 0; z-index: 2; padding: .05rem .1rem .05rem .07rem; border: 1px solid transparent; border-left-width: 3px;
font-size: max(.12rem, 9px); font-weight: 700; letter-spacing: .12em; color: var(--text-dim);
background: linear-gradient(180deg, #0d1110 70%, rgba(13, 17, 16, .85)); box-shadow: inset 0 -1px 0 var(--line);
}
.lo-list__h small { margin-left: .05rem; font-weight: 400; letter-spacing: .04em; color: var(--gold-2); opacity: .8; }
.lo-list__rows { display: flex; flex-direction: column; gap: max(.04rem, 3px); padding-top: max(.05rem, 4px); }
.lo-empty { padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
.lo-card {
.lo .lo-card {
--tc: var(--tier-1);
position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0;
padding: 0; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer;
background: linear-gradient(180deg, rgba(34, 41, 38, .95), rgba(14, 17, 16, .98));
border: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--tc);
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
touch-action: manipulation;
position: relative; min-width: 0; padding: max(.05rem, 4px) .1rem max(.05rem, 4px) .07rem;
background: rgba(255, 255, 255, .025); border: 1px solid var(--line); border-left: 3px solid var(--tc);
transition: background var(--t-fast), border-color var(--t-fast);
}
.lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); }
.lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); }
.lo-card:hover { border-color: var(--line-3); transform: translateY(-1px); }
.lo-card.is-sel { border-color: var(--mint-400); box-shadow: inset 0 -2px 0 var(--tc), 0 0 0 1px var(--mint-500), 0 0 .16rem rgba(23, 249, 183, .35); }
.lo-card:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
.lo-card__art { position: relative; display: block; aspect-ratio: 1; overflow: hidden; background: #1b201e; }
.lo-card__art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-card__art::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(180deg, transparent, rgba(8, 10, 9, .85)); }
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: .44rem; font-weight: 900; color: var(--text-dim); }
.lo-card__tier { position: absolute; left: .03rem; top: .03rem; z-index: 1; }
.lo-card__flag {
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
}
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */
.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
.lo .lo-card:hover { background: rgba(255, 255, 255, .045); border-color: var(--line-3); border-left-color: var(--tc); }
.lo .lo-card.is-sel { background: linear-gradient(90deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02) 60%); border-color: var(--mint-700); border-left-color: var(--tc); box-shadow: 0 0 0 1px var(--mint-500) inset; }
.lo-card button:focus-visible, .lo-card select:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
.lo-card__pick { display: flex; align-items: center; gap: max(.1rem, 6px); min-width: 0; padding: 0; font: inherit; color: inherit; text-align: left; background: none; border: 0; cursor: pointer; touch-action: manipulation; }
.lo-card__art { position: relative; flex: none; width: var(--art); height: var(--art); overflow: hidden; background: #1b201e; border: 1px solid var(--line-2); }
.lo-card__art > img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: max(.24rem, 16px); font-weight: 900; color: var(--text-dim); }
.lo-card__tier { position: absolute; left: 1px; top: 1px; z-index: 1; transform-origin: 0 0; transform: scale(.8); }
.lo-card__id { display: flex; flex-direction: column; gap: max(.03rem, 2px); min-width: 0; }
.lo-card__name { font-size: max(.17rem, 12px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lo-card__bonds { display: flex; flex-wrap: wrap; gap: max(.04rem, 3px); min-height: max(.16rem, 12px); }
.lo-card__bond, .lo-card__bonds .lo-bond__dot { width: max(.18rem, 13px); height: max(.18rem, 13px); object-fit: contain; }
.lo-card__flag { position: absolute; right: 0; top: 0; z-index: 1; width: max(.16rem, 10px); height: max(.16rem, 10px); background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); pointer-events: none; }
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab — its stand-in fights with a fixed skill and no 潜能 / 练度; the choices
here apply once it is owned again */
.lo-card__sub { align-self: flex-start; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
.lo .lo-card.is-standin .lo-card__skills, .lo .lo-card.is-standin .lo-card__mods, .lo .lo-card.is-standin .lo-cult { opacity: .55; }
.lo-locknote--standin { color: var(--ice); }
.lo-card__name {
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-shadow: 0 1px 2px #000;
.lo-card__skills, .lo-card__mods { display: flex; align-items: center; gap: var(--qg); min-width: 0; }
.lo-card__mods { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.lo-q {
position: relative; flex: none; display: grid; place-items: center; width: var(--q); height: var(--q); padding: 0;
font: inherit; color: var(--text-lo); background: none; border: 0; cursor: pointer; touch-action: manipulation;
}
.lo-card__kit { display: flex; align-items: center; justify-content: space-between; gap: .04rem; height: max(.3rem, 18px); padding: 0 .04rem; background: rgba(0, 0, 0, .4); }
.lo-card__sk { display: inline-flex; align-items: center; gap: .04rem; font-size: max(.13rem, 9px); color: var(--text-lo); }
.lo-card__sk b { font-family: var(--font-num); letter-spacing: .04em; }
.lo-card__sk.is-alt { color: var(--mint-400); }
.lo-card__mod {
display: inline-grid; place-items: center; min-width: max(.24rem, 15px); height: max(.2rem, 13px); padding: 0 .03rem;
font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-lo);
border: 1px solid var(--line-2); background: rgba(255, 255, 255, .04);
}
.lo-card__mod.is-alt { color: var(--text-on-mint); background: var(--gold-2); border-color: var(--gold); }
.lo-q--empty { border: 1px dashed rgba(255, 255, 255, .07); cursor: default; }
.lo-q:not(.is-on):not(.lo-q--empty) { opacity: .62; }
.lo-q:not(.is-on):not(.lo-q--empty):hover { opacity: 1; }
.lo-q__type { position: absolute; left: 0; bottom: 0; padding: 0 2px; font-size: max(.11rem, 8px); line-height: 1.3; color: var(--text-hi); background: rgba(0, 0, 0, .7); pointer-events: none; }
.lo-q--mod.is-on .lo-mglyph { border-color: var(--gold-2); box-shadow: 0 0 .08rem rgba(255, 198, 0, .35); }
/* 潜能 / 练度 (screens/cultivation.js): two compact selects side by side in the row's last column; at the defaults (潜能 6,
精英2 Lv.60 — the same on every row) they stay quiet, a changed one is mint */
.lo-cult { display: flex; align-items: center; gap: max(.04rem, 3px); min-width: 0; }
.lo .lo-cult .lo-select { min-height: 0; height: max(.32rem, 22px); padding-left: 0; border-color: transparent; background: rgba(255, 255, 255, .03); }
.lo .lo-cult .lo-select:hover, .lo .lo-cult .lo-select:focus-within { border-color: var(--line-3); }
.lo .lo-cult .lo-select select { width: 100%; min-width: 0; padding: 0 max(.2rem, 14px) 0 max(.06rem, 4px); font-size: max(.14rem, 10px); color: var(--text-lo); }
.lo-cult__pot { flex: 0 0 auto; }
.lo-cult__tier { flex: 1 1 auto; min-width: 0; }
.lo .lo-cult .lo-select.is-off { border-color: var(--mint-700); background: rgba(78, 216, 175, .08); }
.lo .lo-cult .lo-select.is-off select { color: var(--mint-400); font-weight: 700; }
.sp-coarse .lo .lo-cult .lo-select { height: max(.32rem, 28px); }
/* the detail's 潜能 / 练度 section */
.lo-sec--cult { padding: .08rem .1rem .1rem; margin-bottom: .14rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); border-left: 3px solid var(--mint-500); }
.lo-sec--cult .lo-sec__head .lo-badge { margin-left: auto; }
.lo-cult__row { display: grid; grid-template-columns: max(.5rem, 30px) minmax(0, 1fr); align-items: center; gap: .08rem; }
.lo-cult__k { font-size: max(.13rem, 10px); color: var(--text-dim); letter-spacing: .08em; }
.lo .lo-cult__row .lo-seg { margin-left: 0; flex-wrap: wrap; justify-self: start; }
.lo-seg--pot button { min-width: max(.36rem, 26px); font-family: var(--font-num); }
.lo-seg--tier button { padding: 0 .1rem; }
.lo-cult__eff { margin: .02rem 0 0; font-size: max(.14rem, 10px); line-height: 1.5; color: var(--text-md); }
.lo-cult__eff b { color: var(--mint-400); margin-right: .04rem; }
.lo-cult__note { margin: 0; font-size: max(.12rem, 9px); line-height: 1.5; color: var(--text-dim); }
.lo-sec--cult.is-moot .lo-cult__row, .lo-sec--cult.is-moot .lo-cult__eff { opacity: .55; }
.lo-sec--cult.is-moot .lo-cult__note { color: var(--ice); }
/* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */
.lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); }
@@ -195,9 +232,13 @@
.lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; }
.lo-sicon--xs .lo-sicon__glyph { display: none; }
.lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); }
.lo-sicon--q { width: 86%; height: 86%; }
.lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; }
.lo-sicon.is-on { border-color: #1ea0ff; }
.lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; object-fit: fill; pointer-events: none; }
/* the selected skill's frame (a 30 × 30 sprite, a 3 px ring to its edges) drawn .05rem outside the icon on every side, square:
theme.css `img { max-width: 100% }` clamped its width to the icon's while its height grew by .1rem, and `.lo-sicon img`
(more specific) made it `cover` — a tall, left-shifted frame with clipped side lines (GitHub #398, PR #399) */
.lo-sicon .lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; max-width: none; object-fit: fill; pointer-events: none; }
.lo-sicon__ring { position: absolute; inset: -.05rem; border: 2px solid #1ea0ff; pointer-events: none; }
.lo-mglyph { position: relative; flex: none; display: inline-grid; place-items: center; width: max(.56rem, 30px); height: max(.56rem, 30px);
@@ -205,6 +246,7 @@
.lo-mglyph img { width: 78%; height: 78%; object-fit: contain; }
.lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; }
.lo-mglyph--none { background: #121615; border-style: dashed; }
.lo-mglyph--q { width: 86%; height: 86%; }
.lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); }
.lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); }
@@ -236,6 +278,11 @@
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
.lo-garrisons { margin-bottom: .18rem; }
.lo-garrisons .dgarrison__k { font-size: max(.14rem, 11px); }
.lo-garrisons .dgarrison__type { font-size: max(.12rem, 10px); }
.lo-garrisons .dgarrison__icon { width: max(.18rem, 14px); height: max(.18rem, 14px); }
.lo-garrisons .dgarrison__text { font-size: max(.15rem, 11px); }
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
.lo-sec + .lo-sec { margin-top: .18rem; }
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
@@ -408,41 +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; }
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
/* 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(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(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;
@@ -454,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;
@@ -477,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); }
@@ -527,48 +577,55 @@
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
/* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */
/* phones and small tablets (up to 1000 px wide; 0.2.2 — 760 px before the roster became one list with quick choices): the
list takes the whole width and the detail slides over it — a row's quick choice keeps the list, its portrait / name
opens the detail */
@media (max-width: 760px) {
.lo-top { padding: 0 .16rem; }
}
@media (max-width: 1000px) {
.lo-body { grid-template-columns: minmax(0, 1fr); }
.lo-detail-wrap { display: none; }
.lo-body.is-detail .lo-roster { display: none; }
.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; }
}
/* narrow phones: the row's bonds give their line to the name, the columns keep their place */
@media (max-width: 700px) {
.lo-card__bonds { display: none; }
}
@media (max-height: 520px) {
.lo-note { display: none; }
.lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; }
.lo-dhead__en { display: none; }
.lo-grid { grid-template-columns: repeat(auto-fill, minmax(max(1rem, 58px), 1fr)); }
}
/* 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; }
@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } }
@media (prefers-reduced-motion: reduce) { .lo, .lo .lo-card { animation: none; transition: none; } }
/* 导出 / 导入 (干员调配 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 */
@@ -577,3 +634,19 @@
.lo-io__text:focus { border-color: var(--mint-700); }
.lo-io__text::placeholder { color: var(--text-lo); }
.lo-io__file { display: none; }
/* Per-operator voice, a browser-local preference in roster and DIY details. */
.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); }
+19 -19
View File
@@ -71,9 +71,9 @@
}
.mode-card__icon .icon { width: .44rem; height: .44rem; }
.mode-card__text { position: relative; display: flex; flex-direction: column; gap: .1rem; }
.mode-card__name { font-size: .46rem; font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
.mode-card__desc { font-size: .17rem; color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: .15rem; color: var(--text-md); }
.mode-card__name { font-size: calc(.46 * var(--t)); font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
.mode-card__desc { font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-md); }
.mode-card__points span::before {
content: '';
display: inline-block;
@@ -94,7 +94,7 @@
padding: .04rem .12rem .04rem .08rem;
background: var(--mint-500);
color: var(--text-on-mint);
font-size: .14rem;
font-size: calc(.14 * var(--t));
font-weight: 700;
opacity: 0;
transform: translateY(-.06rem);
@@ -118,13 +118,13 @@
.join-row { display: flex; gap: .16rem; align-items: stretch; }
.join-row .field { flex: 1; }
.join-row .btn { height: .64rem; }
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: .14rem; }
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); }
.code-chip {
padding: .03rem .12rem .03rem .16rem;
border: 1px solid var(--line-2);
background: rgba(0, 0, 0, .3);
color: var(--amber);
font-size: .17rem;
font-size: calc(.17 * var(--t));
font-weight: 700;
letter-spacing: .2em;
transition: border-color var(--t-fast), background-color var(--t-fast);
@@ -139,9 +139,9 @@
background: rgba(8, 10, 9, .55);
border: 1px solid var(--line);
}
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: .16rem; font-weight: 700; color: var(--text-md); }
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-md); }
.tips__head > .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.tips__idx { margin-left: auto; font-size: .16rem; color: var(--text-md); }
.tips__idx { margin-left: auto; font-size: calc(.16 * var(--t)); color: var(--text-md); }
.tips__nav {
display: grid;
place-items: center;
@@ -155,7 +155,7 @@
}
.tips__nav:hover { color: var(--mint-400); border-color: var(--mint-700); }
.tips__nav .icon { width: .18rem; height: .18rem; }
.tips__text { margin-top: .1rem; font-size: .17rem; color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
.tips__text { margin-top: .1rem; font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
@keyframes tip-in { from { opacity: 0; transform: translateX(.1rem); } }
/* ---- difficulty cards -------------------------------------------------------------------------------- */
@@ -182,20 +182,20 @@
.diff-card__bar { position: absolute; left: 0; top: 0; bottom: 0; width: .06rem; background: var(--d-color); opacity: .45; transition: opacity var(--t-med), box-shadow var(--t-med); }
.diff-card__head { grid-column: 1 / -1; display: flex; align-items: center; gap: .1rem; min-width: 0; }
.diff-card__glyph { width: .28rem; height: .28rem; color: var(--d-color); filter: drop-shadow(0 0 5px var(--d-color)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--d-color) 55%, transparent)); }
.diff-card__name { font-size: .27rem; font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
.diff-card__code { font-size: .14rem; color: var(--text-dim); letter-spacing: .1em; }
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: .17rem; }
.diff-card__name { font-size: calc(.27 * var(--t)); font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
.diff-card__code { font-size: calc(.14 * var(--t)); color: var(--text-dim); letter-spacing: .1em; }
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: calc(.17 * var(--t)); }
.diff-card__hidden {
margin-left: .08rem;
padding: 0 .07rem;
border: 1px solid transparent; border: 1px solid color-mix(in srgb, var(--d-color) 45%, transparent);
color: var(--d-color); color: color-mix(in srgb, var(--d-color) 80%, #fff);
font-size: .12rem;
font-size: calc(.12 * var(--t));
line-height: 1.6;
}
.diff-card__desc { grid-column: 1; font-size: .16rem; color: var(--text-md); }
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: .14rem; color: var(--text-lo); }
.diff-card__desc { grid-column: 1; font-size: calc(.16 * var(--t)); color: var(--text-md); }
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.diff-card__effects span::before { content: '·'; margin-right: .05rem; color: var(--d-color); font-weight: 900; }
/* battlefield pool note (config.json modes[].stages): 战场固定为 战场#01 / 战场随机(共N张) — the last item of the
effects row (no extra card height: the lobby must not scroll at 1280×720 / 1920×1080) */
@@ -210,7 +210,7 @@
flex-direction: column;
align-items: center;
gap: .03rem;
font-size: .12rem;
font-size: calc(.12 * var(--t));
color: var(--text-dim);
}
.diff-card__check .icon {
@@ -236,5 +236,5 @@
/* ---- create ------------------------------------------------------------------------------------------ */
.create-box { margin-top: .3rem; display: flex; flex-direction: column; gap: .12rem; }
.create-box .btn--xl { height: .92rem; font-size: .32rem; letter-spacing: .3em; }
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: .14rem; color: var(--text-lo); }
.create-box .btn--xl { height: .92rem; font-size: calc(.32 * var(--t)); letter-spacing: .3em; }
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
+20 -19
View File
@@ -10,15 +10,16 @@
-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: .96rem; 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__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); }
@keyframes head-in { from { opacity: 0; letter-spacing: .5em; } }
.result__sub { font-size: .2rem; color: var(--text-lo); letter-spacing: .1em; }
.result__sub { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .1em; }
.result__rounds { display: flex; align-items: baseline; gap: .12rem; margin-top: .1rem; }
.result__rlabel { font-size: .22rem; color: var(--text-md); letter-spacing: .14em; }
.result__rnum { font-size: 1.5rem; line-height: .9; font-weight: 700; color: #fff; }
.result__rof { font-size: .4rem; color: var(--text-dim); }
.result__rlabel { font-size: calc(.22 * var(--t)); color: var(--text-md); letter-spacing: .14em; }
.result__rnum { font-size: calc(1.5 * var(--t)); line-height: .9; font-weight: 700; color: #fff; }
.result__rof { font-size: calc(.4 * var(--t)); color: var(--text-dim); }
.result__medals { display: flex; gap: .24rem; margin-top: .12rem; }
.medal { position: relative; width: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: .08rem; }
.medal img { width: .96rem; height: .96rem; object-fit: cover; border-radius: 50%; border: 3px solid var(--line-3); background: #151a18; filter: grayscale(.7) brightness(.7); }
@@ -27,8 +28,8 @@
.medal__check { position: absolute; right: .04rem; top: .64rem; width: .32rem; height: .32rem; display: grid; place-items: center; background: var(--text-dim); color: #fff; border-radius: 50%; }
.medal.is-done .medal__check { background: var(--gold); color: #1d1400; }
.medal__check .icon { width: .2rem; height: .2rem; }
.medal__label { font-size: .14rem; color: var(--text-lo); }
.result__time { font-size: .16rem; }
.medal__label { font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.result__time { font-size: calc(.16 * var(--t)); }
.result__time b { color: var(--text-hi); }
.result__players { display: flex; flex-direction: column; gap: .1rem; min-height: 0; overflow: auto; padding-right: .06rem; }
@@ -45,34 +46,34 @@
.rcard__head { display: grid; grid-template-columns: auto minmax(1.5rem, 2.1rem) minmax(0, 1fr) auto 2.3rem; gap: .18rem; align-items: center; }
.rcard__head .pavatar { --pa: .6rem; }
.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.rcard__name { font-size: .22rem; display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: .11rem; padding: 0 .05rem; font-weight: 700; }
.rcard__name { font-size: calc(.22 * var(--t)); display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: calc(.11 * var(--t)); padding: 0 .05rem; font-weight: 700; }
.rcard__ai { background: #a9c3d8; color: #0b1520; }
.rcard__you { background: var(--mint-500); color: var(--text-on-mint); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.rcard__round { display: flex; flex-direction: column; align-items: center; }
.rcard__round b { font-size: .34rem; line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: .13rem; color: var(--gold); }
.rcard__round b { font-size: calc(.34 * var(--t)); line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: calc(.13 * var(--t)); color: var(--gold); }
.rcard__gain span { display: inline-flex; align-items: center; gap: .02rem; }
.rcard__gain .icon { width: .14rem; height: .14rem; }
.rcard__gain b { font-size: .14rem; color: var(--gold); }
.rcard__gain b { font-size: calc(.14 * var(--t)); color: var(--gold); }
.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; }
.rcard__title--none { border-style: dashed; border-color: var(--line); background: none; height: .64rem; }
.rcard__title > span { display: flex; flex-direction: column; }
.rcard__title b { font-size: .19rem; color: var(--gold); }
.rcard__title b { font-size: calc(.19 * var(--t)); color: var(--gold); }
.rcard__ticon { width: .5rem; height: .5rem; object-fit: contain; }
.rcard__title .icon { width: .3rem; height: .3rem; color: var(--gold); }
.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .06rem; min-width: 0; }
.rcard__lineup .uthumb { --ut: .46rem; }
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
.rcard__noinfo { font-family: var(--font-display); font-size: .14rem; letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__noinfo { font-family: var(--font-display); font-size: calc(.14 * var(--t)); letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__bonds { display: flex; gap: .05rem; flex-wrap: wrap; }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: .13rem; color: var(--text-lo); }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: calc(.13 * var(--t)); color: var(--text-lo); }
.rbond .bglyph { width: .2rem; height: .2rem; }
.rbond.is-on { border-color: var(--mint-800); color: var(--mint-400); }
.rcard__stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; }
.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: .13rem; color: var(--text-lo); white-space: nowrap; min-width: 0; }
.rstat b { font-size: .18rem; color: #fff; }
.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; }
+28 -21
View File
@@ -17,7 +17,7 @@
background: rgba(0, 0, 0, .38);
border: 1px solid var(--line);
}
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: .14rem; color: var(--text-lo); }
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.invite__label .icon { width: .16rem; height: .16rem; color: var(--mint-500); }
.invite__code { display: flex; gap: .06rem; }
.invite__code span {
@@ -28,13 +28,13 @@
background: linear-gradient(180deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02));
border-bottom: 2px solid var(--mint-600);
color: var(--mint-400);
font-size: .44rem;
font-size: calc(.44 * var(--t));
font-weight: 700;
line-height: 1;
text-shadow: 0 0 .16rem rgba(23, 249, 183, .45);
}
.invite__btns { display: flex; flex-direction: column; gap: .06rem; }
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: .16rem; color: var(--text-lo); }
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
/* ---- seats ------------------------------------------------------------------------------------------------ */
.seats {
@@ -82,7 +82,7 @@
transition: background var(--t-med), box-shadow var(--t-med);
}
.seat__head { align-self: stretch; display: flex; align-items: center; gap: .1rem; }
.seat__no { font-size: .22rem; font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
.seat__no { font-size: calc(.22 * var(--t)); font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
.seat__host {
margin-left: auto;
display: flex;
@@ -91,7 +91,7 @@
padding: .02rem .09rem;
background: var(--gold);
color: #1d1400;
font-size: .13rem;
font-size: calc(.13 * var(--t));
font-weight: 700;
}
.seat__host .icon { width: .15rem; height: .15rem; }
@@ -131,13 +131,13 @@
background: rgba(159, 212, 255, .1);
border: 1px solid rgba(159, 212, 255, .35);
color: var(--ice);
font-size: .14rem;
font-size: calc(.14 * var(--t));
font-weight: 700;
}
.seat__bot-label .icon { width: .16rem; height: .16rem; }
.seat__who { display: flex; align-items: center; gap: .08rem; max-width: 100%; }
.seat__name { font-size: .26rem; font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: .13rem; font-weight: 700; }
.seat__name { font-size: calc(.26 * var(--t)); font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: calc(.13 * var(--t)); font-weight: 700; }
.seat__foot {
align-self: stretch;
display: flex;
@@ -150,7 +150,7 @@
border-top: 1px solid var(--line);
}
.seat__foot .tt-anchor { position: absolute; right: .14rem; bottom: .14rem; }
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: .18rem; font-weight: 700; letter-spacing: .06em; }
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: calc(.18 * var(--t)); font-weight: 700; letter-spacing: .06em; }
.seat__state .icon { width: .22rem; height: .22rem; }
.seat__state--ready { color: var(--mint-400); }
.seat__state--waiting { color: var(--text-lo); }
@@ -185,7 +185,7 @@
animation: radar-sweep 4s linear infinite;
}
@keyframes radar-sweep { to { transform: rotate(360deg); } }
.seat__wait { font-size: .2rem; color: var(--text-lo); letter-spacing: .08em; }
.seat__wait { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .08em; }
.seat--empty .seat__foot { border-top-style: dashed; }
.solo-brief {
@@ -202,9 +202,9 @@
border: 1px solid var(--line);
animation: seat-in var(--t-slow) 80ms var(--ease-out) backwards;
}
.solo-brief h2 { font-size: .36rem; font-weight: 900; letter-spacing: .06em; }
.solo-brief p { font-size: .18rem; color: var(--text-md); }
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: .16rem; color: var(--text-lo); }
.solo-brief h2 { font-size: calc(.36 * var(--t)); font-weight: 900; letter-spacing: .06em; }
.solo-brief p { font-size: calc(.18 * var(--t)); color: var(--text-md); }
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
.solo-brief b { color: var(--text-hi); }
@@ -217,7 +217,7 @@
justify-content: center;
gap: .14rem;
padding: 0 .6rem .16rem;
font-size: .15rem;
font-size: calc(.15 * var(--t));
color: var(--text-lo);
}
.specbar__label { display: flex; align-items: baseline; gap: .06rem; font-weight: 700; color: var(--text-md); }
@@ -260,10 +260,10 @@
background: linear-gradient(90deg, var(--mint-700), transparent 30%, transparent 70%, var(--mint-700));
}
.room-bar__left { display: flex; align-items: center; gap: .22rem; min-width: 0; }
.room-bar__label { display: flex; flex-direction: column; font-size: .17rem; font-weight: 700; color: var(--text-md); line-height: 1.35; }
.room-bar__label { display: flex; flex-direction: column; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--text-md); line-height: 1.35; }
.room-bar__center { display: flex; flex-direction: column; align-items: center; gap: .06rem; }
.room-bar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; }
.room-bar__status { font-size: .15rem; }
.room-bar__status { font-size: calc(.15 * var(--t)); }
.room-bar__status .icon { width: .18rem; height: .18rem; margin-right: .05rem; vertical-align: -.03rem; }
.dpick { display: flex; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); }
@@ -279,7 +279,7 @@
border-right: 1px solid var(--line);
background: transparent;
color: var(--text-lo);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
letter-spacing: .08em;
transition: color var(--t-fast), background-color var(--t-med), box-shadow var(--t-med);
@@ -295,12 +295,19 @@
box-shadow: inset 0 -3px 0 var(--d-color);
}
.dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: calc(.14 * var(--t)); }
/* 「AI 队友最后选择」 (GitHub #338, co-op): one switch styled like a difficulty option, under the difficulty picker */
.room-bar__opts { display: flex; flex-direction: column; align-items: flex-start; gap: .08rem; min-width: 0; }
.ailast { display: flex; align-items: center; gap: .12rem; font-size: calc(.14 * var(--t)); }
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: calc(.15 * var(--t)); letter-spacing: .04em; }
.ailast__opt:last-child { border-right: 1px solid var(--line-2); }
.ailast__opt .icon { opacity: .25; }
.ailast__opt .icon.is-on { opacity: 1; }
.ready-count[hidden] { display: none; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; }
.ready-count b { font-size: .36rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
.ready-count .t-dim { font-size: .22rem; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: calc(.16 * var(--t)); }
.ready-count b { font-size: calc(.36 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
.ready-count .t-dim { font-size: calc(.22 * var(--t)); }
.ready-count__icons { display: flex; gap: .02rem; margin-left: .12rem; align-self: center; }
.ready-count__icons .icon { width: .28rem; height: .28rem; color: var(--line-3); transition: color var(--t-med); }
.ready-count__icons .icon.is-on { color: var(--mint-400); filter: drop-shadow(0 0 4px var(--mint-glow)); }
+193
View File
@@ -0,0 +1,193 @@
/* ==========================================================================================
screens/stats.css — 统计数据 (js/screens/stats.js): the local match record as a full-screen overlay in the grammar of
干员调配 (screens/loadout.css) — a top bar with the back button, four tabs (总览 / 策略 / 战斗累计 / 对局记录) and
导出 / 导入 / 清空, a note line, a scrolling body of number cards, tables, the six 评语 and the history list; a stored
match re-viewed as its settlement fills the overlay (.st--replay).
Sizes are design px ÷ 100 (theme.css root scale); text that must stay readable on phones uses max(.xxrem, N px)
(the root scale is clamped at 40 px there, so a bare .15rem would be 6 px). Ported from PR #323 by @2321Robin.
========================================================================================== */
.st {
position: fixed; inset: 0; z-index: 55; /* like .lo: above every screen (1) and the in-match banners (40), below the connection
banner (60), modals / confirm dialogs (80), toasts and tooltips */
display: flex; flex-direction: column;
padding: var(--sa-t, 0px) var(--sa-r, 0px) var(--sa-b, 0px) var(--sa-l, 0px);
background: var(--bg-0);
color: var(--text-hi);
outline: none;
animation: st-in 200ms var(--ease-out) backwards;
overscroll-behavior: contain;
}
@keyframes st-in { from { opacity: 0; } }
.st__bg {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(70% 90% at 18% 10%, rgba(78, 216, 175, .07), transparent 60%),
radial-gradient(60% 70% at 100% 100%, rgba(246, 163, 41, .05), transparent 60%),
linear-gradient(rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(90deg, rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(180deg, #0f1312, #090b0a);
}
.st-top, .st-note, .st-body { position: relative; }
/* the app screens behind the overlay do not need to animate */
.sp-stats-open .app-root > .screen { visibility: hidden; }
/* a stored match re-viewed as its settlement (the result screen fills the overlay) */
.st--replay { background: var(--bg-0); }
/* ---- top bar ----------------------------------------------------------------------------------------------------- */
.st-top {
flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem;
height: max(1rem, 48px); padding: 0 .36rem;
background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6));
border-bottom: 1px solid var(--line);
}
.st-top::after {
content: ''; position: absolute; left: .36rem; right: .36rem; bottom: -1px; height: 1px;
background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700));
}
.st-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; }
.st-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 0; }
.st-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
.st-tabs { display: flex; align-items: stretch; gap: .04rem; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.st-tab {
display: inline-flex; align-items: center; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900;
letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer;
touch-action: manipulation; white-space: nowrap;
}
.st-tab:hover { color: var(--text-hi); }
.st-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); }
/* the note lines under the bar: where the data lives, what went wrong with the stored value */
.st-note {
flex: none; display: flex; align-items: center; gap: .08rem; margin: 0; padding: .07rem .36rem;
font-size: max(.15rem, 10px); color: var(--text-lo);
background: linear-gradient(90deg, rgba(78, 216, 175, .08), transparent 70%);
border-bottom: 1px solid rgba(47, 58, 53, .6);
}
.st-note .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); color: var(--mint-500); flex: none; }
.st-note.is-warn { color: var(--amber); background: linear-gradient(90deg, rgba(246, 163, 41, .12), transparent 70%); }
.st-note.is-warn .icon { color: var(--amber); }
/* ---- body ------------------------------------------------------------------------------------------------------------ */
.st-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
padding: .2rem .36rem .32rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
}
.st-body > * { max-width: max(12.4rem, 760px); margin-left: auto; margin-right: auto; }
.st-loading { flex: 1; display: grid; place-items: center; min-height: 2rem; }
.st-empty { margin: .6rem auto; text-align: center; font-size: max(.2rem, 13px); color: var(--text-lo); letter-spacing: .06em; }
.st-sec__h { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .1rem; font-size: max(.22rem, 14px); font-weight: 800; letter-spacing: .1em; color: var(--text-hi); }
.st-sub { margin: .22rem 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; letter-spacing: .08em; color: var(--text-md); }
/* number cards */
.st-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(max(1.5rem, 96px), 1fr)); gap: .08rem; }
.st-card {
display: flex; flex-direction: column; gap: .02rem; min-width: 0; padding: .08rem .12rem .07rem;
background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
}
.st-card--mint { border-color: var(--mint-800); background: var(--mint-a10); }
.st-card--gold { border-color: rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .07); }
.st-card__num { font-size: max(.3rem, 17px); font-weight: 900; line-height: 1.15; color: var(--text-hi); overflow-wrap: anywhere; }
.st-card--mint .st-card__num { color: var(--mint-400); }
.st-card--gold .st-card__num { color: var(--amber); }
.st-card__label { font-size: max(.14rem, 10px); color: var(--text-md); letter-spacing: .06em; }
.st-split { display: flex; gap: .26rem; margin: .1rem 0 .14rem; font-size: max(.15rem, 11px); color: var(--text-md); }
.st-split b { margin-left: .06rem; font-size: max(.18rem, 12px); color: var(--text-hi); }
/* tables (难度 / 策略 / 对局记录) */
.st-tablewrap { overflow-x: auto; }
.st-table { width: 100%; border-collapse: collapse; font-size: max(.17rem, 11px); }
.st-table th {
padding: .05rem .1rem; text-align: left; font-size: max(.14rem, 10px); font-weight: 600; letter-spacing: .08em;
color: var(--text-lo); border-bottom: 1px solid var(--line-2); white-space: nowrap;
}
.st-table td { padding: .06rem .1rem; color: var(--text-md); border-bottom: 1px solid var(--line); vertical-align: middle; }
/* the per-difficulty and per-strategy tables stay narrow enough to read across; the history takes the width */
.st-table:not(.st-table--history) { max-width: max(9.4rem, 560px); }
.st-table th.num, .st-table td.num { text-align: right; }
.st-table tbody tr:hover td { background: rgba(78, 216, 175, .05); }
.st-table td.st-diff { color: var(--d-color, var(--text-md)); font-weight: 700; white-space: nowrap; }
.st-band { display: inline-flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; vertical-align: middle; }
.st-band .bandicon { flex: none; }
.st-t { font-family: var(--font-num); font-size: max(.15rem, 10px); color: var(--text-lo); white-space: nowrap; }
.st-rate { white-space: nowrap; }
.st-rate b { color: var(--text-hi); font-weight: 700; }
.st-bar { display: inline-block; width: max(.8rem, 48px); height: max(.07rem, 4px); margin-right: .1rem; vertical-align: middle; background: var(--line); }
.st-bar i { display: block; height: 100%; background: var(--mint-500); }
/* 对局记录: result colours, the 不计入统计 chip, clickable rows */
.st-res { white-space: nowrap; }
.t-win { color: var(--mint-400); font-weight: 800; }
.t-lose { color: var(--red-premium); font-weight: 800; }
.t-quit { color: var(--amber); font-weight: 800; }
.st-chip {
display: inline-block; margin-left: .08rem; padding: 0 .06rem; font-style: normal; font-size: max(.12rem, 9px); letter-spacing: .04em;
color: var(--text-lo); border: 1px solid var(--line-2); white-space: nowrap;
}
.st-table tr.is-excluded td { opacity: .6; }
.st-table tr.is-clickable { cursor: pointer; }
.st-table tr.is-clickable:hover td, .st-table tr.is-clickable:focus-visible td { background: rgba(78, 216, 175, .1); color: var(--text-hi); }
.st-table tr.is-clickable:focus-visible { outline: 2px solid var(--mint-400); outline-offset: -2px; }
.st-go { width: .24rem; color: var(--text-lo); }
.st-go .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); }
.st-table tr.is-clickable:hover .st-go { color: var(--mint-400); }
.st-more { display: flex; justify-content: center; margin: .14rem 0 0; }
.st-hint { margin: .1rem 0 0; font-size: max(.13rem, 10px); line-height: 1.6; color: var(--text-lo); letter-spacing: .04em; }
/* the six 评语 with their counts */
.st-titles { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.9rem, 120px), 1fr)); gap: .08rem; }
.st-title { display: flex; align-items: center; gap: .08rem; min-width: 0; padding: .06rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); }
.st-title.is-none { opacity: .45; }
.st-title__icon { width: max(.38rem, 24px); height: max(.38rem, 24px); flex: none; object-fit: contain; }
.st-title__text { display: flex; flex-direction: column; min-width: 0; }
.st-title__text span { font-size: max(.15rem, 10px); color: var(--text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st-title__text b { font-size: max(.22rem, 14px); font-weight: 900; color: var(--amber); line-height: 1.1; }
/* what counts (the rule of ui/stats.js countsTowardStats) */
.st-rule { display: flex; flex-wrap: wrap; align-items: center; gap: .06rem; margin: .24rem 0 0; font-size: max(.14rem, 10px); line-height: 1.6; color: var(--text-lo); }
.st-rule .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); flex: none; color: var(--mint-500); }
.st-rule b { color: var(--amber); font-weight: 700; }
/* 战斗累计 */
.st-sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(2.4rem, 150px), 1fr)); gap: .06rem; }
.st-sum { display: flex; align-items: baseline; justify-content: space-between; gap: .08rem; padding: .08rem .12rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); }
.st-sum span { font-size: max(.15rem, 10px); color: var(--text-md); }
.st-sum b { font-size: max(.22rem, 14px); font-weight: 800; color: var(--text-hi); }
/* the shared Button scales with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's own controls keep a
readable size and a tappable height */
.st .st-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
.st .st-top__right .btn, .st .st-more .btn { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
/* the entry buttons of the lobby / room top bars: on the narrowest phones (≤ 700 px) the icon alone — the room's top bar has
no room for another label beside 玩法说明 (its title ran under the label at 640 px); the name stays aria-label / title */
@media (max-width: 700px) {
.stats-entry .btn__icon + .btn__label {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
/* ---- narrow / short viewports ------------------------------------------------------------------------------------------ */
@media (max-width: 1200px) {
.st-tab { padding: .02rem .08rem; letter-spacing: .04em; }
}
/* phones in landscape (up to 932 px wide): 返回 shows its icon only (the text stays its name) */
@media (max-width: 1000px) {
.st-top .btn__icon + .btn__label {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
@media (max-width: 760px) {
.st-top { padding: 0 .16rem; gap: .1rem; }
.st-top::after { left: .16rem; right: .16rem; }
.st-note { padding: .07rem .16rem; }
.st-body { padding: .12rem .16rem .2rem; }
}
@media (max-height: 520px) {
.st-note { display: none; }
.st-body { padding-top: .12rem; }
.st-split { margin: .06rem 0 .08rem; }
}
.sp-coarse .st-tab, .sp-coarse .st-table tr.is-clickable td { min-height: 30px; }
@media (prefers-reduced-motion: reduce) { .st { animation: none; } }
+15 -10
View File
@@ -197,7 +197,7 @@
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
.title-tag {
margin-top: .12rem;
font-size: .19rem;
font-size: calc(.19 * var(--t));
color: var(--text-lo);
letter-spacing: .1em;
animation: rise-in 600ms 200ms var(--ease-out) backwards;
@@ -238,18 +238,23 @@
background: rgba(246, 163, 41, .08);
border: 1px solid rgba(246, 163, 41, .35);
color: var(--amber);
font-size: .16rem;
font-size: calc(.16 * var(--t));
}
.title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */
.title-invite b { font-size: calc(.2 * var(--t)); letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
/* 统计 (the local stats page) beside the language menu in the top-right corner */
.title-corner__tools { display: flex; align-items: center; justify-content: flex-end; gap: .1rem; margin-bottom: .1rem; }
.title-corner__tools .title-lang { margin-bottom: 0; }
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list.
The height is max(design, 1.4em) so the buttons still hold their text at 设置 →「文字大小」 大 / 特大 (on a phone
1rem is clamped at 40 px and .3rem is only 12 px, less than the enlarged label). */
.title-lang { display: inline-flex; margin-bottom: .1rem; }
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; }
.title-lang.lang-select select { height: .3rem; font-size: .14rem; }
.title-lang button { min-width: .74rem; height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); cursor: pointer; }
.lang-select select { height: max(.38rem, 1.4em); min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: calc(.16 * var(--t)); cursor: pointer; }
.title-lang.lang-select select { height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); }
html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
.title-conn .ping { height: .26rem; font-size: .15rem; }
.title-conn .ping { height: max(.26rem, 1.6em); font-size: calc(.15 * var(--t)); }
.title-foot {
position: absolute;
@@ -260,7 +265,7 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: .13rem;
font-size: calc(.13 * var(--t));
color: var(--text-dim);
}
/* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */
+18 -2
View File
@@ -125,6 +125,22 @@ html {
background: var(--bg-0);
}
/* ---- text root: 设置 →「文字大小」 ---------------------------------------------------------------
1rem above is the LAYOUT root, and five modules convert it to pixels with getComputedStyle:
ui/fieldHost.js hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js
panelSlots (which side the detail card docks), ui/underframe.js (the tile labels' pixel budget) and
ui/fallbackField.js (the DOM board) — raising it would move the board, not just the text.
--t is the TEXT root: it appears only in font-size declarations, so a player can enlarge the
interface text without touching any layout. The field itself is sized from host.clientWidth /
clientHeight (render/app.js), which no font-size can reach.
小 = 1rem reproduces the design's own sizes exactly, on every device. The other steps keep the
desktop gentle and lift the phone: 1rem there is clamped at 40 px by the rule above, which is what
made the .18rem body text 7.2 CSS px. */
:root { --t: 1rem; }
:root[data-text="md"] { --t: max(1rem, 64px); }
:root[data-text="lg"] { --t: max(calc(1rem * 1.15), 76px); }
:root[data-text="xl"] { --t: max(calc(1rem * 1.3), 88px); }
*, *::before, *::after { box-sizing: border-box; }
body {
@@ -134,7 +150,7 @@ body {
background: var(--bg-0);
color: var(--text-hi);
font-family: var(--font-cjk);
font-size: .18rem;
font-size: calc(.18 * var(--t));
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
@@ -170,7 +186,7 @@ h1, h2, h3, h4, p { margin: 0; }
.display { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; }
.micro {
font-family: var(--font-display);
font-size: .11rem;
font-size: calc(.11 * var(--t));
line-height: 1.2;
letter-spacing: .18em;
text-transform: uppercase;
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Battle perf · 卫戍协议:盟约</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<!-- PixiJS 7 + pixi-spine as classic scripts (global PIXI / PIXI.spine), like the game page -->
<script src="/vendor/pixi.min.js"></script>
<script src="/vendor/pixi-spine.js"></script>
<style>
/* Battle perf panel: the game's own tokens (css/theme.css — green-grey glass, mint, Bender figures, bracket corners)
in px, not the game's viewport-scaled rem. The one loud element is the frame-time strip. */
html, body { margin: 0; height: 100%; background: var(--bg-0); overflow: hidden; }
#field { position: fixed; inset: 0; }
.bp { position: fixed; top: 10px; right: 10px; z-index: 5; width: 320px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px);
overflow: auto; background: var(--bg-glass); border: 1px solid var(--line); color: var(--text-md);
font: 13px/1.45 var(--font-cjk); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); --bk-l: 10px; }
.bp.is-hidden, .bp [hidden] { display: none; }
.bp-body { display: grid; gap: 10px; padding: 10px 12px 12px; }
.bp-head { display: flex; align-items: center; gap: 8px; }
.bp-head h1 { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-hi); flex: 1; }
.bp button, .bp select { font: 13px/1.2 var(--font-cjk); color: var(--text-hi); background: var(--bg-2); border: 1px solid var(--line-2);
padding: 5px 8px; cursor: pointer; min-height: 30px; }
.bp button:hover, .bp select:hover { border-color: var(--line-3); }
.bp button:focus-visible, .bp select:focus-visible, .bp summary:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 1px; }
.bp button:disabled { opacity: .5; cursor: default; }
.bp-ctl { display: grid; grid-template-columns: 1fr auto; gap: 6px; }
.bp-ctl select { min-width: 0; }
.bp-ctl #spec { grid-column: 1 / -1; }
.bp-live { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: end; }
.bp-fps { font: 600 44px/0.95 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bp-fps small { font: 400 13px var(--font-cjk); color: var(--text-lo); margin-left: 4px; }
.bp-figs { display: grid; grid-template-columns: auto auto; gap: 1px 10px; justify-content: end; font-size: 12px; color: var(--text-lo); }
/* decimals: Bender's point is nearly invisible at small sizes (4.2 reads as 42), so small figures use the CJK face */
.bp-figs b { font: 600 13px var(--font-cjk); color: var(--text-hi); text-align: right; font-variant-numeric: tabular-nums; }
.bp-meta { grid-column: 1 / -1; font-size: 12px; color: var(--text-lo); }
.bp-strip { position: relative; height: 70px; background: var(--bg-0); border: 1px solid var(--line); }
.bp-strip canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.bp-freeze { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 0 12px;
font-size: 12px; color: var(--text-md); background: rgba(12, 15, 14, .72); }
.bp-key { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 11.5px; color: var(--text-lo); }
.bp-key i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; }
.bp .bp-measure { position: relative; overflow: hidden; width: 100%; min-height: 36px; font-weight: 700;
color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
.bp .bp-measure:hover { background: var(--mint-400); border-color: var(--mint-400); }
.bp-fill { position: absolute; inset: 0; background: var(--mint-800); transform-origin: left; transform: scaleX(0); }
.bp .bp-measure.is-running { color: var(--text-hi); background: var(--mint-900); border-color: var(--mint-700); cursor: default; opacity: 1; }
.bp-measure.is-running .bp-fill { animation: bp-fill 10s linear forwards; }
.bp-measure span { position: relative; }
@keyframes bp-fill { to { transform: scaleX(1); } }
.bp-result { display: grid; gap: 8px; border-top: 1px solid var(--line); padding-top: 10px; }
.bp-verdict { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 8px; color: var(--text-hi); font-weight: 700; }
.bp-verdict::before { content: ''; width: 10px; height: 10px; flex: none; align-self: center; background: var(--v, var(--mint-500)); }
.bp-verdict small { font-weight: 400; color: var(--text-lo); font-size: 12px; }
.bp-sum { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.bp-sum div { display: grid; background: var(--bg-1); border: 1px solid var(--line); padding: 5px 7px; min-width: 0; }
.bp-sum span { font-size: 11.5px; color: var(--text-lo); }
.bp-sum b { font: 700 16px/1.3 var(--font-cjk); color: var(--text-hi); font-variant-numeric: tabular-nums; }
.bp-result details { font-size: 12px; }
.bp-result summary { cursor: pointer; color: var(--text-md); }
.bp-report { margin: 6px 0 0; font: 12px/1.6 var(--font-cjk); color: var(--text-hi); white-space: pre-wrap; word-break: break-word;
background: var(--bg-0); border: 1px solid var(--line); padding: 6px 8px; max-height: 30vh; overflow: auto; user-select: text; }
/* collapsed: a chip with the live figures and a mini strip */
.bp .bp-chip { display: none; align-items: center; gap: 8px; width: 100%; padding: 4px 8px 4px 10px; background: none; border: 0; }
.bp-chip b { font: 600 20px/1 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; }
.bp-chip span { font-size: 12px; color: var(--text-lo); white-space: nowrap; }
.bp-chip canvas { width: 72px; height: 22px; display: block; margin-left: 4px; }
.bp.is-collapsed { width: auto; }
.bp.is-collapsed .bp-body { display: none; }
.bp.is-collapsed .bp-chip { display: flex; }
@media (prefers-reduced-motion: reduce) { .bp-measure.is-running .bp-fill { animation: none; transform: scaleX(1); } }
</style>
</head>
<body>
<div id="field"></div>
<section class="bp brackets" id="panel" aria-label="战斗性能测试">
<button class="bp-chip" id="expand" type="button" aria-expanded="false" aria-controls="bp-body" title="展开面板">
<b id="chip-fps">–</b><span id="chip-ms">FPS</span><canvas id="chip-strip" aria-hidden="true"></canvas>
</button>
<div class="bp-body" id="bp-body">
<div class="bp-head"><h1>战斗性能测试</h1><button id="collapse" type="button" aria-expanded="true" aria-controls="bp-body">收起</button></div>
<div class="bp-ctl">
<select id="spec" aria-label="战斗场景"></select>
<select id="quality" aria-label="画质"><option value="high">高画质</option><option value="medium">中画质</option><option value="low">低画质</option></select>
<button id="restart" type="button">重新开始</button>
</div>
<div class="bp-live">
<div class="bp-fps"><span id="fps">–</span><small>FPS</small></div>
<div class="bp-figs">
<span>帧耗时</span><b id="f-frame">–</b>
<span>渲染 CPU</span><b id="f-view">–</b>
<span>模拟单步</span><b id="f-sim">–</b>
</div>
<div class="bp-meta" id="meta">正在加载战斗…</div>
</div>
<div class="bp-strip" title="近 10 秒逐帧耗时">
<canvas id="strip" role="img" aria-label="近 10 秒逐帧耗时"></canvas>
<div class="bp-freeze" id="freeze" hidden>测量进行中,图表暂停刷新以避免干扰结果</div>
</div>
<div class="bp-key"><span><i style="background:var(--mint-500)"></i>≤ 16.7 ms(≥ 60 FPS)</span><span><i style="background:var(--amber)"></i>16.7–33.3 ms</span><span><i style="background:var(--red)"></i>&gt; 33.3 ms(卡顿帧)</span></div>
<button class="bp-measure" id="measure" type="button" disabled><i class="bp-fill" aria-hidden="true"></i><span id="measure-label">开始测量(10 秒)</span></button>
<div class="bp-result" id="result" hidden aria-live="polite">
<div class="bp-verdict" id="verdict"></div>
<div class="bp-sum">
<div><span>平均帧率</span><b id="r-fps">–</b></div>
<div><span>P95 帧耗时</span><b id="r-p95">–</b></div>
<div><span>卡顿帧占比</span><b id="r-over">–</b></div>
</div>
<details><summary>完整报告(提交反馈时请附上)</summary><pre class="bp-report" id="out"></pre></details>
<button id="copy" type="button">复制报告</button>
</div>
</div>
</section>
<script type="module" src="/dev/battle-perf.js"></script>
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
// Battle perf page (dev): one real battle in the browser, run the way the match screen runs it — the client-side battle
// runner (js/battle/runner.js: the shared sim at the battle speed) feeding the field view (js/render/app.js) — from a
// BattleSpec captured off a bot match (tools/capture-specs.mjs → /dev/perf/<name>.json). Players can open it on a
// phone and report the numbers (「开始测量」→ a verdict and a copyable report); tools/perfbench.mjs drives it headlessly.
//
// Query: ?spec=<name> (default: the first of /dev/perf/index.json) &quality=high|medium|low &panel=0 (hide the panel)
// &board=2d|3d (render/app.js reads it)
// Puppeteer hook: window.__perf = { ready, error, view, runner, sample(ms) → figures over `ms` of animation frames }
//
// The panel must not disturb what it measures: frame times go into a ring buffer (one store per frame), the strip is
// redrawn 4× a second from it, binned to pixel columns, and both the strip and the live figures stand still during a
// measurement (only a CSS progress animation runs).
import { createFieldView } from '../js/render/app.js';
import { data } from '../js/data.js';
import { assets } from '../js/assets.js';
import { createBattleRunner } from '../js/battle/runner.js';
import { round, frameFigures } from './frame-stats.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
const perf = { ready: false, error: null };
window.__perf = perf;
const longTasks = [];
try {
new PerformanceObserver((list) => { for (const e of list.getEntries()) longTasks.push(e.duration); }).observe({ type: 'longtask', buffered: true });
} catch { /* not supported */ }
// Frame-time budgets (ms) shared by the verdict, the strip colours and the stutter share: 16.7 / 33.3 ms are 60 / 30 FPS.
// The 60 FPS budget allows 5% for vsync jitter (a 60 Hz display measures frames of 16.6–17.4 ms); a frame over the 30 FPS
// budget counts as a stutter frame.
export const FRAME_60_MS = 17.5;
export const FRAME_30_MS = 33.4;
const QUALITY_NAME = { high: '高', medium: '中', low: '低' };
/**
* Frame figures over `ms` of animation frames: average fps, frame-time percentiles, the share of frames over 33.4 ms
* (below 30 fps), long tasks, the sim's ticks and their cost (runner.stats), and the view's own CPU / render times.
* The first frame time is partial (from the call to the first frame) and dropped, so the sample runs for at least two
* frames whatever `ms` is: a sample that saw a single frame (a short `ms`, a frame longer than `ms`) had no frame time
* left and divided by zero.
*/
perf.sample = (ms = 10000) => new Promise((resolve) => {
const deltas = [];
let last = performance.now();
const t0 = last;
const r0 = { ...perf.runner.stats() };
const lt0 = longTasks.length;
const step = (t) => {
deltas.push(t - last);
last = t;
if (t - t0 < ms || deltas.length < 2) { requestAnimationFrame(step); return; }
deltas.shift();
const r1 = perf.runner.stats();
const v = perf.view.stats();
const lt = longTasks.slice(lt0);
const ticks = r1.ticks - r0.ticks;
resolve({
...frameFigures(deltas, FRAME_30_MS),
longTasks: lt.length, longTaskMs: Math.round(lt.reduce((a, b) => a + b, 0)),
ticks, simMs: round(r1.stepMs - r0.stepMs), simMsPerTick: ticks > 0 ? round((r1.stepMs - r0.stepMs) / ticks, 3) : null,
viewCpuMs: v.cpuMs, renderMs: v.renderMs, units: v.units, lod: v.lod, particles: v.particles,
board: v.board3d?.on ? '3d' : '2d', done: !!perf.runner.state()?.done,
});
};
requestAnimationFrame(step);
});
/** The device line of a report: renderer, screen, cores (what a phone report needs to be comparable). */
function deviceInfo() {
let gpu = '?';
try {
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : 'no WebGL');
} catch { /* ignore */ }
return {
ua: navigator.userAgent, gpu, cores: navigator.hardwareConcurrency || null, memoryGB: navigator.deviceMemory || null,
viewport: `${innerWidth}×${innerHeight}`, dpr: devicePixelRatio,
};
}
/** The copyable report: one item per line, the panel's terms and units (pasted into feedback as is). */
function report(spec, title, quality, s) {
const d = deviceInfo();
const lines = [
'战斗性能测试报告(/dev/battle-perf.html)',
`场景:${title}(${spec})`,
`画质:${QUALITY_NAME[quality] || quality};棋盘:${s.board === '3d' ? '3D' : '2D'}`,
`帧率:${s.fps} FPS`,
`帧耗时:P50 ${s.p50} ms,P95 ${s.p95} ms,P99 ${s.p99} ms`,
`卡顿帧占比(单帧 > 33.3 ms):${s.over33}%`,
`模拟单步:${s.simMsPerTick} ms(共 ${s.ticks} 步)`,
`渲染 CPU:${s.viewCpuMs} ms;渲染提交:${s.renderMs} ms`,
`场上单位:${s.units};自适应负载档位:${s.lod}`,
`长任务:${s.longTasks} 次,共 ${s.longTaskMs} ms`,
`GPU:${d.gpu}`,
`设备:${d.cores ?? '未知'} 个 CPU 线程${d.memoryGB ? `,内存约 ${d.memoryGB} GB` : ''};视口 ${d.viewport},像素比 ${d.dpr}`,
`浏览器:${d.ua}`,
];
if (s.done) lines.splice(10, 0, '注意:战斗在测量期间结束,结果仅供参考');
return lines.join('\n');
}
/** The verdict a player reads first, on the frame-time budgets of the strip (60 / 30 FPS). */
export function verdictOf(s) {
if (s.p95 <= FRAME_60_MS && s.over33 < 1) return { label: '流畅', note: `P95 帧耗时 ${s.p95} ms,达到 60 FPS`, color: '--mint-500' };
if (s.p95 <= FRAME_30_MS && s.over33 < 1) return { label: '基本流畅', note: `P95 帧耗时 ${s.p95} ms,介于 30–60 FPS`, color: '--amber' };
return { label: '卡顿', note: `卡顿帧占比 ${s.over33}%,P95 帧耗时 ${s.p95} ms`, color: '--red' };
}
// ---- frame ring buffer + strip ------------------------------------------------------------------------------------
const RING = 4096;
const ringT = new Float64Array(RING); // frame end (performance.now)
const ringD = new Float32Array(RING); // frame time (ms)
let ringN = 0;
let lastT = 0;
function recordFrames(t) {
if (lastT) { const i = ringN % RING; ringT[i] = t; ringD[i] = t - lastT; ringN++; }
lastT = t;
requestAnimationFrame(recordFrames);
}
const SPAN_MS = 10000; // the strip shows the last 10 s
const TOP_MS = 50; // frame times above clip to the top (a red spike)
let palette = null;
function colors() {
if (!palette) {
const cs = getComputedStyle(document.documentElement);
const v = (n) => cs.getPropertyValue(n).trim();
palette = { good: v('--mint-500'), warn: v('--amber'), bad: v('--red'), line: v('--line-2'), text: v('--text-lo') };
}
return palette;
}
const band = (ms) => (ms <= FRAME_60_MS ? 'good' : ms <= FRAME_30_MS ? 'warn' : 'bad');
/** Draw the last SPAN_MS of frame times into `cv`: one column per pixel (its slowest frame), budget lines at 60 / 30 fps. */
function drawStrip(cv, { labels = true } = {}) {
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
const dpr = Math.min(2, devicePixelRatio || 1);
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const c = colors();
const pad = labels ? 4 : 1;
const y = (ms) => h - pad - (Math.min(ms, TOP_MS) / TOP_MS) * (h - pad * 2);
g.fillStyle = c.line;
for (const ms of [16.7, 33.3]) g.fillRect(0, Math.round(y(ms)), w, 1);
if (labels) {
g.fillStyle = c.text;
g.font = '10px "Noto Sans SC", sans-serif';
g.textAlign = 'right';
g.fillText('60 FPS', w - 3, y(16.7) - 3);
g.fillText('30 FPS', w - 3, y(33.3) - 3);
}
const now = performance.now();
const cols = new Float32Array(Math.ceil(w));
for (let k = 1; k <= Math.min(ringN, RING); k++) {
const i = (ringN - k) % RING;
const age = now - ringT[i];
if (age > SPAN_MS) break;
const x = Math.floor(w - 1 - (age / SPAN_MS) * w);
if (x >= 0 && ringD[i] > cols[x]) cols[x] = ringD[i];
}
const barW = labels ? 1 : 1.5;
for (let x = 0; x < cols.length; x++) {
const ms = cols[x];
if (!ms) continue;
g.fillStyle = c[band(ms)];
const top = y(ms);
g.fillRect(x, top, barW, h - pad - top);
}
}
// ---- panel ----------------------------------------------------------------------------------------------------------
const PANEL_KEY = 'sp.dev.battlePerfPanel';
function loadPanelPref() {
try { return localStorage.getItem(PANEL_KEY); } catch { return null; }
}
function savePanelPref(v) {
try { localStorage.setItem(PANEL_KEY, v); } catch { /* private mode */ }
}
function setupPanel() {
const panel = $('panel');
const set = (collapsed, save = true) => {
panel.classList.toggle('is-collapsed', collapsed);
$('expand').setAttribute('aria-expanded', String(!collapsed));
$('collapse').setAttribute('aria-expanded', String(!collapsed));
if (save) savePanelPref(collapsed ? 'collapsed' : 'open');
};
const pref = loadPanelPref();
// phones (a landscape phone is short): start folded, the battle is what is being measured
set(pref ? pref === 'collapsed' : (innerWidth < 900 || innerHeight < 500), false);
$('collapse').onclick = () => { set(true); $('expand').focus(); };
$('expand').onclick = () => { set(false); $('collapse').focus(); };
}
async function main() {
if (q.get('panel') === '0') $('panel').classList.add('is-hidden');
setupPanel();
const index = await fetch('/dev/perf/index.json').then((r) => (r.ok ? r.json() : []), () => []);
const specName = q.get('spec') || index[0]?.name;
if (!specName) throw new Error('未找到战斗数据。请先运行 node tools/capture-specs.mjs 生成。');
const quality = ['high', 'medium', 'low'].includes(q.get('quality')) ? q.get('quality') : 'high';
for (const s of index) $('spec').append(new Option(s.title || s.name, s.name));
$('spec').value = specName;
$('quality').value = quality;
const reload = () => {
const p = new URLSearchParams(location.search);
p.set('spec', $('spec').value);
p.set('quality', $('quality').value);
location.search = p.toString();
};
$('restart').onclick = reload;
$('spec').onchange = reload;
$('quality').onchange = reload;
await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config');
await assets.ready();
const msg = await fetch(`/dev/perf/${encodeURIComponent(specName)}.json`).then((r) => {
if (!r.ok) throw new Error(`战斗数据 ${specName} 加载失败(HTTP ${r.status})。请确认该文件位于 public/dev/perf/。`);
return r.json();
});
const view = await createFieldView($('field'), { data, assets, settings: { quality, damageNumbers: true } });
view.setStage(data.lookup('stages', msg.spec.stageId));
// the runner needs no server here: reports go nowhere, the battle runs from its start
const net = { on: () => () => {}, send() {}, request: async () => ({ ok: true }) };
const runner = createBattleRunner({ net, store: null });
const camKind = msg.kind === 'hidden' ? 'boss' : msg.kind;
runner.on('field', (field) => { view.enterBattle(field); view.setCamera(camKind, { rect: field.rect, side: 'L', instant: true }); });
runner.on('snap', (snap) => view.pushSnapshot(snap));
runner.on('ev', (ev) => view.pushEvents(ev));
perf.view = view;
perf.runner = runner;
await runner.onStart({ ...msg, elapsed: 0, authoritative: true, watch: false, done: false });
perf.ready = true;
// frame times from here on: the loading hitches before the battle are not the battle's
requestAnimationFrame(recordFrames);
let measuring = false;
let prev = { ...runner.stats() };
const live = () => {
if (measuring) return;
const v = view.stats();
const r = runner.stats();
const ticks = r.ticks - prev.ticks;
const simMs = ticks > 0 ? (r.stepMs - prev.stepMs) / ticks : 0;
prev = { ...r };
const fps = Math.round(v.fps);
$('fps').textContent = fps;
$('f-frame').textContent = `${v.frameMs.toFixed(1)} ms`;
$('f-view').textContent = `${v.cpuMs.toFixed(1)} ms`;
$('f-sim').textContent = `${simMs.toFixed(2)} ms`;
$('chip-fps').textContent = fps;
$('chip-ms').textContent = `FPS / ${v.frameMs.toFixed(1)} ms`;
const st = runner.state();
$('meta').textContent = st?.done
? '战斗已结束。如需继续测量,请点击「重新开始」。'
: `场上单位 ${v.units} 个,自适应负载 ${v.lod} 档,${v.board3d?.on ? '3D' : '2D'} 棋盘`;
};
setInterval(live, 500);
setInterval(() => {
if (measuring) return;
if ($('panel').classList.contains('is-collapsed')) drawStrip($('chip-strip'), { labels: false });
else drawStrip($('strip'));
}, 250);
const btn = $('measure');
btn.disabled = false;
btn.onclick = async () => {
if (measuring) return;
measuring = true;
btn.classList.add('is-running');
btn.setAttribute('aria-disabled', 'true');
$('freeze').hidden = false;
const t0 = Date.now();
const tick = setInterval(() => { $('measure-label').textContent = `测量中(剩余 ${Math.max(1, 10 - Math.floor((Date.now() - t0) / 1000))} 秒)`; }, 1000);
$('measure-label').textContent = '测量中(剩余 10 秒)';
const s = await perf.sample(10000);
clearInterval(tick);
measuring = false;
btn.classList.remove('is-running');
btn.removeAttribute('aria-disabled');
$('freeze').hidden = true;
$('measure-label').textContent = '重新测量(10 秒)';
const v = verdictOf(s);
const verdict = $('verdict');
verdict.style.setProperty('--v', `var(${v.color})`);
verdict.replaceChildren(document.createTextNode(v.label));
const note = document.createElement('small');
note.textContent = v.note;
verdict.append(note);
$('r-fps').textContent = s.fps;
$('r-p95').textContent = `${s.p95} ms`;
$('r-over').textContent = `${s.over33}%`;
const text = report(specName, index.find((x) => x.name === specName)?.title || specName, quality, s);
$('out').textContent = text;
$('result').hidden = false;
const copy = $('copy');
copy.textContent = '复制报告';
copy.onclick = () => {
const done = () => { copy.textContent = '已复制到剪贴板'; };
const fallback = () => {
const d = $('result').querySelector('details');
d.open = true;
const range = document.createRange();
range.selectNodeContents($('out'));
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
copy.textContent = '无法写入剪贴板,已选中报告文本,请手动复制';
};
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, fallback);
else fallback();
};
};
}
main().catch((err) => {
perf.error = String(err?.stack || err);
console.error(err);
const meta = document.getElementById('meta');
if (meta) meta.textContent = `加载失败:${err?.message || err}`;
});
+22
View File
@@ -0,0 +1,22 @@
// public/dev/frame-stats.js — the frame figures of the battle perf page (battle-perf.js), free of the DOM so the Node
// tests can import them (test/perfbench.test.js).
/** `v` rounded to `d` decimals; null for NaN / ±Infinity (a figure the report shows as missing, not as 0). */
export const round = (v, d = 1) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
/**
* Average fps, frame-time percentiles (ms) and the share (%) of frames over `stutterMs`, from a sample's frame times.
* Every figure is null without a frame time (perf.sample() always records one, but an empty run must not divide by 0).
* @param {number[]} deltas frame times in ms (sorted in place)
* @param {number} stutterMs a frame over this counts as a stutter frame
*/
export function frameFigures(deltas, stutterMs) {
if (!deltas.length) return { fps: null, p50: null, p95: null, p99: null, over33: null };
deltas.sort((a, b) => a - b);
const pick = (p) => deltas[Math.min(deltas.length - 1, Math.floor(deltas.length * p))];
const avg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
return {
fps: avg > 0 ? round(1000 / avg) : null, p50: round(pick(0.5)), p95: round(pick(0.95)), p99: round(pick(0.99)),
over33: round((deltas.filter((x) => x > stutterMs).length / deltas.length) * 100),
};
}
+62 -10
View File
@@ -23,6 +23,9 @@ import { PHASE, GEO } from '/shared/constants.js';
const params = new URLSearchParams(location.search);
const SHOT = params.get('shot') === '1';
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
// the match's stage (data/stages.json id): ?stage=act1autochess_m01 boards a stage whose own devices (crates / turrets)
// stand on the prep field — the default act2autochess_m01 keeps its blowers and turrets off it (rows 6 / 8 / 13)
const STAGE_ID = params.get('stage') || 'act2autochess_m01';
// ---- deterministic rng ----------------------------------------------------------------------------------
let seed = 20260927;
@@ -101,7 +104,7 @@ function refreshPrivate() {
const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p);
p.canReady = !p.temp.some(Boolean);
p.canReady = !p.personalChoice && !p.temp.some(Boolean);
const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
@@ -119,7 +122,7 @@ function pushPublic() {
function buildState() {
uidSeq = 100;
seed = 20260927;
const stage = data.lookup('stages', 'act2autochess_m01');
const stage = data.lookup('stages', STAGE_ID) || data.lookup('stages', 'act2autochess_m01');
const melee = stage.deployTiles.normal.melee.slice();
const ranged = stage.deployTiles.normal.rangedOnly.slice();
const bonds = data.list('bonds');
@@ -178,7 +181,7 @@ function buildState() {
].sort((a, b) => a.t - b.t);
const priv = {
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true,
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true, personalChoice: null,
shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null },
hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [
@@ -206,7 +209,7 @@ function buildState() {
const pub = {
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
stageId: STAGE_ID, factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
};
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
@@ -472,6 +475,13 @@ function startCombat(phase) {
const e = data.lookup('enemies', bossRec.enemyKey);
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
}
// ?variant=devices — the stage's crates inside the rect are device UNITS (kind 'device', defId = the device key), as in the
// real sim: absent from the field meta (a battle shown from its start), they come in with 'spawn' events once it steps;
// __MOCK__.breakDevice(i) destroys one (hp 0 for a second in the snapshots, then gone)
const deviceUnits = !VARIANTS.has('devices') ? [] : (S.stage.devices || [])
.filter((d) => d.role === 'crate' && (typeof d.active === 'boolean' ? d.active : !d.hidden)
&& d.pos[0] >= rect.r0 && d.pos[0] <= rect.r1 && d.pos[1] >= rect.c0 && d.pos[1] <= rect.c1)
.map((d) => ({ id: id++, kind: 'device', side: 'ally', ownerId: null, defId: d.key, name: d.name, x: d.pos[1], y: d.pos[0], facing: 1, maxHp: 100, hp: 100, deadAt: null }));
// every field — 联防 too — is fought on the round's battlefield (server unite.js; 0.2.0's escaped-level map withdrawn)
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
store.patch('match', { field });
@@ -489,6 +499,7 @@ function startCombat(phase) {
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
const ev = [];
for (const d of deviceUnits) if (!spawned.has(d.id)) { spawned.add(d.id); ev.push(['spawn', { ...d }]); }
for (const e of enemies) {
if (e.dead || t < e.spawnAt) continue;
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
@@ -508,6 +519,7 @@ function startCombat(phase) {
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
...deviceUnits.filter((d) => d.deadAt == null || t - d.deadAt < 1).map((d) => [d.id, d.x, d.y, Math.max(0, d.hp), d.maxHp, 0, 0, 0, 2]),
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
];
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
@@ -517,7 +529,13 @@ function startCombat(phase) {
net._emit('b.snap', snap);
};
const e2boss = (e) => !!e.boss;
S.battle = { timer: setInterval(tick, 50), fieldRef };
S.battle = { timer: setInterval(tick, 50), fieldRef, devices: deviceUnits, hurtDevice: (i = 0, dmg = 100) => {
const d = deviceUnits[i];
if (!d || d.deadAt != null) return false;
d.hp = Math.max(0, d.hp - dmg);
if (d.hp <= 0) { d.deadAt = (performance.now() - t0 - pausedFor) / 1000 + 4; net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: d.deadAt, ev: [['die', d.id]] }); }
return true;
} };
setTimeout(tick, 60);
}
@@ -575,7 +593,7 @@ async function mockRequest(t, f = {}) {
prepOnly();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
const n = p.shop.slots.filter((s) => s.kind !== 'item').length;
const n = p.shop.slots.filter((s) => !s || s.kind !== 'item').length;
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
refreshPrivate(); return {};
}
@@ -586,7 +604,8 @@ async function mockRequest(t, f = {}) {
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
const n = p.shop.level >= 4 ? 5 : 4;
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level));
// the upgrade opens the new slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot; a refresh fills it)
while (p.shop.slots.filter((s) => !s || s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, null);
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
refreshPrivate(); return {};
}
@@ -661,7 +680,16 @@ async function mockRequest(t, f = {}) {
}
pushPublic(); return {};
}
case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; }
case 'g.art': {
prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET');
if (it.piece.id === 'chess_item_6_03_m') {
if (p.personalChoice) fail('BAD_TARGET');
const cards = data.get('choices').cards.bounty.filter((c) => c.payout === 'perfect' && c.rounds < 90).slice(0, 3)
.map((c) => ({ ...c, kind: 'bounty', id: c.effectId, descRaw: data.lookup('effects', c.effectId)?.descRaw }));
p.personalChoice = { id: `mock.choice.${nextUid()}`, round: pub.round, sourceItemId: it.piece.id, cards };
}
removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {};
}
case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
@@ -669,6 +697,15 @@ async function mockRequest(t, f = {}) {
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
}
case 'g.choice': {
if (f.choiceId !== undefined) {
prepOnly();
const choice = p.personalChoice;
if (!choice || choice.id !== f.choiceId || choice.round !== pub.round || !choice.cards[f.idx]) fail('BAD_TARGET');
const card = choice.cards[f.idx];
p.effects.push({ id: choice.id, name: card.name, desc: card.descRaw || card.desc, iconKind: 'choice', iconId: card.id });
p.personalChoice = null;
refreshPrivate(); return {};
}
if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET');
@@ -676,6 +713,7 @@ async function mockRequest(t, f = {}) {
}
case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE');
if (f.ready && p.personalChoice) fail('BAD_TARGET');
if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {};
}
@@ -706,6 +744,15 @@ async function mockRequest(t, f = {}) {
return {};
}
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
// the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes
// another player the host)
case 'room.removeSpectator': {
const room = store.get().room;
if (room?.hostId !== ME) fail('NOT_HOST');
if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET');
store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } });
return {};
}
default: return {};
}
}
@@ -780,7 +827,11 @@ async function boot() {
me: { playerId: ME, name: '凯尔希', token: null },
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
clock: { offset: 0, rtt: 20, synced: true },
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
room: {
code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true,
seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })),
...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}),
},
});
await data.loadAll(GAME_FILES);
installDeviceSupport();
@@ -790,6 +841,7 @@ async function boot() {
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
renderBar();
store.subscribe(() => renderBar());
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic(),
hurtDevice: (i, dmg) => S?.battle?.hurtDevice?.(i, dmg) ?? false };
}
boot().catch((err) => console.error('[mock] boot failed', err));
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","spec":{"v":1,"battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","seed":3380376830,"modeId":"mode_multi_hard","round":14,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":98},"swiftShip":{"count":3,"active":true,"tier":1,"layers":82},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":26},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":10,"gainedChess":0,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":11,"col":4,"dir":"DOWN","items":[],"skillIndex":2},{"uid":73,"kind":"chess","chessId":"chess_char_1_01_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_inside"},{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":10,"col":7,"dir":"RIGHT","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_a"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tier":0Line truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","spec":{"v":1,"battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","seed":725852008,"modeId":"mode_multi_hard","round":15,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_3_03_e_a"],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_05_e_a","chess_item_4_02_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":108},"swiftShip":{"count":3,"active":true,"tier":1,"layers":92},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":32},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":11,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":11,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":274,"kind":"chess","chessId":"chess_char_2_17_b","row":10,"col":7,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_branch"},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_b"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tLine truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","spec":{"v":1,"battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","seed":720961930,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":58},"preciShip":{"count":3,"active":true,"tier":2,"layers":80},"swiftShip":{"count":3,"active":true,"tier":1,"layers":64},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"normal","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":0,"count":3,"interval":3,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":0,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1166_dusbr_2","routeIndex":1,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":1,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":39,"enemyKey":"enemy_1166_dusbr_2","routeIndex":2,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":2,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":62,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":3,"interval":4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":3,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":4,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":37,"enemyKey":"enemy_1169_duphlx_2","routeIndex":5,"count":5,"interval":7.2,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[9,10]Line truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.13.62.u","fieldId":"u","kind":"unite","spec":{"v":1,"battleId":"7.13.62.u","fieldId":"u","kind":"unite","seed":3495193848,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":8,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"carryState":{"hpPct":0.3342892236024844,"sp":0}},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo","carryState":{"down":true}},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst","carryState":{"hpPct":1,"sp":8}},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn","carryState":{"down":true}},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd","carryState":{"down":true}},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma","carryState":{"hpPct":1,"sp":0}},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2,"carryState":{"hpPct":0.7218991466789668,"sp":0}},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2,"carryState":{"down":true}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":88},"swiftShip":{"count":3,"active":true,"tier":1,"layers":72},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"ai_3","seat":3,"side":"L","colOffset":0,"units":[{"uid":80,"kind":"chess","chessId":"chess_char_3_11_a","row":12,"col":4,"dir":"RIGHT","items":["chess_item_1_02_e_b","chess_item_6_04_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":13.13}},{"uid":143,"kind":"chess","chessId":"chess_char_4_22_a","row":12,"col":5,"dir":"DOWN","items":["chess_item_3_02_e_a","chess_item_6_09_e_a"],"skillIndex":2,"carryState":{"hpPct":1,"sp":0.17}},{"uid":158,"kind":"chess","chessId":"chess_char_5_11_a","row":12,"col":6,"dir":"RIGHT","items":["chess_item_4_06_e_a","chess_item_4_04_e_b"],"skillIndex":1,"carryState":{"hpPct":0.9422580123247037,"sp":4.57}},{"uid":112,"kind":"chess","chessId":"chess_char_4_13_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_10_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":1.85}},{"uid":59,"kind":"chess","chessId":"chess_char_3_20_a","row":11,"col":5,"dir":"RIGHT","items":["chess_item_6_06_e_a","chess_item_6_07_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":0}},{"uid":146,"kind":"chess","chessId":"chess_char_4_21_a","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"carryState":{"down":true}},{"uid":81,"kind":"chess","chessId":"chess_char_3_14_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_6_01_e_a","chess_item_3_11_e_b"],"skillIndex":1,"carrySLine truncated
+38
View File
@@ -0,0 +1,38 @@
[
{
"name": "hard-normal-r13",
"title": "普通战 · 第 13 回合",
"kind": "normal",
"round": 13,
"stageId": "act2autochess_m01",
"players": 1,
"units": 8
},
{
"name": "hard-unite-r13",
"title": "联防 · 第 13 回合",
"kind": "unite",
"round": 13,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
},
{
"name": "hard-boss-r14",
"title": "最终攻势 · 第 14 回合",
"kind": "boss",
"round": 14,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
},
{
"name": "hard-hidden-r15",
"title": "隐秘核心 · 第 15 回合",
"kind": "hidden",
"round": 15,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
}
]
+159 -2
View File
@@ -104,6 +104,9 @@
"复制密钥": "Copy Key",
"复制链接": "Copy Link",
"由创建者选择": "Chosen by the Host",
"由创建者设置": "Set by the Host",
"AI 队友最后选择": "AI Teammates Pick Last",
"策略与机变轮选时,所有博士先于 AI 队友选择": "In the Strategy and Improv drafts, every Doctor picks before the AI teammates",
"连接中断,请稍候重试": "Connection lost, please try again shortly",
"离开同盟": "Leave Alliance",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?",
@@ -148,6 +151,7 @@
"完成": "Done",
"背景音乐": "Music",
"干员语音": "Operator Voices",
"语音语言": "Voice Language",
"音效": "Sound Effects",
"静音": "Mute",
"显示伤害数字": "Damage Numbers",
@@ -235,7 +239,7 @@
"继续作战": "Resume Battle",
"正在载入模拟数据…": "Loading simulation data...",
"正在进入模拟…": "Entering simulation...",
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "The renderer could not load. Switched to the simplified view (all features remain available; reload to retry).",
"已配发的装备无法销毁": "Equipped Equipment cannot be destroyed",
"销毁道具": "Destroy Item",
"道具": "Item",
@@ -382,6 +386,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
"选择一项": "Choose one",
"倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically",
"教鞭选择": "Pointing Stick Choice",
"教鞭 · 战术特训": "Pointing Stick · Tactical Training",
"请选择一项战术特训": "Choose a Tactical Training bounty",
"请先完成教鞭选择": "Complete the Pointing Stick choice first",
"休整期结束时未选择将自动选定": "An unselected bounty will be picked automatically when the Rest Phase ends",
"从候选中选择一项战术特训": "Choose one Tactical Training bounty from the offered cards",
"选择一项(无时间限制)": "Choose one (no time limit)",
" · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
"已完成选择": "Choice made",
@@ -681,6 +691,7 @@
" · 未装备模组": " · No Module equipped",
"已购买": "Purchased",
"已招募": "Recruited",
"空栏位:刷新或下回合开始时补满": "Empty slot: filled by the next refresh or at the start of the next round",
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
"已满级": "Max Level",
@@ -777,6 +788,7 @@
"观战席": "Spectators",
"移出观战者 {name}": "Remove spectator {name}",
"移出该观战者": "Remove this spectator",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.",
"入座": "Take a Seat",
"移出同盟": "Remove from Alliance",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
@@ -808,6 +820,15 @@
"已恢复默认快捷键": "Default hotkeys restored",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "Hotkeys need a physical keyboard; connect one to use them on a touch device",
"查看 / 更改快捷键": "Show / change hotkeys",
"问题反馈": "Report a problem",
"复制诊断信息": "Copy diagnostics",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "If something goes wrong, copy the diagnostics and paste them into a GitHub issue so the developers can see what happened. They are generated on this device and never uploaded; player names and the room code are replaced.",
"已记录的错误": "Errors recorded",
"附上本场战斗": "Attach this battle",
"开发者可以用附上的战斗数据重现这场战斗。": "Developers can replay this battle from the attached data.",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "Diagnostics copied. Paste them into a GitHub issue.",
"无法写入剪贴板,请手动复制下面的内容": "Couldn't write to the clipboard. Copy the text below by hand.",
"诊断信息": "Diagnostics",
"观战中无法进行该操作": "Spectators cannot do that",
"标准": "Standard",
"险境": "Perilous",
@@ -815,6 +836,8 @@
"终极": "Ultimate",
"rounds::回合": "Rounds",
"{who}:获得{name}": "{who}: obtained {name}",
"{who}:卡池中已没有{name}": "{who}: no {name} left in the shared pool",
"{who}:没有可获得的同盟约干员": "{who}: no Operator of the same Alliance can be obtained",
"获得{name}": "Obtained {name}",
"联防阶段:{names} 迎战突破防线的敌人": "Unite Phase: {names} take on the enemies that broke through",
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
@@ -903,6 +926,22 @@
"地面单位可通过": "Ground units can pass",
"地形机制": "Terrain Mechanics",
"这一格": "This Tile",
"场地装置": "Stage Device",
"装置机制": "Device Mechanics",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "An obstacle on the ground: ground enemies walk around it, and only run into it when there is no other way through, destroying it before moving on",
"生命 {hp} 点,被摧毁后从场上消失": "{hp} HP; it disappears from the battlefield once destroyed",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "Automatically attacks one enemy in range for Arts damage; the enemy hit is inflicted with Fragile",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "Scales with the Alliance that currently has the most stacks: per stack, ASPD +{aspd} (up to +{aspdMax}) and the Fragile it inflicts +{frag}% (up to +{fragMax}%)",
"脆弱持续 {sec} 秒": "The Fragile lasts {sec} {sec|second|seconds}",
"高台位装置:地面敌人不能走上这一格": "A high-ground device: ground enemies cannot walk onto this tile",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "Ranged-position Operators can be deployed on it; an Operator standing on it is on high ground and does not block enemies",
"向前方 {n} 格吹出气流(这一台朝{dir})": "Blows an airflow {n} {n|tile|tiles} ahead (this one faces {dir})",
"朝向与风向相同的攻击力 {v}": "ATK {v} when facing the same way as the airflow",
"相反的 {v}": "{v} when facing the opposite way",
"部署在气流里的干员:{mods}": "Operators deployed in the airflow: {mods}",
"顺风移动速度 ×{v}": "Movement Speed ×{v} moving with the airflow",
"逆风 ×{v}": "×{v} moving against it",
"在气流里移动的敌人:{mods}": "Enemies moving in the airflow: {mods}",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
@@ -1055,5 +1094,123 @@
"选择「{name}」的朝向": "Choose {name}'s facing",
"选择朝向": "Choose a facing",
"朝向:{dir}": "Facing: {dir}",
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers"
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
"统计数据": "Statistics",
"统计": "Stats",
"总览": "Overview",
"总局数": "Games",
"胜场": "Wins",
"胜率": "Win rate",
"到达隐秘核心": "Hidden Core reached",
"通关隐秘核心": "Hidden Core cleared",
"最远回合": "Best round",
"累计时长": "Time played",
"{n} 分": "{n} min",
"难度": "Difficulty",
"局数": "Games",
"隐秘核心通关": "Hidden Core clears",
"使用局数": "Used",
"通过局数": "Cleared",
"通过率": "Clear rate",
"称号": "Titles",
"战斗累计": "Combat totals",
"时间": "Time",
"模式": "Mode",
"结果": "Result",
"点击查看该局结算": "View this match's settlement",
"单人": "Solo",
"同盟": "Co-op",
"胜": "Win",
"负": "Loss",
"已导出 {n} 条对局记录": "Exported {n} match {n|record|records}",
"已导入 {added} 条记录{tail}": "Imported {added} {added|record|records}{tail}",
"(跳过重复 {skipped} 条)": " ({skipped} {skipped|duplicate|duplicates} skipped)",
"没有新记录(全部与现有数据重复)": "Nothing new (everything already exists)",
"导入失败:不是有效的统计导出文件": "Import failed: not a valid stats export file",
"已清空本机统计数据": "Local stats cleared",
"本机数据 · 仅记录本浏览器中你参与的对局": "Local data · only matches you took part in, in this browser",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "Stats data from a newer version detected — upgrade the client before viewing, or the data could be overwritten.",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "No records yet — finish a match and the page starts filling in.",
"未知": "Unknown",
"对局记录": "Match history",
"{n} 小时": "{n} h",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "Counted: matches played to the settlement, and matches given up after clearing at least 1 round. Matches given up earlier stay in the match history only.",
"另有 {n} 局未计入统计": "{n} more {n|match|matches} not counted",
"没有清完第一个回合就退出,不计入统计": "Left before clearing the first round — not counted",
"中途退出的对局没有结算可看": "A match given up has no settlement to view",
"较早的对局只保留汇总,不能回看结算": "Older matches keep only their summary — no settlement to view",
"中途退出": "Left early",
"不计入统计": "Not counted",
"显示更多(还有 {n} 局)": "Show more ({n} left)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "Click a row to re-view that match's settlement; a match given up only leaves its entry.",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "Only the latest {max} matches are kept, and only the newest {full} can show their settlement again.",
"返回统计": "Back to Stats",
"文件过大,无法导入": "File too large to import",
"无法写入本机存储,可能已满": "Could not write to local storage — it may be full",
"({n} 条无法读取)": " ({n} unreadable)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "Import failed: this file was exported by a newer client — upgrade the client first",
"清空统计数据": "Clear Statistics",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "This deletes the {n} match {n|record|records} stored on this device. It cannot be undone.",
"导出为文件(可在别的设备上导入)": "Export to a file (import it on another device)",
"从导出的文件导入(与现有记录合并)": "Import from an exported file (merged with the current records)",
"清空本机的统计数据": "Clear the statistics stored on this device",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "Local data: kept only in this browser, never uploaded to the server. Teammates' names from Team Simulations are stored here too (and appear in an export file).",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "The stats stored on this device could not be read. They were set aside as a backup and recording starts afresh.",
"有 {n} 条记录已损坏,已忽略。": "{n} damaged {n|record was|records were} ignored.",
"攻回": "Offensive",
"受回": "Defensive",
"自回": "Auto",
"常驻": "Always",
"瞬发": "Instant",
"部署触发": "On deployment",
"未精英化": "Non-Elite",
"精英阶段1": "Elite 1",
"精英阶段2": "Elite 2",
"精英阶段2-60级": "Elite 2 Lv.60",
"潜能": "Potential",
"潜能 {n}": "Potential {n}",
"练度": "Development",
"潜能与练度": "Potential & development",
"满潜能 · 精英2 Lv.60": "Max potential · Elite 2 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "Marked as not owned on Operator Roster: the stand-in has no potential or development; these settings apply once it is owned again",
"原型干员没有潜能与练度": "A prototype Operator has no potential or development",
"模组仅在精锐形态生效": "Modules apply only to the Elite form",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "Not owned (Operator Roster): its stand-in fights, with no potential or development",
"含潜能与练度;": "Potential and development included; ",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "Reset the skills, modules, potential and development of {nChanged} Operators to their defaults?",
"已导入 {n} 名干员的潜能与练度": "Imported the potential and development of {n} Operators",
"精英1": "E1",
"精英2": "E2",
"精英2 Lv.60": "E2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry, and their potential and development; Operator levels in the match cannot be changed",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "Default: max Potential, Elite 2 Lv.60 (the full bonus). Officially, Potential is your own and development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier); a licensed Operator you do not own fights at Potential 1 with no bonus — to play it that way, set Potential 1 and Non-Elite here.",
"文字大小": "Text Size",
"小": "Small",
"大": "Large",
"特大": "Extra Large",
"调整界面文字大小,棋盘保持原比例。": "Adjust interface text size while keeping the board at its original scale.",
"此干员语音": "Operator voice",
"跟随全局": "Use global setting",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "Saved in this browser only. Missing Japanese lines use Chinese.",
"添加到桌面": "Add to Home Screen",
"恢复本机对局": "Resume local match",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "This match is still open in another window, or this browser cannot safely resume it. Close the original window and retry.",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "Select a match from a closed window. Matches cannot be resumed after a server restart or expiry.",
"{name} · 同盟 {code}": "{name} · Alliance {code}",
"重刷本局": "Reroll setup",
"重刷本局投票:{agreed}/{total}": "Reroll vote: {agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "With unanimous consent, reroll the field, bosses, training enemies and bans. The confirmation timer is paused.",
"保留房间和个人配置,重新随机全部开局信息。": "Keep the room and player settings, and reroll the full opening setup.",
"已同意": "Agreed",
"待同意": "Waiting",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "All human players must agree. Random results may repeat.",
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
"同意重刷": "Agree to reroll",
"拒绝重刷": "Reject reroll",
"取消投票": "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"
}
+167 -10
View File
@@ -110,6 +110,9 @@
"复制密钥": "コードをコピー",
"复制链接": "リンクをコピー",
"由创建者选择": "ホストが選択",
"由创建者设置": "ホストが設定",
"AI 队友最后选择": "AI チームメイトは最後に選択",
"策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます",
"连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください",
"离开同盟": "同盟から退出",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?",
@@ -154,6 +157,7 @@
"完成": "完了",
"背景音乐": "BGM",
"干员语音": "オペレーターボイス",
"语音语言": "ボイス言語",
"音效": "効果音",
"静音": "ミュート",
"显示伤害数字": "ダメージ数値を表示",
@@ -196,7 +200,7 @@
"该队友当前没有战场": "この同盟者には現在戦場がありません",
"队友与你在同一战场,使用 ‹ › 切换视角": "同盟者はあなたと同じ戦場にいます。‹ › で視点を切り替えます",
"无法查看另一组队友的战场": "もう一組の同盟者の戦場は確認できません",
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。戦闘終了後に確認できます",
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。作戦終了後に確認できます",
"无人在家": "不在",
"你自己": "自分",
"全景": "全体",
@@ -241,7 +245,7 @@
"继续作战": "戦闘を再開",
"正在载入模拟数据…": "シミュレーションデータを読み込み中…",
"正在进入模拟…": "シミュレーションに進入中…",
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "この端末では3D / WebGLで描画できないため、簡易表示に切り替えました(機能に影響はありません)",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "描画エンジンを読み込めなかったため簡易表示に切り替えました(機能はそのまま利用できます。再読み込みで再試行)。",
"已配发的装备无法销毁": "装備済みの装備品は破棄できません",
"销毁道具": "アイテムを破棄",
"道具": "アイテム",
@@ -358,7 +362,7 @@
"{owner} 的盟约": "{owner}の盟約",
"我的盟约": "自分の盟約",
"(含调和 +{harmony})": "(調和 +{harmony} を含む)",
"层数叠加已禁用": "加算数は増加しません",
"层数叠加已禁用": "盟約加算無効化中",
"调和": "調和",
"层数": "加算数",
"盟约效果": "盟約効果",
@@ -385,6 +389,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階",
"选择一项": "1つ選択",
"倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます",
"教鞭选择": "教鞭の選択",
"教鞭 · 战术特训": "教鞭 · 戦術訓練",
"请选择一项战术特训": "戦術訓練を1つ選択してください",
"请先完成教鞭选择": "先に教鞭の選択を完了してください",
"休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます",
"从候选中选择一项战术特训": "候補から戦術訓練を1つ選択",
"选择一项(无时间限制)": "1つ選択(時間制限なし)",
" · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定",
"已完成选择": "選択完了",
@@ -398,8 +408,8 @@
"再次点击": "もう一度タップ",
"选择中": "選択中",
"队友作战进度": "同盟者の戦闘状況",
"作战结束": "戦闘終了",
"作战结束,等待队友完成作战": "戦闘終了。同盟者の戦闘終了を待っています",
"作战结束": "作戦終了",
"作战结束,等待队友完成作战": "作戦終了。同盟者の作戦終了を待っています",
"你已被淘汰,正在观战": "あなたは脱落しました。観戦中",
"上一个战场": "前の戦場",
"下一个战场": "次の戦場",
@@ -432,7 +442,7 @@
"辅助": "補助",
"特种": "特殊",
"召唤物": "召喚物",
"自动回复": "自動回復",
"自动回复": "自然回復",
"攻击回复": "攻撃回復",
"受击回复": "被撃回復",
"被动": "パッシブ",
@@ -508,7 +518,7 @@
"团队增益": "連合バフ",
"生效中的效果": "発動中の効果",
"表情": "スタンプ",
"交流": "通信",
"交流": "交流",
"上一组表情": "前のスタンプ",
"下一组表情": "次のスタンプ",
"表情主题": "スタンプのテーマ",
@@ -563,7 +573,7 @@
"被源石侵蚀的假想敌": "源石に侵食された仮想敵",
"回合开始": "ラウンド開始",
"回合结算": "ラウンド集計",
"休息一下": "ひと休み",
"休息一下": "休憩タイム",
"找不到该单位": "ユニットが見つかりません",
"无法放置在该位置": "この位置には置けません",
"位置未变化": "位置が変わっていません",
@@ -682,6 +692,7 @@
" · 未装备模组": " · モジュール未装備",
"已购买": "購入済み",
"已招募": "獲得済み",
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
"已满级": "最大レベル",
@@ -778,6 +789,7 @@
"观战席": "観戦席",
"移出观战者 {name}": "観戦者{name}を除外",
"移出该观战者": "この観戦者を除外",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
"入座": "着席",
"移出同盟": "同盟から除外",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
@@ -808,6 +820,15 @@
"已恢复默认快捷键": "ショートカットキーをデフォルトに戻しました",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "ショートカットキーには物理キーボードが必要です。タッチ端末でもキーボードを接続すれば使えます",
"查看 / 更改快捷键": "ショートカットキーの確認 / 変更",
"问题反馈": "問題の報告",
"复制诊断信息": "診断情報をコピー",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "問題が起きたときは、診断情報をコピーして GitHub の issue に貼り付けてください。開発者がエラー発生時の状況を確認できます。診断情報はこの端末内で作成され、自動でアップロードされることはありません。プレイヤー名とルーム番号は置き換えられます。",
"已记录的错误": "記録されたエラー",
"附上本场战斗": "この戦闘を添付",
"开发者可以用附上的战斗数据重现这场战斗。": "添付した戦闘データから、開発者がこの戦闘を再現できます。",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "診断情報をコピーしました。GitHub の issue に貼り付けてください",
"无法写入剪贴板,请手动复制下面的内容": "クリップボードに書き込めませんでした。下の内容を手動でコピーしてください",
"诊断信息": "診断情報",
"观战中无法进行该操作": "観戦中はこの操作はできません",
"标准": "標準",
"险境": "逆境",
@@ -815,6 +836,8 @@
"终极": "究極",
"rounds::回合": "ラウンド",
"{who}:获得{name}": "{who}:{name}を獲得",
"{who}:卡池中已没有{name}": "{who}:共有プールに{name}が残っていません",
"{who}:没有可获得的同盟约干员": "{who}:入手できる同じ盟約のオペレーターがいません",
"获得{name}": "{name}を獲得",
"联防阶段:{names} 迎战突破防线的敌人": "防衛支援段階:{names}が防衛線を突破した敵を迎え撃つ",
"地形变化:{names}无法停留在原位置,已撤回整备区": "地形変化:{names}は元の位置に留まれないため、整備エリアに戻りました",
@@ -903,6 +926,22 @@
"地面单位可通过": "地上ユニットも通過可能",
"地形机制": "地形の仕組み",
"这一格": "このマス",
"场地装置": "ステージ装置",
"装置机制": "装置の仕組み",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "地面に置かれた障害物:地上の敵は迂回して進み、他に道がないときだけぶつかって破壊し、そのまま進む",
"生命 {hp} 点,被摧毁后从场上消失": "HP {hp}。破壊されると戦場から消える",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "射程内の敵1体を自動で攻撃して術ダメージを与え、命中した敵に脆弱を付与する",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "加算数が最も多い盟約に応じて強化:加算数1ごとに攻撃速度+{aspd}(最大+{aspdMax})、付与する脆弱+{frag}%(最大+{fragMax}%)",
"脆弱持续 {sec} 秒": "脆弱は{sec}秒持続する",
"高台位装置:地面敌人不能走上这一格": "高台の装置:地上の敵はこのマスに入れない",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠距離位置のオペレーターを配置できる。上に立つオペレーターは高所にいるため、敵をブロックしない",
"向前方 {n} 格吹出气流(这一台朝{dir})": "前方{n}マスに気流を吹き出す(この装置は{dir}向き)",
"朝向与风向相同的攻击力 {v}": "気流と同じ向きの攻撃力{v}",
"相反的 {v}": "逆向きは{v}",
"部署在气流里的干员:{mods}": "気流内に配置されたオペレーター:{mods}",
"顺风移动速度 ×{v}": "気流と同じ向きに移動すると移動速度×{v}",
"逆风 ×{v}": "逆向きは×{v}",
"在气流里移动的敌人:{mods}": "気流内を移動する敵:{mods}",
"全部清空": "すべてクリア",
"清空全部 {n} 个自选名额?": "自由編成枠{n}つをすべてクリアしますか?",
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
@@ -1059,7 +1098,125 @@
"导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络": "インポート失敗:ゲームデータが読み込まれていないため、何も変更していません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください",
"游戏数据没有载入": "ゲームデータが読み込まれていません",
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
"全员无伤!": "全員無傷!",
"全员无伤!": "完璧な作戦!",
"生命值减少 −{n}": "耐久値減少 −{n}",
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。"
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
"统计数据": "統計データ",
"统计": "統計",
"总览": "概要",
"总局数": "対戦数",
"胜场": "勝利数",
"胜率": "勝率",
"到达隐秘核心": "シークレットコア到達",
"通关隐秘核心": "シークレットコアクリア",
"最远回合": "最高ラウンド",
"累计时长": "累計プレイ時間",
"{n} 分": "{n}分",
"难度": "難易度",
"局数": "対戦数",
"隐秘核心通关": "シークレットコアクリア",
"使用局数": "使用回数",
"通过局数": "クリア回数",
"通过率": "クリア率",
"称号": "称号",
"战斗累计": "戦闘累計",
"时间": "日時",
"模式": "モード",
"结果": "結果",
"点击查看该局结算": "この対戦の結果を見る",
"单人": "単独",
"同盟": "連合",
"胜": "勝",
"负": "敗",
"已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました",
"已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}",
"(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)",
"没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)",
"导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません",
"已清空本机统计数据": "この端末の統計データを消去しました",
"本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。",
"未知": "不明",
"对局记录": "対戦履歴",
"{n} 小时": "{n}時間",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "集計対象:結果画面まで進んだ対戦と、1ラウンド以上クリアしてから途中退出した対戦。それより早く退出した対戦は対戦履歴にのみ残ります。",
"另有 {n} 局未计入统计": "他に{n}件の対戦が集計対象外です",
"没有清完第一个回合就退出,不计入统计": "最初のラウンドをクリアする前に退出したため、集計対象外です",
"中途退出的对局没有结算可看": "途中退出した対戦には表示できる結果がありません",
"较早的对局只保留汇总,不能回看结算": "古い対戦は要約のみ保存されているため、結果は表示できません",
"中途退出": "途中退出",
"不计入统计": "集計対象外",
"显示更多(还有 {n} 局)": "さらに表示(残り{n}件)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "行をクリックすると、その対戦の結果をもう一度見られます。途中退出した対戦は記録のみ残ります。",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "保存されるのは直近{max}件のみで、結果を再表示できるのは最新の{full}件だけです。",
"返回统计": "統計に戻る",
"文件过大,无法导入": "ファイルが大きすぎるためインポートできません",
"无法写入本机存储,可能已满": "ローカルストレージに書き込めません。容量がいっぱいの可能性があります",
"({n} 条无法读取)": "(読み取れない記録{n}件)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "インポートに失敗しました:このファイルはより新しいバージョンのクライアントでエクスポートされています。先にクライアントを更新してください",
"清空统计数据": "統計データを消去",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "この端末に保存されている対戦記録{n}件を削除します。元に戻せません。",
"导出为文件(可在别的设备上导入)": "ファイルにエクスポート(別の端末でインポートできます)",
"从导出的文件导入(与现有记录合并)": "エクスポートしたファイルからインポート(既存の記録と統合)",
"清空本机的统计数据": "この端末の統計データを消去",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。",
"有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。",
"攻回": "攻撃",
"受回": "被撃",
"自回": "自動",
"常驻": "常時",
"瞬发": "即時",
"部署触发": "配置時",
"未精英化": "未昇進",
"精英阶段1": "昇進段階1",
"精英阶段2": "昇進段階2",
"精英阶段2-60级": "昇進段階2-Lv60",
"潜能": "潜在",
"潜能 {n}": "潜在{n}",
"练度": "育成度",
"潜能与练度": "潜在と育成度",
"满潜能 · 精英2 Lv.60": "潜在最大・昇進2 Lv60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "オペレーター所持で未所持に設定中:代役オペレーターには潜在も育成度もありません。ここでの設定は「所持」に戻すと有効になります",
"原型干员没有潜能与练度": "モデルオペレーターには潜在も育成度もありません",
"模组仅在精锐形态生效": "モジュールは精鋭のみ有効です",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未所持(オペレーター所持):代役オペレーターが出撃し、潜在も育成度もありません",
"含潜能与练度;": "潜在と育成度を含む。",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "{nChanged}名のオペレーターのスキル・モジュール・潜在・育成度を初期設定に戻しますか?",
"已导入 {n} 名干员的潜能与练度": "{n}名のオペレーターの潜在と育成度をインポートしました",
"精英1": "昇進1",
"精英2": "昇進2",
"精英2 Lv.60": "昇進2 Lv60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。",
"文字大小": "文字サイズ",
"小": "小",
"大": "大",
"特大": "特大",
"调整界面文字大小,棋盘保持原比例。": "画面の文字サイズを変更します。盤面の縮尺は変わりません。",
"此干员语音": "このオペレーターの音声",
"跟随全局": "全体設定に従う",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "このブラウザーのみに保存されます。日本語音声がない場合は中国語に切り替わります。",
"添加到桌面": "ホーム画面に追加",
"恢复本机对局": "この端末の対戦を復帰",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "別のウィンドウで対戦中か、このブラウザーでは安全に復帰できません。元のウィンドウを閉じて再試行してください。",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "閉じたウィンドウの対戦を選択してください。サーバー再起動後や期限切れの場合は復帰できません。",
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
"重刷本局": "開始条件を再抽選",
"重刷本局投票:{agreed}/{total}": "再抽選の投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員の同意で戦場、ボス、特訓の敵と禁止対象を再抽選します。確認の制限時間は一時停止中です。",
"保留房间和个人配置,重新随机全部开局信息。": "ルームと個人設定を維持して、開始条件をすべて再抽選します。",
"已同意": "同意済み",
"待同意": "同意待ち",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "人間プレイヤー全員の同意が必要です。同じ結果になる場合があります。",
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
"同意重刷": "再抽選に同意",
"拒绝重刷": "再抽選を拒否",
"取消投票": "投票を取り消す",
"只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
"库存不足": "在庫不足"
}
+449 -292
View File
File diff suppressed because it is too large. Load diff
+159 -2
View File
@@ -108,6 +108,9 @@
"复制密钥": "複製金鑰",
"复制链接": "複製連結",
"由创建者选择": "由建立者選擇",
"由创建者设置": "由建立者設定",
"AI 队友最后选择": "AI 隊友最後選擇",
"策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇",
"连接中断,请稍候重试": "連線中斷,請稍候重試",
"离开同盟": "離開同盟",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?",
@@ -152,6 +155,7 @@
"完成": "完成",
"背景音乐": "背景音樂",
"干员语音": "幹員語音",
"语音语言": "語音語言",
"音效": "音效",
"静音": "靜音",
"显示伤害数字": "顯示傷害數字",
@@ -239,7 +243,7 @@
"继续作战": "繼續作戰",
"正在载入模拟数据…": "正在載入模擬資料…",
"正在进入模拟…": "正在進入模擬…",
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "目前的裝置無法啟用 3D / WebGL 渲染,已切換為簡化檢視(功能不受影響)",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "繪圖引擎暫時未能載入,已切換為簡化檢視(功能不受影響,可重新整理再試)",
"已配发的装备无法销毁": "已配發的裝備無法銷毀",
"销毁道具": "銷毀道具",
"道具": "道具",
@@ -383,6 +387,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段",
"选择一项": "選擇一項",
"倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配",
"教鞭选择": "教鞭選擇",
"教鞭 · 战术特训": "教鞭 · 戰術特訓",
"请选择一项战术特训": "請選擇一項戰術特訓",
"请先完成教鞭选择": "請先完成教鞭選擇",
"休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定",
"从候选中选择一项战术特训": "從候選中選擇一項戰術特訓",
"选择一项(无时间限制)": "選擇一項(無時間限制)",
" · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認",
"已完成选择": "已完成選擇",
@@ -680,6 +690,7 @@
" · 未装备模组": " · 未裝備模組",
"已购买": "已購買",
"已招募": "已招募",
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
"已满级": "已滿級",
@@ -776,6 +787,7 @@
"观战席": "觀戰席",
"移出观战者 {name}": "移出觀戰者 {name}",
"移出该观战者": "移出該觀戰者",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
"入座": "入座",
"移出同盟": "移出同盟",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
@@ -807,6 +819,15 @@
"已恢复默认快捷键": "已恢復預設快捷鍵",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "快捷鍵需要實體鍵盤;觸控裝置連接鍵盤後可用",
"查看 / 更改快捷键": "查看 / 更改快捷鍵",
"问题反馈": "問題回報",
"复制诊断信息": "複製診斷資訊",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "遇到問題時,複製診斷資訊並貼到 GitHub issue 中,開發者就能看到出錯時的情況。診斷資訊只在本機產生,不會自動上傳;玩家名稱和房間號碼會被替換。",
"已记录的错误": "已記錄的錯誤",
"附上本场战斗": "附上本場戰鬥",
"开发者可以用附上的战斗数据重现这场战斗。": "開發者可以用附上的戰鬥資料重現這場戰鬥。",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "已複製診斷資訊,可以貼到 GitHub issue 中",
"无法写入剪贴板,请手动复制下面的内容": "無法寫入剪貼簿,請手動複製下面的內容",
"诊断信息": "診斷資訊",
"观战中无法进行该操作": "觀戰中無法進行該操作",
"标准": "標準",
"险境": "險境",
@@ -814,6 +835,8 @@
"终极": "終極",
"rounds::回合": "回合",
"{who}:获得{name}": "{who}:獲得{name}",
"{who}:卡池中已没有{name}": "{who}:卡池中已沒有{name}",
"{who}:没有可获得的同盟约干员": "{who}:沒有可獲得的同盟約幹員",
"获得{name}": "獲得{name}",
"联防阶段:{names} 迎战突破防线的敌人": "聯防階段:{names} 迎戰突破防線的敵人",
"地形变化:{names}无法停留在原位置,已撤回整备区": "地形變化:{names}無法停留在原位置,已撤回整備區",
@@ -902,6 +925,22 @@
"地面单位可通过": "地面單位可通過",
"地形机制": "地形機制",
"这一格": "這一格",
"场地装置": "場地裝置",
"装置机制": "裝置機制",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "擋在地面上的工事:地面敵人會繞開它走;只有無路可走時才會撞上來,把它摧毀後繼續前進",
"生命 {hp} 点,被摧毁后从场上消失": "生命 {hp} 點,被摧毀後從場上消失",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "自動攻擊射程內的一名敵人,造成法術傷害,命中的敵人附加脆弱",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "按目前層數最高的盟約計算:每 1 層,攻擊速度 +{aspd}(最多 +{aspdMax}),附加的脆弱 +{frag}%(最多 +{fragMax}%)",
"脆弱持续 {sec} 秒": "脆弱持續 {sec} 秒",
"高台位装置:地面敌人不能走上这一格": "高台位裝置:地面敵人不能走上這一格",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠程位幹員可以部署在其上;站上去的幹員在高位,不阻擋敵人",
"向前方 {n} 格吹出气流(这一台朝{dir})": "向前方 {n} 格吹出氣流(這一台朝{dir})",
"朝向与风向相同的攻击力 {v}": "朝向與風向相同的攻擊力 {v}",
"相反的 {v}": "相反的 {v}",
"部署在气流里的干员:{mods}": "部署在氣流裡的幹員:{mods}",
"顺风移动速度 ×{v}": "順風移動速度 ×{v}",
"逆风 ×{v}": "逆風 ×{v}",
"在气流里移动的敌人:{mods}": "在氣流裡移動的敵人:{mods}",
"全部清空": "全部清空",
"清空全部 {n} 个自选名额?": "清空全部 {n} 個自選名額?",
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
@@ -1059,5 +1098,123 @@
"游戏数据没有载入": "遊戲資料沒有載入",
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。",
"全员无伤!": "全員無傷!",
"生命值减少 −{n}": "生命值減少 −{n}"
"生命值减少 −{n}": "生命值減少 −{n}",
"统计数据": "統計資料",
"统计": "統計",
"总览": "總覽",
"总局数": "總局數",
"胜场": "勝場",
"胜率": "勝率",
"到达隐秘核心": "到達隱秘核心",
"通关隐秘核心": "通關隱秘核心",
"最远回合": "最遠回合",
"累计时长": "累計時長",
"{n} 分": "{n} 分",
"难度": "難度",
"局数": "局數",
"隐秘核心通关": "隱秘核心通關",
"使用局数": "使用局數",
"通过局数": "通過局數",
"通过率": "通過率",
"称号": "稱號",
"战斗累计": "戰鬥累計",
"时间": "時間",
"模式": "模式",
"结果": "結果",
"点击查看该局结算": "點擊查看該局結算",
"单人": "單人",
"同盟": "同盟",
"胜": "勝",
"负": "負",
"已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄",
"已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}",
"(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)",
"没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)",
"导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案",
"已清空本机统计数据": "已清空本機統計資料",
"本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。",
"未知": "未知",
"对局记录": "對局記錄",
"{n} 小时": "{n} 小時",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "計入統計:打到結算的對局,以及中途退出前至少清完 1 個回合的對局;更早退出的對局只留在對局記錄裡。",
"另有 {n} 局未计入统计": "另有 {n} 局未計入統計",
"没有清完第一个回合就退出,不计入统计": "沒有清完第一個回合就退出,不計入統計",
"中途退出的对局没有结算可看": "中途退出的對局沒有結算可看",
"较早的对局只保留汇总,不能回看结算": "較早的對局只保留彙總,不能回看結算",
"中途退出": "中途退出",
"不计入统计": "不計入統計",
"显示更多(还有 {n} 局)": "顯示更多(還有 {n} 局)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "點擊一行,回看該局結算;中途退出的對局只留記錄。",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "最多保留最近 {max} 局,只有最新的 {full} 局能回看結算。",
"返回统计": "返回統計",
"文件过大,无法导入": "檔案過大,無法匯入",
"无法写入本机存储,可能已满": "無法寫入本機儲存空間,可能已滿",
"({n} 条无法读取)": "({n} 筆無法讀取)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "匯入失敗:這份檔案由更新版本的用戶端匯出,請先升級用戶端",
"清空统计数据": "清空統計資料",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "將刪除本機保存的 {n} 筆對局記錄,無法復原。",
"导出为文件(可在别的设备上导入)": "匯出為檔案(可在別的裝置上匯入)",
"从导出的文件导入(与现有记录合并)": "從匯出的檔案匯入(與現有記錄合併)",
"清空本机的统计数据": "清空本機的統計資料",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。",
"有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。",
"攻回": "攻回",
"受回": "受回",
"自回": "自回",
"常驻": "常駐",
"瞬发": "瞬發",
"部署触发": "部署觸發",
"未精英化": "未精英化",
"精英阶段1": "精英階段1",
"精英阶段2": "精英階段2",
"精英阶段2-60级": "精英階段2-60級",
"潜能": "潛能",
"潜能 {n}": "潛能 {n}",
"练度": "練度",
"潜能与练度": "潛能與練度",
"满潜能 · 精英2 Lv.60": "滿潛能 · 精英2 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "幹員持有中標記為未持有:替補幹員沒有潛能與練度;這裡的設定在改回「持有」後生效",
"原型干员没有潜能与练度": "原型幹員沒有潛能與練度",
"模组仅在精锐形态生效": "模組僅在精銳形態生效",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未持有(幹員持有):由替補幹員上場,替補幹員沒有潛能與練度",
"含潜能与练度;": "含潛能與練度;",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "將 {nChanged} 名幹員的技能、模組、潛能與練度恢復為預設配置?",
"已导入 {n} 名干员的潜能与练度": "已匯入 {n} 名幹員的潛能與練度",
"精英1": "精英1",
"精英2": "精英2",
"精英2 Lv.60": "精英2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。",
"文字大小": "文字大小",
"小": "小",
"大": "大",
"特大": "特大",
"调整界面文字大小,棋盘保持原比例。": "調整介面文字大小,棋盤保持原比例。",
"此干员语音": "此幹員語音",
"跟随全局": "跟隨全域",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "僅儲存在此瀏覽器,缺失的日語語音會回退到中文。",
"添加到桌面": "加入主畫面",
"恢复本机对局": "恢復本機對局",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "此對局仍在其他視窗中,或目前瀏覽器無法安全恢復。請關閉原視窗後重試。",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "選擇已關閉視窗的對局。伺服器重啟或對局過期後無法恢復。",
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
"重刷本局": "重刷本局",
"重刷本局投票:{agreed}/{total}": "重刷本局投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員同意後重新隨機戰場、領袖、特訓敵人和禁用;確認倒數已暫停。",
"保留房间和个人配置,重新随机全部开局信息。": "保留房間和個人配置,重新隨機全部開局資訊。",
"已同意": "已同意",
"待同意": "待同意",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全體真人玩家一致同意後刷新,隨機結果可能重複。",
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
"同意重刷": "同意重刷",
"拒绝重刷": "拒絕重刷",
"取消投票": "取消投票",
"只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
"库存不足": "庫存不足"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<title>Stronghold Protocol — refined mint rook</title>
<desc>Refines the existing 0.2.x project castle: three battlements, inset tower and splayed base. Original project geometry; no official artwork, lettering, gradients or texture.</desc>
<rect width="512" height="512" fill="#0c0f0e"/>
<g id="mark">
<polygon fill="#4ed8af" points="128,88 184,88 184,128 224,128 224,88 288,88 288,128 328,128 328,88 384,88 384,184 344,224 344,352 384,392 384,424 128,424 128,392 168,352 168,224 128,184"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+8 -1
View File
@@ -15,7 +15,12 @@
<meta name="apple-mobile-web-app-title" content="卫戍协议" />
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icons/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" href="/icons/favicon-16.png" type="image/png" sizes="16x16" />
<link rel="icon" href="/icons/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="icon" href="/icons/app.svg" type="image/svg+xml" sizes="any" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180" />
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -38,6 +43,7 @@
<link rel="stylesheet" href="/css/screens/result.css" />
<link rel="stylesheet" href="/css/screens/guide.css" />
<link rel="stylesheet" href="/css/screens/loadout.css" />
<link rel="stylesheet" href="/css/screens/stats.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
@@ -68,6 +74,7 @@
<div class="rotate-hint__phone"></div>
<div class="rotate-hint__title">请将设备横屏</div>
<div>卫戍协议:盟约 需要横屏显示以获得完整体验</div>
<button type="button" hidden class="btn btn--secondary btn--sm rotate-hint__pwa" id="rotate-hint-pwa">添加到桌面</button>
<div class="rotate-hint__micro">ROTATE DEVICE</div>
</div>
+12 -2
View File
@@ -226,7 +226,11 @@ export function bgmEntry(m, kind) {
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
/**
* Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` and — for an attack or
* an impact — `attacks[i]` / `hits[i]` (the running skill's own files, tools/assets/audio.mjs pickModeAttacks /
* pickModeHits) when the manifest has them.
*/
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
@@ -234,6 +238,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
const per = kind === 'attack' ? u.attacks : kind === 'hit' ? u.hits : null;
if (Number.isInteger(skillIndex) && isObj(per)) {
const a = str(per[skillIndex]);
if (a) return a;
}
return str(u[kind]);
}
@@ -831,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(
+160 -34
View File
@@ -4,6 +4,7 @@
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
// bossBgm { [bossId]: { intro?, loop } },
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
// voiceJp { [charId]: { …the same slots } } (the Japanese dub, settings 语音语言 日本語; see voiceLine),
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
@@ -18,14 +19,22 @@
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
// 联防 that follows a 作战 keeps its round's track.
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
// 行动出发 start, 行动开始 faceEnemy, 部署 place, 作战中1-4 skillN and the settlement's 结算 result* (battle-only),
// and 选中干员 select whenever the player taps an operator — in the prep, the shop and the hand too (the owner's
// request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, which lifts 2026-10-03's 「整备阶段不需要干员语音」 for
// this line only). Voices are seconds long and a battle deploys eight
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`.
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
// the gap: VOICE_TAP_SLOTS). Two dubs: 中文 `audio.voice` (default) and 日本語 `audio.voiceJp` (settings 语音语言, not
// tied to the interface language; the owner's request of 2026-10-08 「全套的日配语音」) — a JP line the manifest or the
// host lacks falls back to the Chinese one (voiceLine).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its
// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy
// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every
// later one played (GitHub PR #292 by @LimitlessHPPK).
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
@@ -34,6 +43,16 @@
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
// - The attack sound of the RUNNING skill (`attacks`, 技能1 / 2 / 3 = `_d` / `_h` / `_s`): an operator whose every attack
// belongs to a skill mode has no normal attack bank at all — 司霆惊蛰 (解放者: she only attacks while a skill runs) was
// completely silent when she attacked (report 「三技能攻击没有音效」). The manifest then carries that mode's own file
// per skill index (`attacks`, tools/assets/audio.mjs pickModeAttacks) and it plays while the skill is active, from the
// `['skill', id, 1]` / `0` events; a unit with a normal bank too (能天使 S2 过载模式, 史尔特尔 S2) keeps that bank
// outside the skill. Being the official file of that very skill, it is not the normalAttackSfx case above.
// - …and its impact (`hits`): a mode's swing is not always the whole difference — 赤刃明霄陈 S3's slashes swing
// p_atk_hljdswd_s AND land p_imp_hljdswd_s, while her normal attack uses p_atk_hljdswd_n / p_imp_hljdswd_n
// (report 「开启三技能后斩击音效应该和普通攻击不一样」). Same mechanism and the same window: the `hit` of an attack
// aimed while the skill ran — the attacker's state travels with `lastAttacker`.
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
@@ -46,6 +65,7 @@
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
import { PHASE } from '../../shared/constants.js';
import { mediaUrl } from './media.js';
@@ -76,6 +96,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
const IMPACT_WINDOW_MS = 2500;
/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */
const PENDING_SKILL_MAX = 64;
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
@@ -248,9 +270,9 @@ export function unitSoundPlays(mix, roll) {
/**
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
* PLACE_CHAR 20, FOCUS_CHAR 10 (选中干员). The settlement lines are no battle voice of the official scheduler: they sit at
* 85, above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The three
* prep-only slots (编入队伍 / 任命队长 / 干员报到) keep their levels although nothing plays them (see the header).
*/
export const VOICE_PRIORITY = Object.freeze({
start: 100, faceEnemy: 90,
@@ -259,14 +281,55 @@ export const VOICE_PRIORITY = Object.freeze({
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
});
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
/**
* Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. 选中干员
* has none — official FOCUS_CHAR `cooldown: 0` (it was 1.5 s until 0.2.2, when a tap in the prep made it audible).
*/
export const VOICE_COOLDOWN_MS = Object.freeze({
start: 0, faceEnemy: 3000,
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 0,
});
/**
* The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority:
* true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once:
* on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same
* priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does
* it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 /
* 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are).
*/
export const VOICE_TAP_SLOTS = Object.freeze(['select']);
/**
* The line an operator says for a slot, in the chosen dub (settings 语音语言, ui/gameLogic/settings.js VOICE_LANGS):
* { url, fallback }, or null for an operator no dub voices (the 预备干员 / 原型干员 stand-ins, 盟约·辅助干员, summons)
* or a slot it lacks. 'cn' draws from `audio.voice`; 'jp' from
* `audio.voiceJp`, the same slots and file names in the Japanese dub, and falls back to the Chinese line twice over: per
* slot when the JP tree lacks it (a file the fetch could not get is left out of the manifest), and per line at play time
* — `fallback` is the Chinese file of the same name, played when the host does not have the JP one (a full zip built
* without the JP dub, before setup downloaded it). A slot with several lines draws one (`random` ∈ [0, 1)).
* @param {any} audio the manifest's `audio`
* @param {string} charId
* @param {string} slot
* @param {'cn'|'jp'|string} [lang]
* @param {() => number} [random]
* @returns {{ url: string, fallback: string|null } | null}
*/
export function voiceLine(audio, charId, slot, lang = 'cn', random = Math.random) {
const lines = (line) => (Array.isArray(line) ? line : [line]).filter((u) => typeof u === 'string' && u);
const draw = (list) => (list.length ? list[Math.min(list.length - 1, Math.floor(random() * list.length))] : null);
const cn = lines(audio?.voice?.[charId]?.[slot]);
const jp = lang === 'jp' ? draw(lines(audio?.voiceJp?.[charId]?.[slot])) : null;
if (jp) {
const file = (u) => u.slice(u.lastIndexOf('/') + 1);
return { url: jp, fallback: cn.find((u) => file(u) === file(jp)) ?? draw(cn) };
}
const url = draw(cn);
return url ? { url, fallback: null } : null;
}
/**
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
@@ -365,7 +428,9 @@ export class SfxLimiter {
/**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
* clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the
* cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock
* is passed in.
*/
export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
@@ -396,10 +461,13 @@ export class VoiceGate {
*/
request(slot, unitKey, now) {
const pri = VOICE_PRIORITY[slot] ?? 0;
const tap = VOICE_TAP_SLOTS.includes(slot);
if (this.playing) {
if (tap && this.playing.slot === slot) return 'preempt'; // a newer tap replaces the 选中 line on air
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
return 'preempt'; // a clearly more important line takes the channel
}
if (tap) return 'play'; // a tap on an idle channel always answers (FOCUS_CHAR cooldown 0, no gap)
if (now - this.lastAt < this.gapMs) return 'drop';
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
return 'play';
@@ -408,7 +476,7 @@ export class VoiceGate {
/** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now;
if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
@@ -457,13 +525,17 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.voiceOverrides = {};
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
this.lastAttacker = new Map(); // target id → { def, at, skillIndex, skillActive } of the hostile attack aimed at it (its impact sound,
// including the running skill's own `hits` — 赤刃明霄陈 S3's slash)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
@@ -596,6 +668,16 @@ export class AudioManager {
this._applyVolumes();
}
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
* @param {string} lang
*/
setVoiceLang(lang, overrides = this.voiceOverrides) {
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
}
_applyVolumes() {
if (!this.ctx) return;
try {
@@ -802,18 +884,27 @@ export class AudioManager {
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @param {number} [skillIndex] the unit's equipped skill slot (0-based)
* @param {boolean} [skillActive] whether that skill runs right now (`attacks` / `hits` need it, see header)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
unit(defId, kind, unitId, skillIndex, skillActive = false) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
// the running skill's own swing / impact (header); only while it runs — otherwise 能天使 S2's file would ring on
// every normal attack of hers (exactly what normalAttackSfx keeps out). 赤刃明霄陈 S3: `attacks[2]` = the S3 swing,
// `hits[2]` = the S3 slash's impact.
const table = kind === 'attack' ? u?.attacks : kind === 'hit' ? u?.hits : null;
const mode = skillActive && Number.isInteger(skillIndex) && table ? table[skillIndex] : null;
const url = typeof own === 'string' ? own : typeof mode === 'string' ? mode : u?.[kind];
if (typeof url !== 'string') return false;
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
// a skill-mode file is fine here when it IS the running skill's (mode); the gate is for normal attacks
if ((kind === 'attack' || kind === 'hit') && typeof mode !== 'string' && !normalAttackSfx(defId, url)) return false;
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
const mix = kind === 'skill' ? null : u?.mix?.[kind];
const mix = kind === 'skill' ? null : typeof mode === 'string'
? (kind === 'attack' ? u?.attackMix : u?.hitMix)?.[skillIndex] : u?.mix?.[kind];
if (!unitSoundPlays(mix, this.random())) return true;
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
return true;
@@ -823,9 +914,11 @@ export class AudioManager {
// ---- operator battle voice ----------------------------------------------------------------------------------
/**
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* Play an operator's battle line (`audio.voice[charId][slot]`, or `audio.voiceJp` when the 语音语言 setting is 日本語 —
* voiceLine; a slot with several lines draws one at random).
* Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on
* an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The
* line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* @param {string} charId e.g. 'char_263_skadi'
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
@@ -836,31 +929,40 @@ export class AudioManager {
try {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
if (typeof url !== 'string' || !url) return false;
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
const line = voiceLine(this.getManifest()?.audio, charId, slot, voiceLangFor(charId, this.voiceLang, this.voiceOverrides));
if (!line) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
if (verdict === 'drop') return false;
if (verdict === 'preempt') this._stopVoice();
this.voiceGate.start(slot, o.unitKey ?? null, now);
const token = ++this.voiceToken;
this._playVoice(url, token, o.volume);
this._playVoice(line.url, token, o.volume, line.fallback);
return true;
} catch (err) { this._warn('voice', err); return false; }
}
/** Fetch/decode and start one voice line through the voice channel. */
_playVoice(url, token, volume) {
/**
* Fetch/decode and start one voice line through the voice channel. `fallback`: the line to play instead when this one
* cannot be loaded (a JP file the host lacks → the Chinese file of the same name; voiceLine) — same token, same gate.
*/
_playVoice(url, token, volume, fallback = null) {
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
// on #73).
const failed = () => {
if (token !== this.voiceToken) return; // taken over / stopped meanwhile: not ours to release
if (fallback && fallback !== url && this.ctx && this.voiceGain) this._playVoice(fallback, token, volume);
else this.voiceGate.release();
};
this._buffer(url).then((buf) => {
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
if (!buf) { failed(); return; }
if (!this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
try {
const src = this.ctx.createBufferSource();
src.buffer = buf;
@@ -888,7 +990,7 @@ export class AudioManager {
this._warn('voice-play', err);
if (token === this.voiceToken) this.voiceGate.release();
}
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
}, failed);
}
/** Fade the line on air out (a higher priority line is taking the channel over). */
@@ -912,6 +1014,9 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
this.pendingSkill.clear();
this.lastAttacker.clear();
this.consumed.clear();
// a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over
@@ -923,9 +1028,15 @@ export class AudioManager {
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
// official class sounds (operator vs summon vs device). `skillActive` follows the ['skill', id, 1] / 0 events.
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null, skillActive: !!u.skillActive });
// a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once
const held = this.pendingSkill.get(u.id);
if (held) {
this.pendingSkill.delete(u.id);
this.handleBattleEvents([held]);
}
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
@@ -952,25 +1063,40 @@ export class AudioManager {
if (!src) continue;
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
const tgt = this.units.get(e[2]);
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now, skillIndex: src.skillIndex ?? null, skillActive: !!src.skillActive });
if (!this.unit(src.def, 'attack', e[1], src.skillIndex ?? undefined, src.skillActive) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (!by || !IMPACT_TYPES.has(e[3])) continue;
this.lastAttacker.delete(e[1]); // one impact per attack
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`, by.skillIndex ?? undefined, by.skillActive);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) {
// the running skill's own swing / impact (`attacks` / `hits`, header) follow this flag
u.skillActive = true;
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
if (unitSoundClass(u) === 'char') {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
} else {
// The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick
// (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same
// batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill
// turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`).
if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value);
this.pendingSkill.set(e[1], e);
}
} else if (kind === 'skill') {
// ['skill', id, 0] = the skill ENDED (the sim's skills.js: `b._ev(['skill', u.id, 0])`): back to the unit's own
// attack / impact. The end carries no sound of its own (the activation sound belongs to the start).
const u = this.units.get(e[1]);
if (u) u.skillActive = false;
this.pendingSkill.delete(e[1]);
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
const u = this.units.get(e[1]);
@@ -1039,13 +1165,13 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean, voiceLang?:string } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang, deps.settings.voiceOverrides); }
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try {
+4 -2
View File
@@ -100,7 +100,7 @@ export function followedScout({ field = null, watching = null, alive = true, spe
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, resolved, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
@@ -111,7 +111,9 @@ export function teammateProgress(pub, myId) {
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
killed: Number.isFinite(pr?.killed) ? pr.killed : null,
resolved: Number.isFinite(pr?.resolved) ? pr.resolved : (Number.isFinite(pr?.killed) ? pr.killed : null),
total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
+39 -5
View File
@@ -72,6 +72,7 @@ import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
import { spectateEffects } from './observe.js';
import { recordError, setBattleSource } from '../diag.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
@@ -119,6 +120,14 @@ export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIME
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
/**
* What the runner compares between two looks at a 联防 battle (noteUniteLeft) before it reads the leakers' enemies still
* standing again: a knock-out, a leak, a spawn, the end. `total` stands for the spawns no longer — a split child or a summon
* is outside the capsule's denominator (DESIGN §14 顶栏胶囊) — so the length of the enemy list does.
* @param {any} b the battle
*/
export const uniteLeftMark = (b) => `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${Array.isArray(b.enemies) ? b.enemies.length : 0}:${b.finished ? 1 : 0}`;
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
@@ -156,9 +165,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
/**
* Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep
* them for the diagnostics a player copies (diag.js).
*/
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); },
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
@@ -288,7 +300,7 @@ export function createBattleRunner(deps) {
function noteUniteLeft(e) {
const b = e.battle;
const mark = `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${b.finished ? 1 : 0}`;
const mark = uniteLeftMark(b);
if (mark === e.leakMark) return;
e.leakMark = mark;
let left = e.left;
@@ -351,6 +363,7 @@ export function createBattleRunner(deps) {
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
recordError('runner', err, 'battle step failed');
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
@@ -393,7 +406,7 @@ export function createBattleRunner(deps) {
const list = catchingUp ? s.ev.filter(keepsState) : s.ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); }
}
/**
@@ -428,9 +441,16 @@ export function createBattleRunner(deps) {
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
// the HUD capsule's numerator of this field (shared/protocol.js b.progress `resolved`): the field's own scheduled
// enemies knocked out or leaked. Sent only when the battle reports one (Battle.resolved) — an absent field leaves
// the teammate UI on its `resolved ?? killed` fallback
if (Number.isFinite(p.resolved)) msg.resolved = Math.max(0, Math.min(msg.total, p.resolved));
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
// the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's
// leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree)
if (p.leaksBy) msg.leaksBy = p.leaksBy;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
@@ -457,7 +477,7 @@ export function createBattleRunner(deps) {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
} catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); }
if (result) {
e.result = result;
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
@@ -491,6 +511,7 @@ export function createBattleRunner(deps) {
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
recordError('runner', code, 'b.result refused');
}
return null;
});
@@ -670,6 +691,7 @@ export function createBattleRunner(deps) {
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
recordError('runner', err, 'simulation unavailable');
return;
}
if (!wanted()) return;
@@ -678,6 +700,7 @@ export function createBattleRunner(deps) {
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
recordError('runner', err, 'battle construction failed');
return;
}
stats.battles++;
@@ -806,6 +829,16 @@ export function createBattleRunner(deps) {
return () => listeners.get(type)?.delete(fn);
},
state,
/**
* The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one.
* @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null}
*/
currentBattle() {
const e = cur;
if (!e || !e.spec) return null;
const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null;
return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time };
},
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
@@ -869,3 +902,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !==
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
if (battleRunner) setBattleSource(() => battleRunner.currentBattle());
+269
View File
@@ -0,0 +1,269 @@
// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈).
//
// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted
// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names,
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
const MESSAGE_MAX = 300;
/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */
const log = [];
let total = 0;
let dropped = 0;
const pageStart = Date.now();
/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */
export function messageOf(err) {
let s;
if (err == null) s = String(err);
else if (typeof err === 'string') s = err;
else if (typeof err === 'object') {
const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : '';
const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : '';
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : '';
if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`;
else if (code) s = `${name}${code}`;
else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } }
} else s = String(err);
s = s.replace(/\s+/g, ' ').trim();
return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s;
}
/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */
export function stackOf(err) {
const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : '';
if (!raw) return null;
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
// V8 starts with "Name: message"; Firefox / Safari list frames only
const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l)));
const out = frames.slice(0, STACK_LINES).map(stripHosts);
return out.length ? out.join('\n') : null;
}
/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */
export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, '');
/**
* Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead.
* @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms
*/
export function recordError(source, err, detail = null, at = Date.now()) {
const message = stripHosts(messageOf(err));
const stack = stackOf(err);
const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX);
total++;
const last = log[log.length - 1];
if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) {
last.count++;
last.lastAt = at;
return last;
}
const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 };
log.push(e);
while (log.length > LOG_MAX) { log.shift(); dropped++; }
return e;
}
/** The log, oldest first (copies). */
export const errorLog = () => log.map((e) => ({ ...e }));
/** Errors recorded on this page (repeats included). */
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
/** The battle on screen for a report, or null. */
export function currentBattle() {
try { return battleSource ? battleSource() || null : null; } catch { return null; }
}
/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */
function secretMap(pairs) {
const map = new Map();
for (const [v, ph] of pairs) {
if (typeof v === 'string' && v.trim()) map.set(v, ph);
}
return map;
}
/**
* Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again).
* A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so
* "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is
* two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari
* before 16.4.
* @param {string} text @param {[string, string][]} pairs [value, placeholder]
*/
export function redact(text, pairs) {
const map = secretMap(pairs);
const list = [...map.keys()].sort((a, b) => b.length - a.length);
if (!list.length) return String(text);
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const word = '[\\p{L}\\p{N}_]';
const re = new RegExp(`(?<pre>^|(?!${word})[\\s\\S])(?<hit>${list.map(esc).join('|')})(?!${word})`, 'gu');
return String(text).replace(re, (...args) => {
const g = args[args.length - 1];
return `${g.pre}${map.get(g.hit)}`;
});
}
/**
* Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
* merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
* token replacement would still rewrite `"charge the char"` when the name is `char`.
* @param {any} value @param {[string, string][]} pairs
*/
export function redactValues(value, pairs) {
const map = secretMap(pairs);
const walk = (v) => {
if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const out = {};
for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
return out;
}
return v;
};
return walk(value);
}
/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
export function secretsOf(state) {
const pairs = [];
const names = new Map();
const addName = (n) => {
if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
names.set(n, n === state?.me?.name ? '<me>' : `<player${names.size + 1}>`);
};
addName(state?.me?.name);
for (const s of state?.room?.seats || []) addName(s?.name);
for (const s of state?.room?.spectators || []) addName(s?.name);
for (const p of state?.match?.public?.players || []) addName(p?.name);
for (const [n, ph] of names) pairs.push([n, ph]);
if (state?.room?.code) pairs.push([String(state.room.code), '<room>']);
if (state?.me?.token) pairs.push([String(state.me.token), '<token>']);
return pairs;
}
const pad2 = (n) => String(n).padStart(2, '0');
const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
const spanOf = (ms) => {
const s = Math.max(0, Math.round(ms / 1000));
return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
};
const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
/**
* The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
lines.push('Stronghold-Protocol diagnostics');
lines.push(`version: ${input.version ?? APP_VERSION}`);
lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
if (env.ua) lines.push(`browser: ${env.ua}`);
if (env.gpu) lines.push(`gpu: ${env.gpu}`);
lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
const c = state.connection || {};
lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
const pub = state.match?.public || null;
const room = state.room || null;
lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
room?.difficulty ? `difficulty ${room.difficulty}` : null,
Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
const b = state.match?.battle || null;
if (b) {
lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
}
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
lines.push('');
lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
shown.forEach((e, i) => {
lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
if (e.detail) lines.push(` ${e.detail}`);
if (e.stack) for (const l of e.stack.split('\n')) lines.push(` ${l}`);
});
const pairs = secretsOf(state);
let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
if (battle && battle.spec) {
const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
const payload = redactValues({
t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
}, pairs);
text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
}
return text;
}
let gpuName;
/** What the report says about this browser and device (null fields stay out). */
export function browserEnv() {
const g = globalThis;
if (gpuName === undefined) {
gpuName = null;
try {
const cv = g.document?.createElement('canvas');
const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
} catch { gpuName = null; }
}
let uiLang = null;
try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
return {
ua: g.navigator?.userAgent ?? null,
gpu: gpuName,
viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
dpr: g.devicePixelRatio ?? null,
touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
cores: g.navigator?.hardwareConcurrency ?? null,
memoryGB: g.navigator?.deviceMemory ?? null,
lang: g.navigator?.language ?? null,
uiLang,
};
}
/**
* The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
* battle on screen.
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
}
+23 -2
View File
@@ -49,10 +49,13 @@ import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { StatsHost } from './screens/stats.js';
import { recordResult, installStatsRecorder } from './ui/stats.js';
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -133,6 +136,7 @@ function maybeFinishRestore() {
* follow the store themselves).
*/
function backToLobby() {
identity.rememberMatch(null);
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
@@ -142,9 +146,11 @@ function backToLobby() {
function onWelcome(msg) {
identity.saveToken(msg.token);
identity.rememberMatch(null);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
identity.saveName(name);
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
@@ -185,6 +191,8 @@ function onRoomState(msg) {
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
identity.rememberMatch(room.inMatch && seats.some((seat) => seat?.playerId === myId)
? { name: store.get().me.name, code: room.code || '' } : null);
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
@@ -207,7 +215,10 @@ function wireNet() {
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('helloError', (err) => {
if (err.code === 'SESSION_IN_USE') identity.rejectToken();
toastError(err);
});
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
@@ -219,7 +230,13 @@ function wireNet() {
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.result', (msg) => {
const res = payload(msg);
store.patch('match', { result: res });
// 本机统计 (PR #323): every arrival, including the lobby's result replay after a reconnect / reload —
// replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
});
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
@@ -287,6 +304,7 @@ function App() {
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
<${StatsHost} />
</div>`;
}
@@ -311,12 +329,14 @@ function installGlobalErrorHandlers() {
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
recordError('rejection', err);
if (err instanceof NetError) toastError(err);
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
});
}
@@ -339,6 +359,7 @@ async function boot() {
}));
wireNet();
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
installLoadoutSync({ net });
installOwnershipSync({ net });
installDiySync({ net });
+156 -24
View File
@@ -114,6 +114,7 @@ export class Net {
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
* @param {() => ({ noReplace: boolean, claimAt?: number })} [opts.getTokenClaim] local ownership for this hello
* @param {() => number} [opts.now]
* @param {() => number} [opts.random]
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
@@ -122,6 +123,7 @@ export class Net {
this.url = opts.url || null;
this.WS = opts.WebSocket || null;
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
this.getTokenClaim = typeof opts.getTokenClaim === 'function' ? opts.getTokenClaim : () => null;
this.now = opts.now || (() => Date.now());
this.random = opts.random || Math.random;
this.timers = opts.timers || {
@@ -157,6 +159,7 @@ export class Net {
this._helloTimer = null;
this._helloRid = null;
this._helloSentName = null;
this._helloToken = null;
this._lastRx = 0;
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
this._clockSamples = []; // [{ offset, rtt }]
@@ -254,8 +257,9 @@ export class Net {
}
/** Drop the current socket and reconnect immediately (fresh hello). */
reconnectNow() {
reconnectNow(name = this.name) {
if (this._manualClose) return;
this.name = name;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
@@ -365,7 +369,13 @@ export class Net {
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
let token = null;
try { token = this.getToken(); } catch { token = null; }
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
if (typeof token === 'string' && token.length > 0 && token.length <= 64) {
msg.token = token;
const claim = this.getTokenClaim();
if (claim?.noReplace) msg.noReplace = true;
if (Number.isFinite(claim?.claimAt)) msg.claimAt = claim.claimAt;
}
this._helloToken = token;
this._helloRid = rid;
this._helloSentName = this.name;
if (this.status !== 'handshaking') this._setStatus('handshaking');
@@ -533,6 +543,9 @@ export class Net {
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
if (t === 'welcome') {
// A local ownership election can revoke a token after its hello went onto the wire.
// Do not publish or persist that stale welcome, even if the server had already accepted it.
if (this._helloToken !== this.getToken()) { this.reconnectNow(); return; }
this._onWelcome(msg);
} else if (t === 'pong') {
this._onPong(msg);
@@ -654,12 +667,17 @@ export class Net {
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
// resolving shared candidates at the same moment tie-break on tab id. An existing session token
// never falls back to another recent identity. Its welcome stamp survives reload; an earlier
// welcome outranks a later claimant, then a live holder wins over its duplicate, then tab id.
// Without BroadcastChannel a tab only ever uses
// its own token (never another tab's), so it can't steal a live session either.
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_WELCOME = 'sp.tokenWelcome'; // sessionStorage: { hash, at } from the first welcome for this token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
const RECENT_MAX = 4;
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
@@ -713,20 +731,31 @@ function defaultChannel() {
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number, now?: () => number,
* onYield?: () => void }} [deps]
*/
export function createIdentity(deps = {}) {
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
const now = deps.now || (() => Date.now());
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
let channel = null;
let initPromise = null;
let initialized = false;
let resumeBusy = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { taken: Set<hash> } while init() waits for claims
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
let owned = sget(session, K_TOKEN);
if (!isToken(owned)) owned = null;
let welcomeAt = null;
try {
const saved = JSON.parse(sget(session, K_WELCOME) || 'null');
if (owned && saved?.hash === tokenHash(owned) && Number.isFinite(saved.at) && saved.at >= 0) welcomeAt = saved.at;
} catch { /* old / damaged storage: the session token still claims its seat */ }
let welcomed = false; // this document has received welcome (a copied stamp alone is not a live holder)
const readRecent = () => {
try {
@@ -738,22 +767,75 @@ export function createIdentity(deps = {}) {
};
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
const readMatches = () => {
try {
const v = JSON.parse(sget(local, K_MATCHES) || '{}');
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
} catch { return {}; }
};
const rememberMatch = (info) => {
if (!current) return;
const old = readMatches(), next = {};
for (const token of readRecent()) {
const hash = tokenHash(token);
const v = token === current ? info : old[hash];
if (v && typeof v.name === 'string' && typeof v.code === 'string') {
next[hash] = { name: v.name.slice(0, 64), code: v.code.slice(0, 32) };
}
}
sset(local, K_MATCHES, JSON.stringify(next));
};
const post = (msg) => {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
// Session ownership is already valid while init is waiting. Shared candidates, including an
// explicit resume, remain tentative until saveToken receives the server's welcome.
const claimFor = (hash) => {
if (owned && tokenHash(owned) === hash) return { owned: true, at: welcomeAt, live: welcomed };
if ((current && tokenHash(current) === hash) || resolving?.hashes.has(hash)) return { owned: false, at: null, live: false };
return null;
};
const peerWins = (peer, from, ours) => {
if (!peer || !ours) return true; // an older client answering "mine" is conservatively treated as live
const a = Number.isFinite(peer.at) && peer.at >= 0 ? peer.at : Infinity;
const b = Number.isFinite(ours.at) && ours.at >= 0 ? ours.at : Infinity;
if (a !== b) return a < b;
if (!!peer.owned !== !!ours.owned) return !!peer.owned;
if (!!peer.live !== !!ours.live) return !!peer.live;
return from < tabId;
};
const yieldCandidate = (hash) => {
resolving?.taken.add(hash);
// The original page can reappear after resume's query but before its hello. Keep our old
// session in storage and give back the tentative selection immediately in that interval.
if (current && tokenHash(current) === hash) {
if (current === owned) {
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_WELCOME);
}
current = owned;
if (owned) sset(session, K_TOKEN, owned);
else sdel(session, K_TOKEN);
if (initialized) deps.onYield?.();
}
};
function onChannelMessage(ev) {
const m = ev && ev.data;
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
if (m.type === 'who' && Array.isArray(m.hashes)) {
if (current) {
const h = tokenHash(current);
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
for (const h of m.hashes) {
if (typeof h !== 'string') continue;
const ours = claimFor(h);
if (!ours) continue;
const peer = m.claims?.[h] || { owned: false, at: null, live: false };
if (peerWins(peer, m.from, ours)) yieldCandidate(h);
else post({ type: 'mine', hash: h, claim: ours, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h);
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
} else if (m.type === 'mine' && m.to === tabId && typeof m.hash === 'string') {
if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash);
}
}
@@ -763,13 +845,16 @@ export function createIdentity(deps = {}) {
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
else channel.onmessage = onChannelMessage;
}
const own = sget(session, K_TOKEN);
const ownOk = isToken(own) ? own : null;
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
resolving = { taken: new Set() };
post({ type: 'who', hashes: candidates.map(tokenHash) });
const candidates = owned ? [owned] : channel ? [...new Set(readRecent())] : [];
if (!channel || candidates.length === 0) return owned;
return queryCandidates(candidates);
}
async function queryCandidates(candidates) {
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes],
claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
@@ -786,17 +871,46 @@ export function createIdentity(deps = {}) {
init() {
if (!initPromise) {
initPromise = resolveToken().catch(() => null).then((pick) => {
const own = sget(session, K_TOKEN);
if (pick) sset(session, K_TOKEN, pick);
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
// A welcome may already have set a token while we were waiting: that one wins.
if (!current) current = pick;
if (!welcomed) {
current = pick;
if (pick) sset(session, K_TOKEN, pick);
else {
owned = null; welcomeAt = null;
sdel(session, K_TOKEN); sdel(session, K_WELCOME);
}
}
initialized = true;
return current;
});
}
return initPromise;
},
/** Record only display metadata; the existing recent-token ring remains the authority. */
rememberMatch,
recoverable() {
const matches = readMatches();
return readRecent().filter((token) => token !== current).flatMap((token) => {
const hash = tokenHash(token), v = matches[hash];
return v && typeof v.name === 'string' && typeof v.code === 'string'
? [{ id: hash, name: v.name.slice(0, 64), code: v.code.slice(0, 32) }] : [];
});
},
/** Explicit recovery only. Recheck live claims immediately before selecting a saved seat. */
async resume(id) {
await this.init();
if (!channel || resumeBusy) return false;
const token = readRecent().find((t) => t !== current && tokenHash(t) === id);
if (!token || !this.recoverable().some((m) => m.id === id)) return false;
resumeBusy = true;
try {
const pick = await queryCandidates([token]);
if (!pick) return false;
current = pick;
return true;
} catch { return false; }
finally { resumeBusy = false; }
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
@@ -808,18 +922,36 @@ export function createIdentity(deps = {}) {
const own = sget(session, K_TOKEN);
return isToken(own) ? own : null;
},
/** A shared candidate must never replace a connected session, including a hello already in flight. */
getTokenClaim() {
return current === owned && owned
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
: { noReplace: true };
},
/** Only this page gives up the rejected claim; the shared recovery list belongs to all tabs. */
rejectToken() {
if (current) yieldCandidate(tokenHash(current));
},
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
if (current !== token) rememberMatch(null);
if (owned !== token || welcomeAt == null) welcomeAt = now();
owned = token;
welcomed = true;
current = token;
sset(session, K_TOKEN, token);
sset(session, K_WELCOME, JSON.stringify({ hash: tokenHash(token), at: welcomeAt }));
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
rememberMatch(null);
const t = current || sget(session, K_TOKEN);
current = null;
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_TOKEN);
sdel(session, K_WELCOME);
if (t) writeRecent(readRecent().filter((x) => x !== t));
},
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
@@ -830,7 +962,7 @@ export function createIdentity(deps = {}) {
}
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
export const identity = createIdentity();
export const identity = createIdentity({ onYield: () => net.reconnectNow(net.helloName || net.name) });
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
export const net = new Net({ getToken: () => identity.getToken() });
export const net = new Net({ getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
+40
View File
@@ -0,0 +1,40 @@
// Install prompts are browser-issued, one-use capabilities. No service worker or application cache is installed.
export function createPwaInstall(win) {
let prompt = null, installed = false;
const listeners = new Set();
const display = win?.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen)');
const available = () => !!prompt && !installed && !display?.matches && !win?.navigator?.standalone;
const emit = () => { for (const fn of listeners) fn(available()); };
const offer = (event) => {
if (installed || display?.matches || win?.navigator?.standalone || typeof event.prompt !== 'function') return;
event.preventDefault();
prompt = event;
emit();
};
const done = () => { installed = true; prompt = null; emit(); };
win?.addEventListener?.('beforeinstallprompt', offer);
win?.addEventListener?.('appinstalled', done);
display?.addEventListener?.('change', emit);
return {
available,
subscribe(fn) { listeners.add(fn); fn(available()); return () => listeners.delete(fn); },
async request() {
if (!available()) return false;
const event = prompt;
prompt = null; // Consume before awaiting: dismissal, rejection and repeated clicks cannot reuse this event.
emit();
try {
const result = await event.prompt();
const choice = event.userChoice ? await event.userChoice : result;
return choice?.outcome === 'accepted';
} catch { return false; }
},
dispose() {
win?.removeEventListener?.('beforeinstallprompt', offer);
win?.removeEventListener?.('appinstalled', done);
display?.removeEventListener?.('change', emit);
prompt = null; listeners.clear();
},
};
}
export const pwaInstall = createPwaInstall(globalThis.window);
+164 -55
View File
@@ -44,9 +44,10 @@
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
// equips that unit.
// drops on the tile under the pointer (render/drag.js). A dragged unit stands on that tile while it is a legal target
// (a board tile or a bench slot: drag.js dragStandTile; the official deploy drag, the owner's recording of 2026-10-09,
// PR #403's report) and is held under the pointer elsewhere — its drawn feet DRAG_HOLD_TILES below it, the pointer on
// its body; an item plate stays centred on the pointer. An item dropped on a unit's tile equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -117,10 +118,10 @@ import { fxForm } from '../../../shared/protocol.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
import { createDragController, pieceTile, dragStandTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, silhouetteTexture } from './textures.js';
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js';
@@ -148,14 +149,17 @@ export { releaseGl } from './app/host.js';
/**
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
const opts = options && typeof options === 'object' ? options : {};
const P = await ensurePixi();
const signal = opts.signal;
signal?.throwIfAborted();
const P = await withTimeout(ensurePixi(), null, signal);
signal?.throwIfAborted();
const assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
@@ -164,19 +168,28 @@ export async function createFieldView(host, options = {}) {
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
signal?.throwIfAborted();
// web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
signal?.throwIfAborted();
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
const s0 = size();
// Construction is synchronous until the complete view can own cancellation. If setup throws,
// dispose only the resources acquired by this attempt, never another view's host contents.
const setupCleanup = [];
let destroyed = false;
let view = null;
let onAbort;
try {
const app = new P.Application({
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
@@ -184,6 +197,11 @@ export async function createFieldView(host, options = {}) {
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
});
const canvas = app.view;
setupCleanup.push(() => {
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
finally { canvas.remove(); }
});
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
@@ -240,7 +258,6 @@ export async function createFieldView(host, options = {}) {
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
};
let destroyed = false;
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
let stageRec = null;
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
@@ -302,12 +319,14 @@ export async function createFieldView(host, options = {}) {
renderer: app.renderer,
frameNo: () => frameNo,
impostorInterval: () => impInterval,
impostorSlot: () => impSlot++,
clipAllowed: () => clipAllowed,
viewport: () => vp,
loadLevel: () => loadLevel,
surfaceLayer: (row) => tiles.surfaceLayer(row),
};
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
setupCleanup.push(() => tiles.destroy());
const fx = new FxSystem({
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
timeScale: () => ctx.timeScale(),
@@ -322,10 +341,12 @@ export async function createFieldView(host, options = {}) {
return Math.max(80, Math.min(size().height * 0.4, p.y));
},
});
setupCleanup.push(() => fx.destroy());
ctx.fx = fx;
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
const impostors = new ImpostorAtlas(app.renderer);
setupCleanup.push(() => impostors.destroy());
ctx.impostors = impostors;
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
@@ -334,7 +355,6 @@ export async function createFieldView(host, options = {}) {
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
return art;
}, () => null);
await withTimeout(artPromise, 2500);
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
let board3d = null; // BoardScene while the 3D board is on
@@ -342,6 +362,7 @@ export async function createFieldView(host, options = {}) {
let board3dError = null;
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
@@ -411,13 +432,20 @@ export async function createFieldView(host, options = {}) {
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
}, delay);
}
if (want3d) {
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
await withTimeout(ready, 6000);
// 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;
let offShadow = null;
setupCleanup.push(() => offShadow?.());
function loadShadow() {
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (!shadowUrl) return;
@@ -430,13 +458,17 @@ export async function createFieldView(host, options = {}) {
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
};
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
if (t.baseTexture.valid) use();
else {
t.baseTexture.once('loaded', use);
offShadow = () => t.baseTexture.off('loaded', use);
}
} catch { /* optional */ }
}
loadShadow();
ensureDamageFonts();
// web fonts may land after the first chips were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
// web fonts may land after the first chips / redeploy-ring labels were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {});
// ---- camera ---------------------------------------------------------------------------------------------
@@ -775,8 +807,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)
@@ -784,11 +824,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;
@@ -1062,23 +1101,33 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
// a unit over a legal drop target — the tile under the pointer, on the board or the bench (render/drag.js
// dragStandTile) — stands on it, lifted, as in the official deploy drag (the owner's recording of 2026-10-09): the
// world point the drop tween goes to (boardWorld, the prep field transform applied), set at once, no tween
const stand = item ? null : dragStandTile(p);
if (stand) {
const w = boardWorld(stand.row, stand.col, stand.bench);
v.x = w.x; v.y = w.y; v.z = w.z;
} else {
// otherwise held under the pointer (the player still sees what they carry): a unit with its drawn (lifted) feet
// DRAG_HOLD_TILES of its own px per tile below it (the scale at the pointer's ground, refined once at the feet), an
// item plate centred on it
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
}
}
// the drop target (p.target, highlighted) is the tile under the pointer
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
const c = p.target.area === 'board' ? p.target.col : p.target.idx;
@@ -1209,6 +1258,17 @@ export async function createFieldView(host, options = {}) {
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
const onTapTarget = () => {};
const removeInput = () => {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
};
setupCleanup.push(removeInput);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
@@ -1415,6 +1475,16 @@ export async function createFieldView(host, options = {}) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the
// destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally
// started already by syncBattle's look-ahead, which this repeats harmlessly
if (e[1] === 'displace') {
const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = d && d.id != null ? views.get(d.id) : null;
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
...(Number(d.dur) > 0 ? { dur: Number(d.dur) } : {}), keepFacing: d.keepFacing === true,
});
}
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
@@ -1467,10 +1537,27 @@ export async function createFieldView(host, options = {}) {
if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); }
}
/** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */
let slideAt = 0;
function startSlide(e) {
if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return;
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = extra && extra.id != null ? views.get(extra.id) : null;
// `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
at: slideAt, ...(Number(extra.dur) > 0 ? { dur: Number(extra.dur) } : {}), keepFacing: extra.keepFacing === true,
});
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
// A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the
// destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already
// lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued
// here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position.
slideAt = interp.nextSnapT(renderT);
if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide);
interp.sample(renderT, sample);
for (const [id, s] of sample) {
let v = views.get(id);
@@ -1481,9 +1568,7 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
syncView(v, s, renderT);
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
@@ -1534,6 +1619,10 @@ export async function createFieldView(host, options = {}) {
let cpuMs = 0;
let frameNo = 0;
let impInterval = 0;
// Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when
// (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the
// busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle.
let impSlot = 0;
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
let culledCount = 0;
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
@@ -1550,8 +1639,9 @@ export async function createFieldView(host, options = {}) {
// Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with
// the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated
// every 30 frames). Prep and ordinary fields keep full-rate direct rendering.
// Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil
// render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality
// Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on
// tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a
// closed lid would cut (GitHub #177, spine.js _eyeMaskFallback)
let clipAllowed = true;
function pickClipping() {
let n = 0;
@@ -1574,6 +1664,7 @@ export async function createFieldView(host, options = {}) {
}
function frameBody(now) {
frameNo++;
impSlot = 0;
if (frameNo % 30 === 1) {
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
culledCount = 0;
@@ -1658,6 +1749,7 @@ export async function createFieldView(host, options = {}) {
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(() => resize());
setupCleanup.push(() => ro.disconnect());
ro.observe(host);
}
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
@@ -1672,6 +1764,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);
@@ -1680,18 +1773,21 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
}
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
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?.();
leader?.view.retryAssets?.();
};
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
view = {
setStage,
setCamera,
setPrep,
@@ -1830,14 +1926,9 @@ export async function createFieldView(host, options = {}) {
try { ro?.disconnect(); } catch { /* ignore */ }
try { offAssets?.(); } catch { /* ignore */ }
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
removeInput();
offShadow?.();
signal?.removeEventListener('abort', onAbort);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1857,6 +1948,7 @@ export async function createFieldView(host, options = {}) {
try { board3dCanvas?.remove(); } catch { /* ignore */ }
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
canvas.remove();
},
stats() {
return {
@@ -1876,5 +1968,22 @@ export async function createFieldView(host, options = {}) {
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
setupCleanup.length = 0;
onAbort = () => view.destroy();
signal?.addEventListener('abort', onAbort, { once: true });
signal?.throwIfAborted();
await withTimeout(artPromise, 2500, signal);
if (want3d) await withTimeout(boardReady, 6000, signal);
signal?.throwIfAborted();
return view;
} catch (err) {
if (view) view.destroy();
else {
destroyed = true;
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
}
throw err;
}
}
+16 -2
View File
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
function withTimeout(p, ms, signal) {
let timer;
let onAbort;
const stopped = new Promise((resolve, reject) => {
if (ms != null) timer = setTimeout(resolve, ms);
if (signal) {
onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
return Promise.race([p, stopped]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
+5
View File
@@ -37,7 +37,12 @@ export function renderInfo(u) {
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
// an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained
// skill aura is drawn for it (render/fx/rings.js _aura)
ammoSkill: u.ammoSkill === true ? true : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
// Scouted hand and temp units have details but no field attack range.
area: typeof u.area === 'string' ? u.area : undefined,
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
// unit takes its items from the piece instead, so only other players' boards ever read this field
+3 -2
View File
@@ -26,8 +26,9 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
* A dragged unit over no legal target is held with its drawn feet this many tiles below the pointer — the pointer on its
* body, the model under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike); over a legal
* target it stands on that tile (render/drag.js dragStandTile). An item plate is centred on the pointer.
*/
export const DRAG_HOLD_TILES = 0.45;
+23 -7
View File
@@ -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; }
+19 -2
View File
@@ -11,8 +11,10 @@
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on
// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer
// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside'
// (mouse and touch alike).
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
@@ -80,6 +82,21 @@ export function resolveDrop(p) {
return { kind: 'drop', target: slot };
}
/**
* The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while
* it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise
* (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under
* the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the
* finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate.
*/
export function dragStandTile(move, geo = GEO) {
const t = move && move.legal === true ? move.target : null;
if (!t) return null;
if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false };
if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true };
return null;
}
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
export function isLegal(canPlace, piece, slot) {
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
+5
View File
@@ -7,6 +7,8 @@
*/
export const FX_KINDS = Object.freeze({
// blasts
// [ASSUMED] Clementia's S3 uses the shared blast placeholder until her official effect is ported.
blast: { a: 'blast', c: 0xffb35c },
aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 },
// `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch
bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true },
@@ -15,6 +17,9 @@ export const FX_KINDS = Object.freeze({
frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true },
iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 },
finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff },
// 赤刃明霄陈 S3 赤霄·天喟 的龙剑气: a travelling blade, drawn from the sim's per-interval `chen3Wave` events (its own
// position + the previous point + direction); `pt` because it is not at the caster (it roams and passes by her tile)
chen3Wave: { a: 'qi', c: 0xdfe8ff, pt: true },
quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 },
burst: { a: 'element', c: 0xd0a0ff },
// areas
+6 -1
View File
@@ -74,8 +74,13 @@ export class FxRings {
this._aura(view, true);
}
/** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */
/**
* Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo
* skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round
* — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all.
*/
_aura(view, on) {
if (on && view.info?.ammoSkill) return;
const P = this.P;
let a = this.auras.get(view.id);
if (on) {
+30 -3
View File
@@ -47,10 +47,11 @@ export class FxSim {
/**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame, qi), except kinds whose
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs).
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs), dr / dc (a
* direction in tiles: 'qi' points its blade that way).
*/
simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {};
@@ -100,7 +101,12 @@ export class FxSim {
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
break;
}
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
case 'chill':
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
break;
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
case 'healAoe': {
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
@@ -168,6 +174,27 @@ export class FxSim {
}
break;
}
// a travelling blade (赤刃明霄陈 S3's 龙剑气): a short trail from where it was to where it is, plus a crescent head
// turned the way it moves — the sim sends one of these every 0.12 s, and the trail outlives that gap so the
// events join into one sweeping wave. A procedural stand-in (the official effect is a particle prefab this client
// does not extract). Trail, head and heading share one height; the head is projected after streak(), which
// reuses this._p / this._q (projected before it, the head was drawn at the trail's start, one event behind)
case 'qi': {
const z = at.z + 0.35;
const fx0 = num(ex.fromX, NaN), fy0 = num(ex.fromY, NaN);
if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, z, col, 0.34);
const h = cam.project(at.x, at.y, z, this._p), hx = h.x, hy = h.y, hs = h.s;
const dr = num(ex.dr, NaN), dc = num(ex.dc, NaN);
let rot = 0;
if (Number.isFinite(dr) && Number.isFinite(dc) && (dr || dc)) {
const q = cam.project(at.x + dc * 0.5, at.y + dr * 0.5, z, this._q);
// the `slash` sprite's crescent bulges along its local −y, so this turns that edge onto the travel direction
rot = Math.atan2(q.x - hx, -(q.y - hy));
}
this.particle('slash', hx, hy, { tint: col, life: 0.3, s0: hs / 128 * 1.3, s1: hs / 128 * 2.0, a0: 0.9, a1: 0, rot });
this.particle('glow', hx, hy, { tint: col, life: 0.26, s0: hs / 128 * 0.8, s1: hs / 128 * 1.5, a0: 0.55, a1: 0 });
break;
}
case 'wave': {
const k = clamp(num(ex.scale, 1), 0.5, 4);
const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1));
+25 -4
View File
@@ -180,11 +180,32 @@ export class FxZones {
this.tintSprite.tint = tint;
}
/** A short flurry of snow over the field (cold wind). */
snowfall(tint) {
/**
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
*/
snowfall(tint, gust = false) {
const size = this.ctx.screenSize();
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
const W = size.width, H = size.height, low = this.quality === 'low';
if (!gust) {
for (let i = 0; i < (low ? 10 : 26); i++) {
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
}
return;
}
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
for (let i = 0; i < (low ? 22 : 48); i++) {
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
}
// gust streaks: thin wind lines racing up through the field along their velocity
for (let i = 0; i < (low ? 4 : 10); i++) {
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
}
}
+107 -20
View File
@@ -8,7 +8,8 @@
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze,
// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the
// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`),
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
@@ -20,18 +21,31 @@
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
// included — are always delivered, and a full queue sheds only cosmetic ones.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9):
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along
// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9, `stand` / `standCut` from PR #381):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
// both are integers): downAt(time) returns the list of the snapshot at `time`;
// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample()
// from the older snapshot like flags, so they step with the skill flag instead of sliding:
// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only)
// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null
// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none)
// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list
// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执).
// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position
// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a
// snapshot without it interpolates linearly;
// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside
// it stays linear.
// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares
// them with renderT.
import { fxForm } from '../../../shared/protocol.js';
import { ANIM } from '../../../shared/constants.js';
import { ANIM, UF } from '../../../shared/constants.js';
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */
@@ -43,6 +57,15 @@ const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it):
* the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`.
*/
function movementStart(a, b, id) {
const until = a.stand?.get(id), cutAt = b.standCut?.get(id);
return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t;
}
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
/**
@@ -66,8 +89,11 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
* state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, stand: Map<id, until> | null,
* standCut: Map<id, at> | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers
* default to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` / `ammo` / `wolves` /
* `neg` entries are dropped. `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite
* times in [0, t] of units in it.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
@@ -90,6 +116,14 @@ 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), coins = countList(snap.coins, units);
let neg = null;
if (Array.isArray(snap.neg)) {
for (const e of snap.neg) {
if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue;
(neg || (neg = new Map())).set(e[0], Math.min(1, e[1]));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
@@ -99,7 +133,36 @@ export function normalizeSnapshot(snap) {
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
let stand = null;
if (Array.isArray(snap.stand)) {
for (const e of snap.stand) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue;
(stand || (stand = new Map())).set(e[0], e[1]);
}
}
let standCut = null;
if (Array.isArray(snap.standCut)) {
for (const e of snap.standCut) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue;
(standCut || (standCut = new Map())).set(e[0], e[1]);
}
}
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
}
/**
* `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the
* `ammo` and `wolves` lists); null without a valid entry.
*/
function countList(list, units) {
if (!Array.isArray(list)) return null;
let m = null;
for (const e of list) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2])
|| e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue;
(m || (m = new Map())).set(e[0], [e[1], e[2]]);
}
return m;
}
export class SnapshotBuffer {
@@ -252,6 +315,16 @@ export class SnapshotBuffer {
return ans;
}
/**
* Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates
* — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown.
*/
nextSnapT(time = this.renderT) {
const s = this.snaps;
const i = this._indexAt(time) + 1;
return Number.isFinite(time) && i < s.length ? s[i].t : NaN;
}
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
@@ -265,8 +338,9 @@ export class SnapshotBuffer {
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves,
* neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0:
* none), see header). Samples of units no longer present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
@@ -282,28 +356,36 @@ export class SnapshotBuffer {
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
// (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide)
const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b);
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
o.vx = !tele && span > 0 ? dx / span : 0;
o.vy = !tele && span > 0 ? dy / span : 0;
const start = movementStart(A, B, id);
const moveSpan = B.t - start;
const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha;
o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0;
o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
if (P && ext > 0) {
// a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes
// `until` — the next snapshot confirms the position
if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) {
const p = P.units.get(id);
const dtp = A.t - P.t;
if (p && dtp > 0) {
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; }
const moveSpan = A.t - movementStart(P, A, id);
if (p && dtp > 0 && moveSpan > 0) {
const dx = a[1] - p[1], dy = a[2] - p[2];
if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) {
vx = dx / moveSpan; vy = dy / moveSpan;
}
}
}
o.x = a[1] + vx * ext;
@@ -317,6 +399,11 @@ export class SnapshotBuffer {
o.flags = a[7];
o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
// 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;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
+160 -10
View File
@@ -31,6 +31,8 @@
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
// 重生), landing in `roles`
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
// redraws the model, only advances `clock`)
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
@@ -62,20 +64,53 @@ export function windUpPlan(loopDur, hit, interval, lead) {
return { ts, tsWind, start };
}
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)));
/** Whether any skin of the skeleton data has a clipping attachment. */
export function hasClipping(data) {
try {
for (const skin of data?.skins || []) {
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
for (const e of list) {
const a = e && e.attachment;
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
}
for (const e of list) if (isClip(e && e.attachment)) return true;
}
} catch { /* unknown runtime shape: assume none */ }
return false;
}
/**
* Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123).
*/
export function pointInPolygon(x, y, pts, n) {
let inside = false;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its
* world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four
* corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1).
*/
function slotCentre(slot, att, buf, out) {
const n = att.worldVerticesLength | 0;
if (n >= 2 && typeof att.computeWorldVertices === 'function') {
if (buf.v.length < n) buf.v = new Float32Array(n);
const v = buf.v;
att.computeWorldVertices(slot, 0, n, v, 0, 2);
let cx = 0, cy = 0;
for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; }
out[0] = cx / (n >> 1); out[1] = cy / (n >> 1);
} else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) {
const b = slot.bone;
out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY;
} else return false;
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
}
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
@@ -91,11 +126,12 @@ export class SpineActor {
this.names = new Set((spineData.animations || []).map((a) => a.name));
/**
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
* are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them
* share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177).
*/
this.clipped = hasClipping(spineData);
this.clipOn = true;
this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback)
if (this.clipped) {
const sp = this.spine;
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
@@ -126,6 +162,8 @@ export class SpineActor {
*/
this.clipPerAttack = false;
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
this._play(this._idleName(), true);
}
@@ -134,6 +172,7 @@ export class SpineActor {
on = !!on;
if (!this.clipped || on === this.clipOn) return;
this.clipOn = on;
if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback
for (const slot of this.spine?.skeleton?.slots || []) {
if (!slot.clippingContainer) continue;
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
@@ -200,6 +239,41 @@ export class SpineActor {
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
}
/**
* Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's
* stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its
* strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's
* clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`).
*/
syncFormPose() {
if (this.dead) return;
if (this.mode === 'attack') {
const track = this.spine.state.tracks[0];
const clip = this._attackClip();
if (!track || !clip) return;
const dur = this.dur(clip.loop);
const oldHit = this._hitTime(this.current, this.dur(this.current));
const hit = this._hitTime(clip.loop, dur);
const start = clampN(track.trackTime + hit - oldHit, 0, dur);
const lead = this.wound && this.windUntil != null
? Math.max(0, this.windUntil - this.clock) : 0;
const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale;
const tailTs = lead > 0 ? this.windTs : ts;
this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 });
this.attackUntil = this.clock + lead +
Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs;
} else if (this.mode === 'stun') {
const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName();
this._play(name, true, { mix: 0 });
} else if (this.mode === 'base') {
this._play(this._baseName(), true, { mix: 0 });
} else {
return;
}
// Apply the new attachments even when normal updates are frozen.
this.spine.update(0);
}
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
_change(clip) {
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
@@ -240,6 +314,7 @@ export class SpineActor {
if (start) e.trackTime = start;
}
this.current = name;
this._applied = false;
return true;
}
@@ -247,6 +322,7 @@ export class SpineActor {
if (!this.has(name)) return false;
const e = this.spine.state.addAnimation(0, name, loop, 0);
if (e) e.timeScale = timeScale;
this._applied = false;
return true;
}
@@ -332,9 +408,13 @@ export class SpineActor {
this.windUntil = null;
this.wound = false;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 });
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the
// strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip):
// then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
const whole = per && this.interval >= loopDur;
if (whole) this.attackUntil = this.clock + loopDur / ts;
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
} else if (single) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
@@ -474,6 +554,24 @@ export class SpineActor {
return 0;
}
/**
* True while an update would draw the same pixels: a frozen model, or a dead one whose only clip — track 0, not
* looping, not mixing — has played out (the held end of a death clip; a skeleton without one holds its idle at
* timeScale 0). Only once an update has posed the current clip and put the current tint on the slots (pixi-spine applies
* `tint` in update: the grey of a knocked-out operator, a hit flash). Never while a form's closing clip or a wind-up is
* pending: those act on the clock.
*/
poseHeld() {
if (!this._applied || this.spine.tint !== this._appliedTint || this.endClip || this.windUntil != null) return false;
if (this.frozen) return true;
if (this.mode !== 'die') return false;
const tracks = this.spine.state.tracks;
for (let i = 1; i < tracks.length; i++) if (tracks[i]) return false;
const e = tracks[0];
if (!e || e.loop || e.mixingFrom) return false;
return e.timeScale === 0 || e.trackTime >= e.animationEnd - e.animationStart;
}
/** Revive (redeploy after death). */
revive() {
this.dead = false;
@@ -546,6 +644,58 @@ export class SpineActor {
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
}
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
this._applied = true;
this._appliedTint = this.spine.tint;
}
/**
* The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the
* clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after
* it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks
* are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through
* the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw
* order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is
* hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per
* slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update.
*/
_eyeMaskFallback() {
const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers;
if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return;
this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not)
try {
const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] });
let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing)
for (const slot of skel.drawOrder) {
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
if (isClip(att)) {
const n = att.worldVerticesLength | 0;
if (buf.poly.length < n) buf.poly = new Float32Array(n);
if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2);
pn = n >> 1;
end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null;
continue;
}
if (pn < 0) continue;
const box = boxes[slot.data.index];
if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) {
box.visible = false;
this._clipHidden.add(slot.data.index);
}
if (end && slot.data === end) { pn = -1; end = null; }
}
} catch { /* an unknown runtime shape must never stop the frame */ }
}
/** Show the slot containers the fallback hid (those still drawing an attachment). */
_showClipHidden() {
if (!this._clipHidden.size) return;
const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers;
for (const i of this._clipHidden) {
const slot = slots && slots[i];
if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true;
}
this._clipHidden.clear();
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
+5
View File
@@ -60,9 +60,14 @@ export const COLORS = Object.freeze({
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
// the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP
hpNeg: 0xff4b3e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
ammo: 0xffd04a,
wolf: 0xe8f0ff,
shield: 0xdfe8ff,
});
+31
View File
@@ -7,6 +7,7 @@
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients,
// * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc),
// and the ring's labels (downLabel: one rasterised Text per label, shared by every ring),
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
// globalThis.PIXI and a DOM canvas at call time (never at import time).
@@ -1424,3 +1425,33 @@ export function ringArc(frac) {
if (k === 0 && f > 0) k = 1;
return r.arcs[k];
}
/** Redeploy-ring label style (the seconds left, "DP", "!"); `fill` is set per label. */
export const DOWN_LABEL_STYLE = Object.freeze({ fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
const _downLabels = new Map();
/**
* The texture of a redeploy-ring label: one rasterised PIXI.Text per (label, fill, resolution), shared by every ring
* that shows it (render/units.js _updateDownRing draws it as a Sprite, anchor 0.5). A ring's own Text re-rasterised its
* canvas and re-uploaded its texture on every change, once a second per knocked-out operator, though rings mostly show the
* same few numbers; the shared one is drawn once and kept, the same pixels. At most 80 counts (the longest respawn in
* chess.json) + "DP" + "!" per resolution, ≈ 2.5 MB at resolution 2.
*/
export function downLabel(label, fill, resolution = 1) {
const key = `${label}|${fill}|${resolution}`;
let t = _downLabels.get(key);
if (!t) {
t = new (PIXI().Text)(label, { ...DOWN_LABEL_STYLE, fill });
t.resolution = resolution;
t.updateText(false);
_downLabels.set(key, t);
}
return t.texture;
}
/** Redraw the shared ring labels in place (web fonts that landed after some were drawn); their textures stay the same. */
export function refreshDownLabels() {
for (const t of _downLabels.values()) {
try { t.updateText(false); } catch { /* keep the old glyphs */ }
}
}
+306 -32
View File
@@ -67,7 +67,7 @@
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, downLabel, HUD_DISC, ELEMENT_RING } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
@@ -110,6 +110,16 @@ export function dieClipDur(entry) {
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
return Number.isFinite(d) && d > 0 ? d : 0;
}
/**
* render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a
* device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive
* again (redeployed without an event) stands up first.
*/
export function syncView(v, s, t) {
if (v.alive || v.info?.kind === 'device') v.sync(s, t);
else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); }
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
@@ -117,6 +127,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat
* 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a
* frozen gate. */
const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']);
/**
* Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽"
* (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the
* damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable.
*/
const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']);
const ICE_FRAME = 0x9fe6ff;
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
@@ -161,6 +177,34 @@ const RAISED_Z = 0.12;
* ride the body (`hover`); range highlights are tiles.
*/
export const FLY_HOVER = 1.3;
/**
* A push / pull slide (推拉, PR #380 by @xcdoge). The sim displaces at once — server/sim/battle/displacement.js walks its
* 0.1-tile steps inside one call — and then holds the enemy in its 失衡 (UNBALANCE) state (PR #392's idea): a push for the
* 位移时间 of its 受力等级 (PRTS 游戏数据基础 推力-位移近似对应表: 0.2 … 35/30 game s), a pull for its force window (推与拉:
* 0.5 / 1 s). The snapshots only ever show the destination, so the view slides there under a constant deceleration
* (UnitView.slideTo) for that same span: the `displace` fx carries it (`dur`, game seconds) and the view divides it by the
* playback rate (ctx.animRate — 2 game s per real second by default: a 0.8 s push slides 0.4 real s) — it lands as the
* sim's state ends. v0 = 2D/T, a = v0/T then run the distance in exactly T. DISPLACE_SLIDE (real s per √tile, kept in
* 0.12–0.45 s) is only the fallback for an fx without `dur` (a recording made before 0.2.2) [ASSUMED].
*/
export const DISPLACE_SLIDE = 0.14;
/**
* Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode
* is normal land (its 11 maps have no ice or slime), so it is always 1.
*/
export const SLIDE_FRICTION = 1;
/**
* Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps
* its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in
* `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md).
*/
const DIE_FADE_TAIL = 0.55;
/**
* Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234
* enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip
* out (PR #380). It only bounds a view whose data lies.
*/
export const DIE_CLIP_MAX = 8;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
@@ -184,12 +228,26 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
* (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide;
* - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px
* below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha).
* [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the
* official HUD art of neither is in the game data.
*/
export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 });
export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 });
/**
* Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm —
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
* once first:
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
* - 假想敌:骨刺 (GitHub #296, PR #365): not an fx — its form follows the snapshot's stealth bit (STEALTH_FORMS): the
* manifest's *_A (the three-headed snake) while its 隐匿 is on, 'revealed' *_B the moment it is blocked or revealed, *_A
* again when the 隐匿 is back; no change clip, a running attack / stun pose carried over (SpineActor.syncFormPose);
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
* Attack clip);
@@ -224,6 +282,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
*/
/**
* Enemy forms that follow the snapshot's stealth bit (UF.STEALTH: the sim sends it only while the enemy's 隐匿 is on —
* not blocked, not revealed) instead of a sim fx, per Spine id: the FORMS kind drawn while the bit is OFF; the manifest's
* clips while it is on. Switched at once (UnitView.sync, and for a model loaded later the load callback).
*/
export const STEALTH_FORMS = Object.freeze({ enemy_9008_acbunn: 'revealed' });
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
const clipSet = (idle, move, die, attack = null) => Object.freeze({
idle, deploy: idle, die, move: loop(move),
@@ -274,6 +338,9 @@ export const FORMS = Object.freeze({
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
}),
enemy_9008_acbunn: Object.freeze({
revealed: Object.freeze({ change: null, roles: clipSet('Idle_B', 'Move_B', 'Die_B', 'Attack_B') }),
}),
enemy_10081_mpplai: Object.freeze({
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
@@ -393,6 +460,7 @@ export class UnitView {
this.tier = clamp(Number(info.tier) || 1, 1, 6);
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies)
this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update)
/** @type {number|null} */
this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z
/** @type {number|null} */
@@ -407,6 +475,11 @@ export class UnitView {
this.visFacing = this.isEnemy ? -1 : this.facing;
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
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();
this.alive = true;
@@ -417,7 +490,9 @@ export class UnitView {
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use
this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts)
this._downRing = null; // { disc, track, arc, text } sprites (text: the shared label, textures.js downLabel), built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0;
@@ -457,6 +532,7 @@ export class UnitView {
this.actor = null;
this.spineReady = false;
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start
this._spineBusy = false; // a Spine load of this view is in flight
this._deployPending = false; // deploy() was requested before the Spine model finished loading
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
@@ -578,7 +654,8 @@ export class UnitView {
// no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP
// redeployed: its Back model takes over from the Front model it lay down with)
const swap = !!this.actor;
const deployed = swap && this.alive ? this.actor.deployElapsed() : null;
const deployed = swap && this.alive ? this.actor.deployElapsed() : this._pendingDeployElapsed;
this._pendingDeployElapsed = null;
if (swap) this._dropActor();
this.actor = actor;
this._actorEntry = entry;
@@ -657,6 +734,8 @@ export class UnitView {
this.hpBg = bar(P, h, COLORS.hpBack, 0.85);
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly);
// a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar
this.negFill = bar(P, h, COLORS.hpNeg);
this.shieldBar = bar(P, h, COLORS.shield, 0.95);
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
this.spFill = bar(P, h, COLORS.sp);
@@ -666,7 +745,7 @@ export class UnitView {
this.spGlow.blendMode = P.BLEND_MODES.ADD;
h.addChild(this.spGlow);
// nothing shows until the first HUD update decides (a culled or prep view never draws bars)
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
this.chip = null;
// operators only: summon tokens have no tier (hand and field alike)
if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') {
@@ -734,13 +813,14 @@ export class UnitView {
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y;
this.followSample(s, t);
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
// constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one
// high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it.
const floor = groundZ(this.ctx, s.x, s.y);
// While sliding, the floor is read under the view's own (mid-flight) position, not the destination's.
const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y);
const gz = this.isEnemy ? 0 : floor;
if (this.zTarget == null) this.z = gz;
this.zTarget = gz;
@@ -752,10 +832,23 @@ export class UnitView {
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
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;
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
// a stealth-driven form (STEALTH_FORMS: 假想敌:骨刺) follows this snapshot's stealth bit at once — before a DIE in the
// same snapshot, so it dies in the form it had
const stealthForm = this.isEnemy && this.alive ? STEALTH_FORMS[this.info.spine || this.info.defId] : null;
if (stealthForm) {
const form = this.flags & UF.STEALTH ? null : stealthForm;
if (form !== this.form) {
this.setForm(form);
this.actor?.syncFormPose();
if (this.imp) this.imp.dirty = true;
}
}
if (this.isEnemy && !this.slide?.keepFacing && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
if (this.anim === ANIM.DIE && this.alive) this.die();
if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim());
@@ -763,6 +856,52 @@ export class UnitView {
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
/**
* The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases
* to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or
* snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots),
* so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render).
*/
followSample(s, t) {
const sl = this.slide;
if (!sl) { this.x = s.x; this.y = s.y; }
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
}
/**
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there: `opts.dur` = the
* fx's 失衡 time in game seconds (real seconds = dur / ctx.animRate), else the DISPLACE_SLIDE·√D fallback.
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
* interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood
* (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the
* sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without
* `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push
* put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown.
*/
slideTo(x, y, opts = {}) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
// already on the way there: app.js starts the slide ahead of the event, then the event itself asks again
if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return;
if (this.down) { this.slide = null; return; }
const dx = x - this.x, dy = y - this.y;
const D = Math.hypot(dx, dy);
if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; }
const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION;
// the sim's 失衡 time (game s) at the playback rate; a slippery tile (f < 1) slides longer
const rate = this.ctx.animRate?.() || 1;
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f);
// v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update().
// Push and pull fx carry keepFacing by default: the victim retains its pre-hit facing through the slide. Raw
// displacements have no flag and still face their travel; snapshots take over when the slide ends.
if (this.isEnemy && !opts.keepFacing) this.visFacing = dx < 0 ? -1 : 1;
const v0 = 2 * D / T;
const at = Number.isFinite(opts.at) ? opts.at : -Infinity;
this.slide = {
x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T,
at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, keepFacing: opts.keepFacing === true,
};
}
setFacing(f) {
if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; }
// the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction
@@ -833,6 +972,7 @@ export class UnitView {
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now, kind) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
const once = !!PROJ[kind]?.once;
if (!once) {
@@ -930,6 +1070,7 @@ export class UnitView {
*/
die(instant = false) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
this.alive = false;
this._modelDirty = true;
this._dieForm = this._formSpec();
@@ -937,7 +1078,7 @@ export class UnitView {
if (this.actor && !d) d = this._fallDur();
const rate = this.ctx.animRate?.() || 1;
this.dieT = 0;
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL;
this.dieDur = this.dying;
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
@@ -976,7 +1117,7 @@ export class UnitView {
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; }
return;
}
if (this.alive) this.die(instant);
@@ -997,6 +1138,33 @@ export class UnitView {
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
update(dt, cam, t) {
if (this.destroyed) return;
if (this._pendingDeployElapsed != null) this._pendingDeployElapsed += dt * (this.ctx.animRate?.() || 1);
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
if (this.slide) {
const sl = this.slide;
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
else if (!sl.live) {
this.x = sl.sx; this.y = sl.sy;
// the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the
// destination the fx named
if ((sl.wait += dt) > 0.5) sl.live = true;
} else {
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
const v0 = sl.v;
sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame
// the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short:
// 9 % of a 0.18 s slide at 60 fps, then a jump onto the end)
sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step);
sl.t += step;
if (this.isEnemy && !sl.keepFacing) this.visFacing = sl.ux < 0 ? -1 : 1;
const k = 1 - sl.done / sl.D; // the share of the way still to go
this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k;
// the clock or the spent velocity lands it on the sampled position
if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; }
}
}
const P = this.P;
// the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive)
if (this._modelDirty) { this._modelDirty = false; this._syncModel(); }
@@ -1015,7 +1183,9 @@ export class UnitView {
const d = this.shadowZTarget - this.shadowZ;
this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12);
}
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
// a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in
// CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground
const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0;
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
const s = p.s;
@@ -1029,7 +1199,7 @@ export class UnitView {
} else if (this.dying > 0) {
this.dieT += dt;
this.dying -= dt;
const tail = 0.55;
const tail = DIE_FADE_TAIL;
if (this.dying < tail) alpha *= Math.max(0, this.dying / tail);
if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; }
}
@@ -1083,18 +1253,26 @@ export class UnitView {
this._far = this._far ? s < 60 : s < 52;
if (lvl >= 2 || this._far) interval = Math.max(interval, 2);
}
let clipFlip = false;
if (this.actor.clipped) {
const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true;
const was = this.actor.clipOn;
this.actor.setClipping(clip);
clipFlip = was !== this.actor.clipOn;
if (clip && this.ctx.impostors) interval = Math.max(1, interval);
}
// a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed
// nor redrawn — it would draw the same pixels again; the actor's clock still runs
const held = !clipFlip && this.actor.poseHeld();
if (interval > 0 && this.ctx.renderer) {
this._updateImpostor(sc, flip, tint, animDt, interval);
if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way
this._updateImpostor(sc, flip, tint, animDt, interval, held);
} else {
if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
this.actor.update(animDt);
if (held) this.actor.clock += animDt;
else this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
}
}
@@ -1181,7 +1359,7 @@ export class UnitView {
const prep = this.prep;
const showBars = !prep && this.alive && this.info.kind !== 'item';
const damaged = this.hp < this.maxHp - 0.5;
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
@@ -1202,24 +1380,32 @@ export class UnitView {
this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh;
if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly;
}
// 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one
const neg = showHp ? this.neg : 0;
this.negFill.visible = neg > 0;
if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; }
const shielded = showHp && (this.flags & UF.SHIELD);
this.shieldBar.visible = !!shielded;
if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); }
// SP
const showSp = showBars && !this.isEnemy && this.spMax > 0;
// SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`;
// the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar.
const ammo = !this.isEnemy ? this.ammo : null;
const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo);
this.spBg.visible = this.spFill.visible = showSp;
const spH = Math.max(2, bh * 0.6);
const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6));
let ready = false;
let sy = 0;
if (showSp) {
const active = !!(this.flags & UF.SKILL);
const k = clamp(this.sp / this.spMax, 0, 1);
const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1);
ready = !active && k >= 0.999;
const sy = cy + bh / 2 + spH / 2 + 1.5;
sy = cy + bh / 2 + spH / 2 + 1.5;
this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2;
this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH;
this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this._spY = sy;
}
this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH);
this.spGlow.visible = ready;
if (ready) {
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
@@ -1250,8 +1436,12 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
// 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a
// 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);
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);
@@ -1276,6 +1466,82 @@ export class UnitView {
}
}
/**
* The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round
* magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px
* (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use.
*/
_updateAmmoCuts(show, ammo, x0, y, bw, h) {
const cuts = this.ammoCuts;
const n = show ? ammo[1] : 0;
const step = n > 1 ? bw / n : 0;
const want = step >= AMMO_BAR.minCell ? n - 1 : 0;
while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9));
for (let i = 0; i < cuts.length; i++) {
const c = cuts[i];
c.visible = i < want;
if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; }
}
}
/**
* 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the
* talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row
* takes (0 while hidden) so the element gauge row can sit below it.
*/
_updateWolfPips(show, x0, top, s) {
let r = this._wolfPips;
if (!show) { if (r) r.root.visible = false; return 0; }
const P = this.P;
const [left, max] = this.wolves;
if (!r) {
r = this._wolfPips = { root: new P.Container(), back: [], lit: [] };
this.hud.addChild(r.root);
}
const mk = (list, color, alpha) => {
const d = new P.Sprite(white());
d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha;
r.root.addChild(d);
list.push(d);
};
while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); }
const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal
const side = d / Math.SQRT2;
const cy = top + WOLF_PIPS.pad + d / 2 + 1;
for (let i = 0; i < r.lit.length; i++) {
const on = i < max;
const b = r.back[i], l = r.lit[i];
b.visible = l.visible = on;
if (!on) continue;
const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap);
b.position.set(cx, cy); b.width = b.height = side + 2;
l.position.set(cx, cy); l.width = l.height = side;
l.alpha = i < left ? 1 : WOLF_PIPS.dim;
}
r.root.visible = true;
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;
@@ -1345,10 +1611,8 @@ export class UnitView {
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
text.anchor.set(0.5);
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
const text = mk(P.Texture.EMPTY);
r = this._downRing = { root, disc, track, arc, text, label: null, res: 0 };
this.hud.addChild(root);
}
const dn = this.down;
@@ -1358,10 +1622,11 @@ export class UnitView {
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
if (r.label !== label) {
const res = this.ctx.renderer?.resolution || 1;
if (r.label !== label || r.res !== res) {
r.label = label;
r.text.text = label;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
r.res = res;
r.text.texture = downLabel(label, counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'), res);
}
const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size;
@@ -1391,7 +1656,7 @@ export class UnitView {
return this._box;
}
_updateImpostor(sc, flip, tint, animDt, interval) {
_updateImpostor(sc, flip, tint, animDt, interval, held = false) {
const P = this.P;
const atlas = this.ctx.impostors || null;
if (!this.imp) {
@@ -1399,16 +1664,25 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true };
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
}
const imp = this.imp;
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12;
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
// a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it
// culled on and off in step) is still refreshed after 2 intervals at the latest.
const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
const rescale = Math.abs(sc - imp.sc) > imp.sc * 0.12;
// a held pose (SpineActor.poseHeld) keeps the image its slot already shows; only its clock moves on
const still = held && !imp.dirty && !rescale;
if (still) { this.actor.clock += imp.acc; imp.acc = 0; }
const due = !still && (imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval || rescale);
if (due) {
this.actor.update(imp.acc);
imp.acc = 0;
imp.dirty = false;
imp.last = frame;
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;
+4 -2
View File
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
import { LoadoutButton } from './loadout.js';
import { SetupReroll } from '../ui/setupReroll.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
@@ -50,7 +51,7 @@ export function BriefingScreen() {
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
await actions.infoReady();
await actions.infoReady(pub.setupRevision ?? 0);
setBusy(false);
};
@@ -91,6 +92,7 @@ export function BriefingScreen() {
<${MatchInfo} model=${info} />
</section>
</main>
<${SetupReroll} pub=${pub} />
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
@@ -98,7 +100,7 @@ export function BriefingScreen() {
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
disabled=${!!me?.ready || !me || !!pub.rerollVote} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
+94
View File
@@ -0,0 +1,94 @@
// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier
// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and
// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已
// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights
// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither).
// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented
// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js
// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps.
import { html, MicroLabel } from '../ui/components.js';
import { data } from '../data.js';
import { opsOf } from '../ui/loadoutModel.js';
import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i);
const TIERS = CULTIVATE_EFFECTS.map((_, i) => i);
/** Names while effects.json is not loaded (msgids; the data's own names win). */
const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]);
/** The short names a row's select shows (the full official name is its title and the detail's). */
const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]);
/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */
export function cultivateName(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || '');
}
/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */
export function cultivateDesc(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || '';
}
/**
* Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot).
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void,
* note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip
*/
export function CultivationSelects({ charId, ops, onSet, note = null }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
// a setting off the default stands out (mint); the default stays quiet — it is the same on every row
return html`<span class=${cx('lo-cult', cur.changed && 'is-changed', note && 'is-moot')} data-char=${charId} title=${note || undefined}>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__pot', cur.potential !== 6 && 'is-off')}>
<select value=${String(cur.potential)} aria-label=${t('潜能')} data-cult="potential"
onChange=${(e) => set({ potential: Number(e.currentTarget.value) })}>
${POTENTIALS.map((n) => html`<option key=${n} value=${String(n)}>${t('潜能 {n}', { n })}</option>`)}
</select>
</span>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__tier', cur.cultivate !== TIERS.length - 1 && 'is-off')} title=${note ? undefined : cultivateName(cur.cultivate)}>
<select value=${String(cur.cultivate)} aria-label=${t('练度')} data-cult="cultivate"
onChange=${(e) => set({ cultivate: Number(e.currentTarget.value) })}>
${TIERS.map((n) => html`<option key=${n} value=${String(n)}>${t(SHORT_NAMES[n])}</option>`)}
</select>
</span>
</span>`;
}
/**
* The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect,
* the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither.
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props
*/
export function CultivationSection({ charId, ops, onSet, standIn = false }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
return html`<section class=${cx('lo-sec', 'lo-sec--cult', standIn && 'is-moot')} aria-label=${t('潜能与练度')} data-char=${charId}>
<header class="lo-sec__head">
<h3>${t('潜能与练度')}<${MicroLabel}>POTENTIAL<//></h3>
${cur.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('满潜能 · 精英2 Lv.60')}</span>`}
</header>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('潜能')}</span>
<div class="lo-seg lo-seg--pot" role="radiogroup" aria-label=${t('潜能')}>
${POTENTIALS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.potential === n ? 'true' : 'false'}
class=${cx(cur.potential === n && 'is-on')} data-potential=${n} onClick=${() => set({ potential: n })}><span class="num">${n}</span></button>`)}
</div>
</div>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('练度')}</span>
<div class="lo-seg lo-seg--tier" role="radiogroup" aria-label=${t('练度')}>
${TIERS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.cultivate === n ? 'true' : 'false'}
class=${cx(cur.cultivate === n && 'is-on')} data-cultivate=${n} onClick=${() => set({ cultivate: n })}>${cultivateName(n)}</button>`)}
</div>
</div>
<p class="lo-cult__eff" data-cultivate=${cur.cultivate}><b>${cultivateName(cur.cultivate)}</b> <span>${cultivateDesc(cur.cultivate)}</span></p>
<p class="lo-cult__note">${standIn
? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效')
: t('默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。')}</p>
</section>`;
}
+43 -18
View File
@@ -6,15 +6,19 @@
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 /
// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
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';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -38,8 +42,8 @@ function Bonds({ bonds }) {
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
}
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) {
const D = diyData();
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
@@ -73,6 +77,9 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
</span>
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
<${OperatorVoice} charId=${rec.charId} />
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>
<div class="diy-slot__acts">
@@ -101,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>`;
}
@@ -137,13 +146,16 @@ 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('');
const [draft, setDraft] = useState(cur ? { ...cur } : null);
// Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips
// while a picker is open, and a dialog over the picker (导入) still takes Esc first
useEffect(() => {
// while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is
// attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between
// was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass)
useLayoutEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return;
e.preventDefault();
@@ -153,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);
@@ -168,12 +180,20 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
const sel = (charId) => {
if (draft?.charId === charId) return;
setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
};
const lk = ch?.locked || null;
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
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>
@@ -201,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>
@@ -226,7 +249,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
/**
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
* onSet: (slotId: string, pick: any) => void }} props
* onSet: (slotId: string, pick: any) => void, ops?: Record<string, any>,
* onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props
* `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects)
*/
export function DiyPanel(props) {
const [picking, setPicking] = useState(null);
@@ -234,7 +259,7 @@ export function DiyPanel(props) {
}
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) {
const slots = diySlotList(diyData());
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
const tiers = [...new Set(slots.map((s) => s.tier))];
@@ -247,11 +272,11 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
<div class="diy-grid">
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
</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>`;
}
+155 -31
View File
@@ -95,11 +95,11 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
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, ownerBandId, ownDiyRecord, ownStandIn,
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -115,8 +115,9 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
import { keepEarly } from './game/early.js';
import { keepEarly, audioEarly } from './game/early.js';
import { MatchEnded, PausedOverlay } from './game/overlays.js';
import { inspectRange } from './game/range.js';
import { t, tParts } from '../../../shared/i18n.js';
/** Router for the in-match screens. */
@@ -146,6 +147,9 @@ export function GameScreen() {
// ---- match screen ----------------------------------------------------------------------------------------
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
function MatchScreen() {
useDocClass('sp-in-match');
const pub = useStore((s) => s.match.public);
@@ -156,6 +160,12 @@ function MatchScreen() {
const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
const specFacts = useStore((s) => ({
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
isHost: !!s.room && s.room.hostId === s.me.playerId,
}), shallowEqual);
const gd = useGameData();
const hostRef = useRef(null);
@@ -169,6 +179,9 @@ function MatchScreen() {
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [bondsCollapsed, setBondsCollapsed] = useState(false);
const [detail, setDetail] = useState(null); // detail target
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false);
const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false);
@@ -184,6 +197,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -207,7 +221,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -271,7 +287,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -286,6 +302,18 @@ function MatchScreen() {
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
// the bar is folded for itself: the state it returns to is the one that changes.
const readySeen = useRef(null);
useEffect(() => {
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
const change = readyShopFold(readySeen.current, cur);
readySeen.current = cur;
if (!change) return;
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
else setCollapsed(change === 'fold');
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
@@ -335,6 +363,9 @@ function MatchScreen() {
const lastFieldRef = useRef(null);
const viewModeRef = useRef(null);
const snapUnitsRef = useRef(new Map());
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
const deviceUnitsRef = useRef(new Map());
const hudRef = useRef(null);
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
@@ -372,10 +403,27 @@ function MatchScreen() {
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
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
@@ -402,15 +450,24 @@ function MatchScreen() {
const wanted = combat || mode === 'settle' || watchingOther;
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
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);
// 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);
@@ -434,7 +491,7 @@ function MatchScreen() {
if (early && early.length) {
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
view.pushEvents(early);
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
@@ -446,6 +503,9 @@ function MatchScreen() {
}
if (earlySnap) {
view.pushSnapshot(earlySnap);
if (Array.isArray(earlySnap.units)) {
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
}
hudRef.current = snapHud(earlySnap);
setHud(hudRef.current);
}
@@ -498,6 +558,7 @@ function MatchScreen() {
return;
}
view?.pushEvents(msg);
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
@@ -526,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]);
@@ -585,7 +655,7 @@ function MatchScreen() {
let asked = null;
try { asked = new URLSearchParams(globalThis.location?.search || '').get('render'); } catch { asked = null; }
if (asked === 'fallback' || globalThis.__SP_RENDER__ === 'fallback') return;
toast(t('当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)'), 'info', { ttl: 5000 });
toast(t('渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)'), 'info', { ttl: 5000 });
}, [viewKind]);
// phase changes: banners, sounds, resets
@@ -630,7 +700,7 @@ function MatchScreen() {
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
setSel(null);
if (phase !== PHASE.PREP) setRewardMin(false);
@@ -650,6 +720,17 @@ function MatchScreen() {
}, [phase, pub?.round, priv?.board, view]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -707,6 +788,7 @@ function MatchScreen() {
// ---- actions ----------------------------------------------------------------------------------------------
const buy = useCallback((i) => actions.buy(i), []);
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
const askingReady = useRef(false);
@@ -941,13 +1023,24 @@ function MatchScreen() {
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
// press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
const device = L.deviceBattle
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
: deviceInfo(L.terrainStage, row, col);
if (device) {
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'device', device });
return;
}
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
@@ -1014,10 +1107,6 @@ function MatchScreen() {
}, []);
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid);
@@ -1070,16 +1159,10 @@ function MatchScreen() {
useEffect(() => {
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
}, [editable, placeCtx, facing]);
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
live.current.showPrep = showPrep;
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
useEffect(() => {
if (!view || !selRangeKey) return undefined;
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, selRangeKey]);
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
// panel anyway (css z-index), this keeps it visible too
@@ -1121,6 +1204,13 @@ function MatchScreen() {
const t = id != null ? snapUnitsRef.current.get(id) : null;
return t ? { hp: t[3], max: t[4] } : null;
})();
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
// is over — before the first snapshot of a battle nothing is judged)
useEffect(() => {
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
const snap = snapUnitsRef.current;
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
}, [hud, detail]);
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
@@ -1168,6 +1258,14 @@ function MatchScreen() {
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1259,7 +1357,7 @@ function MatchScreen() {
}, [strip.ownerId]);
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
// (the runner's field meta is taken before they deploy); their hand is never sent
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
@@ -1270,7 +1368,7 @@ function MatchScreen() {
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -1313,6 +1411,23 @@ function MatchScreen() {
}
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
// draw their own
const cardRange = !drag && !facing && !pen ? inspectRange({
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
snapshot: snapUnitsRef.current.get(resolved?.unitId),
live: typeof liveStats === 'function' ? liveStats() : liveStats,
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
}) : null;
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
useEffect(() => {
if (!view) return undefined;
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, cardRangeKey, showPrep, field]);
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
// cards read the strip's (ui/watchBonds.js detailBondOwner)
@@ -1321,7 +1436,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1336,7 +1451,8 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} spectator=${spectator} />
live=${liveLpNow} spectator=${spectator}
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
<div class="gm__bonds">
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
@@ -1369,7 +1485,7 @@ function MatchScreen() {
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
barRef=${barRef} offBonds=${offBonds}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
@@ -1405,7 +1521,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
@@ -1416,6 +1532,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
onPick=${async (i) => {
const choiceId = personalChoice.id;
setPersonalBusy({ choiceId, idx: i });
await actions.choice(i, choiceId);
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
}} />` : null}
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
+9
View File
@@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/**
* What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered
* list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not
* showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering
* (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit,
* audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list).
*/
export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []);
+32
View File
@@ -0,0 +1,32 @@
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
import { normDir } from '../../ui/facing.js';
/** Resolve the inspected field unit without granting placement or sale actions. */
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
let row, col, dir;
if (target?.kind === 'piece' && showPrep) {
const e = pieces?.get(target.uid);
if (!e || e.area !== 'board') return null;
row = e.row; col = e.col; dir = normDir(e.piece.dir);
} else if (target?.kind === 'unit' && !showPrep) {
const unit = target.unit;
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
if (unit.area != null && unit.area !== 'board') return null;
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(snapshot ? snapshot[2] : unit.y);
col = Math.round(snapshot ? snapshot[1] : unit.x);
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
} else return null;
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
return grid?.length ? { grid, row, col, dir } : null;
}
+178 -113
View File
@@ -5,12 +5,16 @@
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
// groups.module — lettered tiles without them).
//
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns
// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the
// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules
// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度,
// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认,
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one
// preview for the skills, the 特质 and the rows' skill details (display only: never stored).
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
@@ -22,19 +26,25 @@ 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 } 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';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
opsOf, setOps, resetOps, moduleRecord,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { CultivationSelects, CultivationSection } from './cultivation.js';
import { cultivationCharIds } from '../../../shared/protocol.js';
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';
@@ -140,38 +150,96 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
</span>`;
}
// ---- roster card ---------------------------------------------------------------------------------------------------
// ---- roster rows -----------------------------------------------------------------------------------------------------
//
// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by
// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 —
// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps
// the list). Hook-free (the tests draw it).
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
const modChanged = golden && choice.module !== opt.defaultModule;
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
title=${`${chess.name} · ${skillRec?.name || ''}`}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
</span>
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__kit">
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
<b class="num">${skillLabel(choice.skill)}</b>
</span>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */
function QuickSkill({ m, opt, on, elite, onPick }) {
const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = quickSkillTags(rec);
const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--skill', on && 'is-on')} data-skill=${opt.index} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.index)}>
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="q" />
</button>`;
}
/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */
function QuickModule({ m, opt, on, onPick }) {
const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--mod', on && 'is-on')} data-module=${opt.id} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.id)}>
<${ModuleGlyph} m=${m} rec=${opt.rec} id=${opt.id} size="q" />
${opt.id !== MODULE_NONE ? html`<b class="lo-q__type num" aria-hidden="true">${moduleBadge(opt.rec, opt.id)}</b>` : null}
</button>`;
}
/** The list's column heads (the rows share its grid). */
export function RosterHead() {
return html`<div class="lo-list__head" aria-hidden="true">
<span class="lo-list__h lo-list__h--op">${t('干员')}</span>
<span class="lo-list__h lo-list__h--skills">${t('技能')}</span>
<span class="lo-list__h lo-list__h--mods" title=${t('模组仅在精锐形态生效')}>${t('模组')}<small>${t('精锐')}</small></span>
<span class="lo-list__h lo-list__h--cult">${t('潜能')} · ${t('练度')}</span>
</div>`;
}
/**
* One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens
* the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects.
* `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the
* 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again.
*/
export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const cv = opsOf(ops, chess.charId);
const changed = choice.changed || cv.changed;
const modules = [...opt.moduleOptions].sort((a, b) => Number(b.id === MODULE_NONE) - Number(a.id === MODULE_NONE));
const elite = level === 'elite' && !!golden;
const slots = [0, 1, 2].map((i) => opt.skillOptions[i] || null);
const standInNote = notOwned ? t('未持有(干员持有):由替补干员上场,替补干员没有潜能与练度') : null;
return html`<div role="listitem" data-chess=${chess.chessId} data-variant=${elite ? 'elite' : 'normal'}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', changed && 'is-changed', notOwned && 'is-standin')}>
<button type="button" class="lo-card__pick" aria-pressed=${selected ? 'true' : 'false'} title=${chess.name} onClick=${() => onPick(chess.chessId)}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
</span>
<span class="lo-card__id">
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__bonds">${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond"
alt=${data.lookup('bonds', b)?.name || b} fallback=${html`<i class="lo-bond__dot" title=${data.lookup('bonds', b)?.name || b}></i>`} />`)}</span>
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')}>${t('替补')}</span>` : null}
</span>
</button>
${changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
<div class="lo-card__skills lo-quick" role="group" aria-label=${t('选择技能')}>
${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill}
onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html`<span key=${`e${i}`} class="lo-q lo-q--empty" aria-hidden="true"></span>`))}
</div>
<div class="lo-card__mods lo-quick" role="group" aria-label=${t('选择模组')} title=${t('模组仅在精锐形态生效')}>
${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module}
onPick=${(module) => onChange(chess.chessId, { module })} />`) : null}
</div>
<${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} />
</div>`;
}
// ---- detail --------------------------------------------------------------------------------------------------------------
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
export function LoadoutGarrisons({ chess, m }) {
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
</section>` : null;
}
function SkillOption({ m, opt, on, level, onPick }) {
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = skillTags(rec);
@@ -198,9 +266,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
</button>`;
}
export function ModuleInfo({ m, golden, opt }) {
/**
* The chosen module's card: its stat bonus, the 特性 it fights with, its talent changes — read at the operator's 潜能
* (`potential`, 0.2.2: the elite composed at it first, shared/potential.js atPotential, as 局内数值 and the battle do; the
* data record is the full-potential one — 余's 闲云隐市 「生命上限2%(+0.5%)」 is 「1.5%」 at 潜能1). null = full potential.
* @param {{ m: any, golden: any, opt: { id: string, rec: any, isDefault?: boolean }, potential?: number|null }} props
*/
export function ModuleInfo({ m, golden, opt, potential = null }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
const at = atPotential(golden, potential);
const rec = opt.id === MODULE_NONE ? null : at === golden ? opt.rec : moduleRecord(at, opt.id) || opt.rec;
if (!rec) {
const traitBase = golden.traitBase || null;
return html`<div class="lo-minfo lo-minfo--none">
@@ -227,67 +302,19 @@ export function ModuleInfo({ m, golden, opt }) {
</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
* @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) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess);
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 }) {
const pv = statsPreview(base, golden, entries, level, getChess);
if (!pv) return null;
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('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
const [level, setLevel] = useState('normal');
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
const setLevel = onLevel;
const bodyRef = useRef(null);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const changed = choice.changed || opsOf(ops, chess.charId).changed;
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
@@ -297,7 +324,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
${changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
@@ -307,9 +334,12 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<${OperatorVoice} charId=${chess.charId} />
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
<section class="lo-sec">
<header class="lo-sec__head">
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
@@ -323,7 +353,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} ops=${ops} level=${statLevel} onLevel=${setStatLevel} />
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
@@ -341,7 +371,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} potential=${opsOf(ops, chess.charId).potential} />` : null}
</section>` : null}
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
@@ -433,7 +463,7 @@ export function DataMissing({ files }) {
/** The overlay screen. */
function LoadoutScreen({ st }) {
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons', 'effects');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
@@ -448,14 +478,16 @@ function LoadoutScreen({ st }) {
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
}, [ready, roster]);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond, st.ops);
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
const nChanged = changedCount(st.entries, getChess);
const nChanged = changedCount(st.entries, getChess, st.ops, roster);
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
const gridRef = useRef(null);
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
// the detail's 普通 / 精锐 skill toggle: one preview for its skills, its 特质 and the rows' skill details (display only)
const [previewLevel, setPreviewLevel] = useState('normal');
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
@@ -479,12 +511,30 @@ function LoadoutScreen({ st }) {
if (ok) setNotOwned([]);
};
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
// a row's quick choice (PR #301): the same setChoice / setEntries path; the row becomes the selection, a phone stays on the list
const quickChange = (id, patch) => {
const recs = recordsOf(id, getChess);
setEntries(setChoice(loadoutStore.get().entries, recs.base, recs.golden, patch));
loadoutStore.set({ sel: id });
};
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
// 0.2.2: an operator's 潜能 / 练度 (by charId: its normal, elite and 自选 forms share them)
const setOpsOf = (charId, patch) => setOpsMap(setOps(loadoutStore.get().ops, charId, patch));
const resetOne = () => {
if (!base) return;
setOpsMap(resetOps(loadoutStore.get().ops, base.charId));
setEntries(resetChoice(loadoutStore.get().entries, base.chessId));
};
const resetAll = async () => {
if (!nChanged) return;
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (ok) setEntries({});
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (!ok) return;
// the roster's operators only: a 自选 pick's settings belong to the 自选编队 tab
const rosterChars = new Set(roster.map((c) => c.charId));
const keep = {};
for (const [id, e] of Object.entries(loadoutStore.get().ops || {})) if (!rosterChars.has(id)) keep[id] = e;
setOpsMap(keep);
setEntries({});
};
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
@@ -495,7 +545,7 @@ function LoadoutScreen({ st }) {
const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries, { ops: loadoutStore.get().ops }) });
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => {
const ok = await copyText(ioText);
@@ -536,13 +586,17 @@ function LoadoutScreen({ st }) {
}
const res = parseImport(ioText);
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
const { applied, dropped } = r;
const nOps = r.ops ?? 0;
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
if (!applied && !nOps) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
setIo(null);
toast(dropped
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
toast(!applied ? t('已导入 {n} 名干员的潜能与练度', { n: nOps })
: dropped ? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
};
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
@@ -554,8 +608,10 @@ function LoadoutScreen({ st }) {
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) return;
// (a row's quick choices: ←/→ stay with the focused group, PR #301)
if (e.target?.closest?.('.lo-quick') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) return;
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond, loadoutStore.get().ops).map((c) => c.chessId);
if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
@@ -577,7 +633,7 @@ function LoadoutScreen({ st }) {
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整');
// 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
@@ -631,20 +687,25 @@ function LoadoutScreen({ st }) {
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} ops=${st.ops} onOps=${setOpsOf} />`
: tab === 'ownership'
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
<div class="lo-list" ref=${gridRef}>
<${RosterHead} />
<div class="lo-list__rows" role="list" aria-label=${t('干员列表')}>
${list.length ? list.map((c) => html`<${RosterRow} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} ops=${st.ops} selected=${c.chessId === selId} onPick=${pick} onChange=${quickChange} onOps=${setOpsOf}
level=${previewLevel} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
</div>
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} ops=${st.ops} onChange=${change} onOps=${setOpsOf} onReset=${resetOne} locked=${locked}
level=${golden ? previewLevel : 'normal'} onLevel=${setPreviewLevel}
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
</div>
</main>`}
@@ -711,11 +772,13 @@ export function LoadoutHost() {
/**
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
* download). Review fix: it counted stale entries the screen does not.
* download). Review fix: it counted stale entries the screen does not. With `ops` (0.2.2) the operators whose 潜能 / 练度
* differ count too (before chess.json: the stored entries plus the stored settings, an operator possibly twice).
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
* @param {Record<string, any>|null} [ops] @param {any[]|null} [roster] rosterOf(…) of the loaded data
*/
export function badgeCount(entries, getChess) {
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
export function badgeCount(entries, getChess, ops = null, roster = null) {
return getChess ? changedCount(entries, getChess, ops, roster) : Object.keys(entries || {}).length + Object.keys(ops || {}).length;
}
/**
@@ -725,9 +788,11 @@ export function badgeCount(entries, getChess) {
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const ops = useStore((s) => s.ops, Object.is, loadoutStore);
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
const chessReady = data.status('chess') === 'ready';
const n = badgeCount(entries, chessReady ? (id) => data.lookup('chess', id) : null, ops, chessReady ? rosterOf(data.list('chess')) : null);
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
const off = Array.isArray(notOwned) ? notOwned.length : 0;
// 0.2.0 自选编队: how many DIY slots the player filled
+16 -5
View File
@@ -11,11 +11,15 @@
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { PwaInstallButton } from '../ui/device.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
@@ -305,21 +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">
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${ResumeMatchButton} />
<${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>
+15 -4
View File
@@ -103,12 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</article>`;
}
/** RESULT screen. */
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
export function ResultScreen() {
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const hasRoom = useStore((s) => !!s.room);
const back = () => store.set({ match: emptyMatch() });
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
}
/**
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
*/
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
const gd = useGameData();
const r = normalizeResult(res, pub);
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
@@ -118,8 +128,7 @@ export function ResultScreen() {
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
if (top && r.players.length > 1) best[k] = top.id;
}
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const back = () => store.set({ match: emptyMatch() });
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const boss = r.bossId ? gd.boss(r.bossId) : null;
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
@@ -131,6 +140,7 @@ export function ResultScreen() {
<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>
@@ -149,8 +159,9 @@ export function ResultScreen() {
<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=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
</footer>
</section>
<section class="result__players">
+39 -3
View File
@@ -1,6 +1,6 @@
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
// copy code / copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
@@ -19,6 +19,8 @@ import {
import { toast, toastError } from '../ui/toasts.js';
import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
@@ -69,6 +71,19 @@ export function roomFacts(room, myId) {
};
}
/**
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
* @param {any} room room.state payload
* @param {any} myId
* @returns {{ on: boolean, editable: boolean } | null}
*/
export function aiLastOption(room, myId) {
if (!room || room.mode === 'solo') return null;
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
@@ -189,6 +204,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
</div>`;
}
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
function AiLastToggle({ option, busy, onToggle }) {
if (!option) return null;
return html`<div class="ailast">
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
onClick=${() => option.editable && onToggle(!option.on)}>
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
</button>
<//>
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
@@ -229,6 +259,7 @@ export function RoomScreen() {
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
@@ -277,6 +308,8 @@ export function RoomScreen() {
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} />
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
@@ -307,7 +340,10 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<div class="room-bar__opts">
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
</div>
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
+360
View File
@@ -0,0 +1,360 @@
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
//
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
// 评语 (titles) with their counts; what counts and what was left out
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
// when it did not count, 不计入统计
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog.
// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never
// downloads them): the overview reads config / bands / assets only.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
import { useGameData, makeLookups, BandIcon, Img } from '../ui/gameComponents.js';
import { data, useData } from '../data.js';
import { fmtNum } from '../ui/gameLogic.js';
import { titleIconUrl } from '../ui/assetUrls.js';
import { useDocClass } from '../ui/device.js';
import { createStore, useStore, selectRoute } from '../store.js';
import { ResultView } from './result.js';
import { toast, toastError } from '../ui/toasts.js';
import {
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, countsTowardStats, selfRowOf, roundsOf,
recordToResult, MAX_RECORDS, FULL_KEEP, IMPORT_MAX_CHARS,
} from '../ui/stats.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Rows of 对局记录 shown at first and added by each 显示更多. */
export const HISTORY_PAGE = 50;
const statsStore = createStore({ open: false, tab: 'overview' });
/** Open the stats viewer (title screen / lobby / room entry points). */
export function openStats() {
data.load('config');
data.load('bands');
data.load('assets');
statsStore.set({ open: true, tab: 'overview' });
}
export const closeStats = () => statsStore.set({ open: false });
const TABS = [
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
];
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
const dateTime = (ms) => {
if (!ms) return '—';
const d = new Date(ms);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
/** Time played: minutes up to 99, then hours with one decimal. */
function playedText(ms) {
const mins = Math.round((ms || 0) / 60000);
return mins < 100 ? t('{n} 分', { n: fmtNum(mins) }) : t('{n} 小时', { n: (mins / 60).toFixed(1) });
}
const difficultyName = (d) => (DIFFICULTY_NAMES[d] ? t(DIFFICULTY_NAMES[d]) : t('未知'));
/** Save `text` as a download. Silent no-op when the browser refuses downloads. */
function downloadText(filename, text) {
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
} catch { /* ignore */ }
}
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
function readFileText(file) {
if (typeof file?.text === 'function') return file.text();
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result ?? ''));
fr.onerror = () => reject(fr.error || new Error('read failed'));
fr.readAsText(file);
});
}
/** `stronghold-stats-20261008-2130.json` */
function exportFilename(now = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-stats-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
}
/** One labelled number card. */
function StatCard({ label, micro, value, tone }) {
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
<b class="st-card__num num">${value}</b>
<span class="st-card__label">${label}</span>
</div>`;
}
function Section({ title, micro, children, class: cls }) {
return html`<section class=${cx('st-sec', cls)}>
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
${children}
</section>`;
}
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
function Overview({ agg, gd }) {
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 }));
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
<div class="st-cards">
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
<${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" />
<${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
</div>
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
<tbody>
${rows.map((d) => {
const v = agg.byDifficulty[d];
return html`<tr key=${d}>
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(d)}</td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num">${pct(v.wins, v.games)}</td>
<td class="num">${fmtNum(v.hiddenCleared)}</td>
</tr>`;
})}
</tbody>
</table></div>` : null}
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
<div class="st-titles">
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
</div>`)}
</div>` : null}
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
<//>`;
}
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
function Bands({ agg, gd }) {
const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins));
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
<tbody>
${rows.map(([bandId, v]) => {
const band = gd.band(bandId);
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
return html`<tr key=${bandId}>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
</tr>`;
})}
</tbody>
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
const SUM_ROWS = [
['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')],
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
];
/** 战斗累计: the self player's sums over the settled matches. */
function CombatSums({ agg }) {
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
${rows.length ? html`<div class="st-sums">
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
function HistoryRow({ rec, gd, onOpen }) {
const self = selfRowOf(rec);
const quit = rec.end === 'quit';
const counted = countsTowardStats(rec);
const won = self ? !!self.victory : !!rec.victory;
const band = self?.bandId ? gd.band(self.bandId) : null;
const rounds = roundsOf(rec);
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
const open = () => onOpen(rec);
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
tabIndex=${view ? 0 : undefined}
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
<td class="st-t">${dateTime(rec.t)}</td>
<td>${difficultyName(rec.difficulty)}</td>
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
</tr>`;
}
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
function History({ records, gd, onOpen }) {
const [shown, setShown] = useState(HISTORY_PAGE);
const rows = records.slice(0, shown);
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
<tbody>${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
</table></div>
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
function Replay({ rec, onBack }) {
const gd = useGameData();
const res = useMemo(() => recordToResult(rec), [rec]);
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
}
/** The overlay, rendered while open (its data hooks run only then). */
function StatsScreen({ tab }) {
const ready = useData('config', 'bands', 'assets');
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
const [stats, setStats] = useState(() => loadStats());
const [replay, setReplay] = useState(null);
const boxRef = useRef(null);
const fileRef = useRef(null);
const replayRef = useRef(null);
replayRef.current = replay;
const inMatch = useStore((s) => selectRoute(s) === 'game');
useDocClass('sp-stats-open');
// a match starting (the host pressed 开始) takes the player to its briefing: the overlay must not sit on top of it
useEffect(() => { if (inMatch) closeStats(); }, [inMatch]);
useEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (replayRef.current) setReplay(null); else closeStats();
};
window.addEventListener('keydown', onKey, true);
const timer = setTimeout(() => boxRef.current?.focus?.(), 30);
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(timer); };
}, []);
const agg = useMemo(() => aggregateStats(stats.records), [stats]);
const reload = () => setStats(loadStats());
const doExport = () => {
try {
const payload = exportStats(stats);
downloadText(exportFilename(), JSON.stringify(payload));
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
} catch (err) {
toastError(err);
}
};
const doFile = async (e) => {
const file = e.currentTarget.files?.[0];
e.currentTarget.value = ''; // allow re-picking the same file
if (!file) return;
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
try {
const raw = JSON.parse(await readFileText(file));
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
reload();
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
} catch (err) {
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
}
};
const doClear = async () => {
const ok = await confirmDialog({
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
});
if (!ok) return;
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
reload();
toast(t('已清空本机统计数据'), 'success');
};
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
</div>`;
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
const empty = !stats.records.length;
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
<div class="st__bg" aria-hidden="true"></div>
<header class="st-top">
<div class="st-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
</div>
<div class="st-top__center">
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
</div>
</div>
<div class="st-top__right">
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
</div>
</header>
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
<main class="st-body" data-tab=${tab}>
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
: html`<${Overview} agg=${agg} gd=${gd} />`}
</main>
</div>`;
}
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
export function StatsHost() {
const { open, tab } = useStore((s) => s, Object.is, statsStore);
if (!open) return null;
return html`<${StatsScreen} tab=${tab} />`;
}
+9 -2
View File
@@ -10,15 +10,17 @@
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
@@ -245,7 +247,10 @@ export function TitleScreen() {
</div>
<div class="title-corner title-corner--tr">
<div>
<${LangToggle} class="title-lang" />
<div class="title-corner__tools">
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${LangToggle} class="title-lang" />
</div>
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
</div>
@@ -283,6 +288,8 @@ export function TitleScreen() {
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<${ResumeMatchButton} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
+3 -1
View File
@@ -17,7 +17,9 @@
// already did.
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench
// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub
// #385); a battle sends no bench.
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
+14 -12
View File
@@ -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;
}
/**
@@ -127,7 +127,7 @@ export function armedCard(armed, sp, o) {
/**
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = props;
@@ -154,10 +154,10 @@ export function ChoiceOverlay(props) {
/**
* The overlay's view (pure: no hooks — test/ui renders it as a function).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
*/
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
@@ -166,8 +166,8 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed;
const order = personal || solo ? [] : sp.order;
const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection
@@ -177,17 +177,18 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${t('机变阶段')} onPointerDown=${onDown}>
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${personal ? t('教鞭选择') : t('机变阶段')} onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<${MicroLabel} tone="mint">${personal ? t('教鞭选择') : t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
<p class="spov__sub">${timed ? (personal ? t('休整期结束时未选择将自动选定') : t('倒计时结束后仍未选定将自动分配')) : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
${personal ? html`<span class="spov__turntxt is-mine">${t('从候选中选择一项战术特训')}</span>`
: mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></span>`}
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
@@ -217,7 +218,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
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>
@@ -232,6 +233,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
</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}
+17 -20
View File
@@ -1,35 +1,32 @@
// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads).
//
// Lives here rather than in a screen: `screens/room.js` imports `screens/loadout.js` (the 干员调配 button), so loadout
// importing the helper back from room would be a cycle.
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
* @param {string} text
* @returns {Promise<boolean>}
*/
// Shared by invite links, loadouts and settings exports, including LAN / insecure contexts.
/** @param {string} text @returns {Promise<boolean>} */
export async function copyText(text) {
try {
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through */ }
} catch { /* use the selected-text fallback */ }
let ta, prev;
try {
const ta = document.createElement('textarea');
prev = document.activeElement;
ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
// Keep the temporary selection inside the active focus trap (#433). The 16 px readonly field avoids iOS zoom
// and its keyboard; do not disable the dialog's focus protection or copy a selection redirected to another field.
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
document.body.appendChild(ta);
const host = prev?.closest?.('[role="dialog"][aria-modal="true"], .guide') || document.body;
host.appendChild(ta);
ta.focus({ preventScroll: true });
ta.select();
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
// navigator.clipboard, so this path is the one iPhones / iPads take)
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
const ok = document.execCommand('copy');
ta.remove();
return ok;
ta.setSelectionRange(0, ta.value.length); // iOS Safari ignores select() alone
if (document.activeElement !== ta || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return false;
return !!document.execCommand('copy');
} catch {
return false;
} finally {
ta?.remove();
if (prev?.isConnected) prev.focus?.({ preventScroll: true });
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ function ProgressList({ list }) {
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
: html`<span class="num">${(p.resolved ?? p.killed) != null && p.total != null ? `${p.resolved ?? p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
}
+37 -11
View File
@@ -55,6 +55,8 @@ export const ICONS = {
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
// 统计 (the stats page's entry): three columns on a baseline — apart from `signal`, the ping bars
chart: { d: 'M4 12h4v7H4zm6-6h4v13h-4zm6 3h4v10h-4zM3 20h18v2H3z' },
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
@@ -74,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')
@@ -386,7 +390,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
// ---- Modal & dialogs ---------------------------------------------------------------------------
const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape
const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape
/**
* Modal dialog (declarative). Esc / backdrop click call onClose.
@@ -397,24 +401,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
const boxRef = useRef(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
useLayoutEffect(() => {
if (!open) return undefined;
const token = {};
modalStack.push(token);
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
const box = boxRef.current;
// The independent guide can open above SettingsModal (its z-index is higher than --z-modal).
const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide');
const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0
&& getComputedStyle(el).visibility !== 'hidden';
const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')]
.filter((el) => el.tabIndex >= 0 && available(el));
const focusFirst = () => (tabbable()[0] || box).focus();
const onKey = (e) => {
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
e.stopPropagation();
closeRef.current();
if (!topmost()) return;
if (e.key === 'Escape' && closeRef.current) {
e.stopPropagation();
closeRef.current();
} else if (e.key === 'Tab') {
const els = tabbable();
const first = els[0] || box, last = els[els.length - 1] || box;
if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
};
const onFocus = (e) => {
if (topmost() && !box.contains(e.target)) focusFirst();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => {
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
el?.focus?.();
}, 30);
window.addEventListener('focusin', onFocus);
const autofocus = box.querySelector('[data-autofocus]');
if (topmost()) {
if (autofocus && available(autofocus)) autofocus.focus();
else focusFirst();
}
return () => {
window.removeEventListener('keydown', onKey);
clearTimeout(t);
window.removeEventListener('focusin', onFocus);
const i = modalStack.indexOf(token);
if (i >= 0) modalStack.splice(i, 1);
prevFocus?.focus?.();
@@ -423,7 +449,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
if (!open) return null;
return html`<div class="modal" role="presentation"
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
style=${width ? `width:${width}` : undefined}>
<div class="modal__stripe" aria-hidden="true"></div>
${title || micro ? html`<header class="modal__head">
+77 -32
View File
@@ -37,7 +37,7 @@
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter, unitPick, unitCultivation } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { abilityRows } from './abilityLines.js';
import { data } from '../data.js';
@@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge, fullTraitText } from './loadoutModel.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
import { tokenVariantFor } from './gameLogic/loadout.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -315,7 +316,7 @@ export function garrisonTypeIconKey(garrison) {
}
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) {
export function GarrisonBlock({ garrison, m }) {
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
<div class="dgarrison__head">
<span class="dgarrison__k">${t('特质')}</span>
@@ -383,7 +384,7 @@ function skillTextNote(sk) {
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : null;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null, cultOpts = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
@@ -395,7 +396,9 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
// still apply (the owner's recall of the official mode, 2026-10-06)
const si = standIn && standIn.standInFor ? standIn : null;
const body = si || chess;
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
// 0.2.2: at the operator's 潜能 / 练度 — the player's settings (`cultOpts.ops`) or a teammate's unit's own
// (`cultOpts.cultivation`); a stand-in has neither
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess, { ...(cultOpts || {}), effects: data.get('effects') });
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || body;
@@ -580,21 +583,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY)
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
}
/**
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
* @param {any} token tokens.json record
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
*/
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
function tokenOwnerId(piece, pieces) {
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
@@ -711,9 +699,36 @@ function TerrainDetail({ terrain }) {
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
}
/**
* A stage device's tip (GitHub #228, PR #229: 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 — the terrain tip's sibling). Opened
* by a tap on the device itself: the game screen resolves it with `gameLogic.deviceInfo` (prep) or `deviceTipAt` (a battle)
* from the stage the board on screen is built from, so the lines carry that stage's own numbers. In a battle the
* crate / turret is a device unit: its live HP (`live` from the battle's own sim, else `snapHp` — the screen reads the latest
* snapshot tuple through `unitId`) draws a HP bar like a unit card's.
* @param {{ name:string, tag:string, lines:string[], facts?:string[], stats?:{k:string,v:any}[] }} device
* @param {{ hp:number, max:number }|null} snapHp
*/
function DeviceDetail({ device, snapHp = null, live = null }) {
const hp = hpOf(live, snapHp);
return html`
<div class="dhead">
<div class="dhead__icon"><${Icon} name="info" /></div>
<div class="dhead__info">
<div class="dhead__chips"><span class="dtag-kind">${device.tag}</span></div>
<h3 class="dhead__name">${device.name}</h3>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
</div>
</div>
<${Section} title=${t('装置机制')} micro="DEVICE">
${device.lines.map((line, i) => html`<p class="dtext" key=${i}>${line}</p>`)}
<//>
${Array.isArray(device.stats) && device.stats.length ? html`<div class="dstats">${device.stats.map((x) => html`<${Stat} key=${x.k} k=${x.k} v=${x.v} />`)}</div>` : null}
${Array.isArray(device.facts) && device.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${device.facts.join(' · ')}</p><//>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain'|'device', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map<number, any>} pieces indexPieces(priv)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
@@ -726,6 +741,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
if (!target) return null;
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
// a stage device (#228): the screen resolved the device's stage entry (gameLogic.deviceInfo / deviceTipAt); in a battle
// `unitId` is the crate / turret unit whose snapshot tuple gives the live HP
if (target.kind === 'device') {
const device = target.device && typeof target.device === 'object' ? target.device : null;
return device ? { type: 'device', device, ...(Number.isInteger(device.unitId) ? { unitId: device.unitId } : {}) } : null;
}
const ownSi = (c) => ownStandIn(c, priv, backups);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
@@ -755,9 +776,14 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1)
const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null;
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
// `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
const tap = target.tap != null ? { tap: target.tap } : null;
// 0.2.2: a teammate's member card reads its unit's 潜能 / 练度 when the row hands it on (`target.cultivation`), else
// the defaults (never the viewer's own settings)
const cv = foreign ? (target.cultivation !== undefined ? { cultivation: target.cultivation } : { ops: null }) : null;
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap };
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null;
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap } : null;
}
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
@@ -776,10 +802,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
else if (c && own?.piece) si = ownSi(c);
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
// m.private.diy like the piece
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
const pick = c && u.diy && typeof u.diy === 'object' ? unitPick(u) : own?.piece ? ownDiyPick(priv, c) : null;
const dr = pick ? diyRecordFor(c, pick, dd) : null;
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
// 0.2.2: another player's unit says its 潜能 / 练度 itself (UnitInfo potential / cultivate); an own piece's unit follows
// m.private.ops (the panel's `cultOpts`)
const cv = own?.piece ? null : { cultOpts: { cultivation: unitCultivation(u) } };
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick, ...cv };
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si, ...cv };
const t = data.lookup('tokens', u.defId) || diyToken(u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId);
@@ -788,6 +817,18 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
return null;
}
/**
* The 选中干员 key of a resolved detail (null: nothing to say). The panel says the line once per opened operator and stays
* mounted while its target changes, so the key carries what identifies the opening: the chess record, the piece / battle
* unit, and the card tap (`tap`, game.js) — two shop / reward cards of one operator (the pool deals duplicates) carry the
* same chess id and no piece, so without it the second card's tap said nothing, nor replaced the first one's line.
* @param {any} detail resolveDetail's result
* @returns {string|null}
*/
export function selectVoiceKey(detail) {
return detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}:${detail.tap ?? ''}` : null;
}
/**
* The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
@@ -795,18 +836,21 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
* (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
* = the defaults; ops: m.private.ops (0.2.2 潜能 / 练度 of the player's own operators and cards — a unit the detail
* resolved as another player's carries its own, `detail.cultOpts`)
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
* 整备期不播干员语音), so the game screen passes its combat flag
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped:
* a piece on the field or in the hand, a shop / reward card, a bond member. The game screen passes true in every phase
* (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」
* for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR)
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, ops = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
// so the key carries what identifies it (its chess record and its piece / battle unit id)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null;
// so the key carries what identifies it (its chess record, its piece / battle unit id, the card tap: selectVoiceKey)
const selectKey = voice && detail?.type === 'chess' ? selectVoiceKey(detail) : null;
// 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name
// and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's
const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null;
@@ -834,11 +878,12 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null}
standIn=${detail.standIn || null} diy=${detail.diy || null} cultOpts=${detail.cultOpts || { ops }} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
${detail.type === 'device' ? html`<${DeviceDetail} device=${detail.device} snapHp=${snapHp} live=${liveNow} />` : null}
</div>
</aside>`;
}
+27 -1
View File
@@ -20,7 +20,8 @@
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
import { pwaInstall } from '../pwa.js';
import { html, Icon, Button } from './components.js';
import { t } from '../../../shared/i18n.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
@@ -162,6 +163,24 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/**
* 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="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) {
const btn = doc?.getElementById?.('rotate-hint-pwa');
if (btn) btn.textContent = t('添加到桌面');
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
@@ -253,6 +272,13 @@ export function installDeviceSupport(win = globalThis) {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
const pwaBtn = doc?.getElementById?.('rotate-hint-pwa');
if (pwaBtn) {
updateRotateHintPwaI18n(doc);
offs.push(pwaInstall.subscribe((available) => { pwaBtn.hidden = !available; }));
on(pwaBtn, 'click', () => { pwaInstall.request(); });
}
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+2 -2
View File
@@ -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). */
+2 -1
View File
@@ -111,7 +111,8 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
].join('');
const confirmIcon = localAsset('ui/battle', 'equip_replace_comfirm_icon');
const swapIcon = localAsset('ui/battle', 'equip_replace_replace_icon');
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
// A focused button owns Enter through its native click, including Cancel and the item options.
return html`<div class="eqr-host" style=${style} onKeyDown=${(e) => { if (e.key === 'Enter' && e.target?.tagName !== 'BUTTON' && chosen && !busy) { e.preventDefault(); onConfirm(chosen.uid); } }}>
<${Modal} open=${true} tone="red" class="eqr" width="min(7.6rem, 96vw)" micro="EQUIPMENT · REPLACE" title=${t('替换装备')} onClose=${onCancel}
actions=${html`
<${Button} variant="secondary" icon="close" class="eqr__cancel" onClick=${onCancel}>${t('取消')}<//>
+18 -5
View File
@@ -13,7 +13,8 @@
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused
// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events.
//
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
@@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
import { facingSwallows } from './gameLogic.js';
import { facingSwallows, facingEnter } from './gameLogic.js';
import { settingsStore } from './settings.js';
import { t } from '../../../shared/i18n.js';
@@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
};
const onPointerCancel = () => { setDrag(null); setDir(null); };
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter
// follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD
// behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it
// opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then
// reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space).
const rootRef = useRef(null);
useEffect(() => {
const onKey = (e) => {
const L = live.current;
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
const k = dirFromKey(e.key);
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
const enter = facingEnter(e, !!L.dir);
if (enter) {
e.preventDefault(); e.stopImmediatePropagation();
if (enter === 'cancel') L.onCancel();
else if (enter === 'commit') L.onCommit(L.bdir);
return;
}
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ }
return () => window.removeEventListener('keydown', onKey, true);
}, []);
const outside = !!drag && !!dir;
const box = half * 2;
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
return html`<div ref=${rootRef} tabIndex="-1" class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
+10
View File
@@ -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);
+78 -16
View File
@@ -132,8 +132,32 @@ function renderPref() {
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
}
function withTimeout(p, ms, what) {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
function abortError(signal) {
return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError');
}
function checkActive(signal) {
if (signal?.aborted) throw abortError(signal);
}
function withTimeout(p, ms, what, signal) {
return new Promise((resolve, reject) => {
let settled = false;
let timer;
const finish = (done, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
done(value);
};
const onAbort = () => finish(reject, abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms);
// Keep a rejection handler attached even when cancellation wins before this promise settles.
Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err));
if (signal?.aborted) onAbort();
});
}
/**
@@ -186,34 +210,55 @@ export function seedAssets(store) {
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback.
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
export async function mountFieldView(host, { signal } = {}) {
checkActive(signal);
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
if (pref !== 'fallback') {
const controller = new AbortController();
const onAbort = () => controller.abort(abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
if (signal?.aborted) onAbort();
let engineView = null;
const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } };
try {
checkActive(controller.signal);
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => {
checkActive(controller.signal);
return null;
});
checkActive(controller.signal);
if (am?.assets && typeof am.assets.ready === 'function') {
opts.assets = am.assets;
seedAssets(am.assets);
}
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal);
checkActive(controller.signal);
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
// A factory that ignores cancellation still belongs to this attempt when it eventually returns.
const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => {
if (controller.signal.aborted) dispose(view); else engineView = view;
return view;
});
const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal);
checkActive(controller.signal);
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`);
return guardView(view, 'engine');
} catch (err) {
controller.abort();
dispose(engineView);
checkActive(signal);
console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err);
// anything the engine left behind in the host goes
try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ }
} finally {
signal?.removeEventListener('abort', onAbort);
}
}
checkActive(signal);
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
@@ -223,29 +268,46 @@ export async function mountFieldView(host) {
*/
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;
mountFieldView(host).then((view) => {
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) => console.error('[field] mount failed', err));
}, (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;
}
+8 -2
View File
@@ -48,7 +48,10 @@ export async function act(t, fields = {}, opts = {}) {
}
export const actions = {
infoReady: () => act('g.infoReady'),
infoReady: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
@@ -64,7 +67,7 @@ export const actions = {
art: (itemUid, row, col, dir) => act('g.art', dir ? { itemUid, row, col, dir } : { itemUid, row, col }),
destroy: (uid) => act('g.destroy', { uid }),
reward: (idx) => act('g.reward', { idx }),
choice: (idx) => act('g.choice', { idx }),
choice: (idx, choiceId) => act('g.choice', choiceId === undefined ? { idx } : { idx, choiceId }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
@@ -72,4 +75,7 @@ export const actions = {
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
};
+7 -7
View File
@@ -33,16 +33,16 @@ export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
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 } from './gameLogic/shop.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 } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.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';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, unitPick, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
+16 -4
View File
@@ -8,6 +8,8 @@
// player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
// 0.2.2: a pick may carry `potential` (the owner's 潜能 of the operator — m.private.ops for the player's own, UnitInfo
// `potential` for another player's unit): the record is composed at it (an owned pick; a prototype has none).
import { diyRecordOf } from '../../../../shared/diy.js';
import { isObj } from './shared.js';
@@ -26,7 +28,10 @@ export function ownDiyPick(priv, chess) {
if (!isObj(chess) || !chess.isDiy) return null;
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
const p = diyPicks(priv)[slot];
return isObj(p) && typeof p.charId === 'string' ? p : null;
if (!isObj(p) || typeof p.charId !== 'string') return null;
// 0.2.2: at the player's 潜能 of that operator (m.private.ops; none set = 6)
const pot = isObj(priv?.ops) && isObj(priv.ops[p.charId]) ? priv.ops[p.charId].potential : null;
return Number.isInteger(pot) && pot >= 1 && pot < 6 ? { ...p, potential: pot } : p;
}
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
@@ -44,10 +49,11 @@ export function diyRecordFor(chess, pick, data) {
if (!isObj(pick) || !isObj(data?.backups)) return null;
let m = CACHE.get(data.backups);
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
const pot = Number.isInteger(pick.potential) ? pick.potential : null;
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}|${pot ?? ''}`;
if (m.has(key)) return m.get(key);
let rec;
try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
try { rec = diyRecordOf(chess, pick, data, { potential: pot }); } catch { rec = null; }
m.set(key, rec);
return rec;
}
@@ -65,10 +71,16 @@ export function ownDiyRecord(chess, priv, data) {
*/
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
if (!isObj(chess) || !chess.isDiy) return null;
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unitPick(unit), data) : null;
return ownDiyRecord(chess, priv, data);
}
/** A unit's 自选 pick with its potential (UnitInfo `diy` + `potential`, 0.2.2), or null. */
export function unitPick(unit) {
if (!isObj(unit) || !isObj(unit.diy)) return null;
return Number.isInteger(unit.potential) ? { ...unit.diy, potential: unit.potential } : unit.diy;
}
/**
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
+14
View File
@@ -1,6 +1,8 @@
// ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js.
import { isObj } from './shared.js';
import { PHASE } from '../../../../shared/constants.js';
import { t } from '../../../../shared/i18n.js';
// ---- 机变 / band draft normalisation ----------------------------------------------------------------------
@@ -73,3 +75,15 @@ export function normalizeSp(sp, players = []) {
cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size,
};
}
/** Adapt only the recipient's current PREP choice; never write it into the public draft. */
export function normalizePersonalChoice(pub, priv, myId) {
const choice = priv?.personalChoice;
if (pub?.phase !== PHASE.PREP || !priv || priv.playerId !== myId || priv.alive === false
|| !choice || choice.round !== pub.round) return null;
return {
...normalizeSp({ family: 'bounty', name: t('教鞭 · 战术特训'), desc: t('请选择一项战术特训'),
cards: choice.cards, turn: myId, order: [myId], picks: {} }),
id: choice.id,
};
}
+5 -2
View File
@@ -22,7 +22,9 @@ export const STATUS_META = Object.freeze({
// ---- snapshots / battle HUD ---------------------------------------------------------------------------------
/**
* HUD numbers from a b.snap: { killed, total, dp, boss } (boss: { hp, max } when present).
* HUD numbers from a b.snap: { killed, resolved, total, dp, boss } (boss: { hp, max } when present).
* `resolved` is the capsule's numerator — knocked out + leaked among the round's own enemies (Battle.leakedInTotal);
* an older snapshot without it falls back to the kill count.
* @param {any} snap
*/
export function snapHud(snap) {
@@ -30,7 +32,8 @@ export function snapHud(snap) {
const n = (v) => (Number.isFinite(v) ? v : null);
let boss = null;
if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) };
return { killed: n(snap.killed), total: n(snap.total), dp: n(snap.dp), boss };
const killed = n(snap.killed);
return { killed, resolved: n(snap.resolved) ?? killed, total: n(snap.total), dp: n(snap.dp), boss };
}
/** Boss HP fraction 0..1 (null when unknown). */
+53 -6
View File
@@ -2,9 +2,19 @@
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
import { cultivationOf, cultivateMul, cultivatedStats, isPotential, isCultivate } from '../../../../shared/potential.js';
import { isObj } from './shared.js';
import { t } from '../../../../shared/i18n.js';
/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------
@@ -12,14 +22,21 @@ import { t } from '../../../../shared/i18n.js';
* The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop
* card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite
* `modules[]`) and with older data (only the default `skill` / `module`).
* 0.2.2: the record (stats, talents) is at the operator's 潜能 and its stats carry the 练度 multiplier — the player's
* settings (`opts.ops`: m.private.ops / the 干员调配 store; missing = 潜能 6, 精英2 Lv.60), or a unit's own
* (`opts.cultivation`: a teammate's UnitInfo — null = none); a 补位 stand-in / prototype 自选 record has neither
* (shared/potential.js cultivationOf). The 练度 needs `opts.effects` (data/effects.json). A composed 自选 record is
* already at its potential (gameLogic/diy.js: the pick's `potential`).
* @param {any} chess the chess record (normal or elite)
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
* missing); null → defaults
* @param {(id: string) => any} [getChess]
* @param {{ ops?: any, cultivation?: { potential?: number|null, cultivate?: number|null } | null, effects?: any }} [opts]
* @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null,
* defaultModule: boolean, changed: boolean, choices: number } | null}
* defaultModule: boolean, changed: boolean, choices: number, record: any,
* cultivation: { potential: number, cultivate: number|null } | null } | null}
*/
export function chessLoadout(chess, loadout, getChess = () => null) {
export function chessLoadout(chess, loadout, getChess = () => null, opts = {}) {
if (!isObj(chess)) return null;
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
let r = { skillIndex: null, moduleId: null };
@@ -43,12 +60,42 @@ export function chessLoadout(chess, loadout, getChess = () => null) {
}
defaultModule = opt.defaultModule == null || id === opt.defaultModule;
}
// the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition,
// shared/loadoutRecord.js): the detail card must show what the sim runs
// 0.2.2: the operator's 潜能 / 练度 (the player's settings, or the unit's own)
const cv = cultivationFor(chess, opts);
// the record the unit fights with (stats / 特性 / talents of the chosen module or none, at its potential — the
// battle's own composition, shared/loadoutRecord.js): the detail card must show what the sim runs
let record = chess;
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ }
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId, potential: cv ? cv.potential : null })) || chess; } catch { /* the record as is */ }
const mul = cv && isCultivate(cv.cultivate) ? cultivateMul(opts?.effects, cv.cultivate) : null;
if (mul && record && record.stats) record = { ...record, stats: cultivatedStats(record.stats, mul) };
return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule,
choices: Math.max(skills.length, opt.skills?.length || 0), record };
choices: Math.max(skills.length, opt.skills?.length || 0), record, cultivation: cv };
}
/**
* The 潜能 / 练度 of a card's record (chessLoadout `opts`): the unit's own (`cultivation`, null = none) or the player's
* settings (`ops`, shared/potential.js cultivationOf — null for a stand-in / prototype 自选 record).
* @returns {{ potential: number, cultivate: number|null } | null}
*/
function cultivationFor(chess, opts) {
if (opts && opts.cultivation !== undefined) {
const c = opts.cultivation;
if (!isObj(c) || !isObj(chess) || chess.standInFor || chess.diyProto) return null;
return { potential: isPotential(c.potential) ? c.potential : 6, cultivate: isCultivate(c.cultivate) ? c.cultivate : null };
}
return cultivationOf(chess, opts ? opts.ops : null);
}
/**
* The 潜能 / 练度 a battle / scouting unit fights at (UnitInfo `potential` — below 6 only — and `cultivate`, 0.2.2): for
* chessLoadout `opts.cultivation`; null when the unit has neither (a stand-in, a prototype 自选 piece, a raw unit).
* @param {any} unit UnitInfo
*/
export function unitCultivation(unit) {
if (!isObj(unit)) return null;
const pot = isPotential(unit.potential) ? unit.potential : 6;
const cult = isCultivate(unit.cultivate) ? unit.cultivate : null;
return cult == null && pot === 6 ? null : { potential: pot, cultivate: cult };
}
/**
+18 -10
View File
@@ -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)));
}
@@ -301,7 +305,8 @@ function unitAllowed(ctx, piece, row, col, owner = null) {
* must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out.
* board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps.
* hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise
* it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack).
* it uses a free slot or one freed by its own summon stack (HAND_FULL when none). hand ← token from the board:
* always (back onto its stack).
* hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap.
* board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile.
* @param {ReturnType<typeof placementContext>} ctx
@@ -334,8 +339,11 @@ export function canPlace(ctx, uid, target) {
if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置'));
return { ok: true, action: 'swap' };
}
// the withdrawn operator goes to another free slot
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i));
// Withdrawal removes the operator's own summon stacks, freeing their hand slots too.
const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => {
const p = ctx.handAt.get(i)?.piece;
return !p || (p.kind === 'token' && p.ownerUid === piece.uid);
});
return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满'));
}
return { ok: true, action: 'swap' };
@@ -363,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') {
+24 -4
View File
@@ -1,20 +1,37 @@
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
import { VOICE_LANGS, sanitizeVoiceOverrides } from '../../voicePrefs.js';
export { VOICE_LANGS } from '../../voicePrefs.js';
import { clamp, isObj } from './shared.js';
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
// ---- settings ------------------------------------------------------------------------------------------------------
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
/**
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
*/
/**
* The steps of 设置 →「文字大小」 (textSize): the interface text root `--t` of css/theme.css — 'sm' is the design's own
* sizes (`--t: 1rem`), the others lift the phone's 40 px root by a floor and grow the desktop gently. Text only: the
* layout root `1rem` (and with it the field camera, the detail card's side and the DOM fallback board) never moves.
*/
export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -23,9 +40,12 @@ export function sanitizeSettings(raw) {
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
keys: sanitizeHotkeys(r.keys),
};
}
+21 -2
View File
@@ -1,4 +1,5 @@
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js.
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt, the shop bar's fold on 准备. Re-exported from
// ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { isObj } from './shared.js';
@@ -139,7 +140,10 @@ export function offerHeader(offer) {
export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return t('尚未就绪');
if (priv.alive === false) return t('你已被淘汰');
if (kind === 'ready') return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
if (kind === 'ready') {
if (priv.personalChoice) return t('请先完成教鞭选择');
return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
}
if (!editable) {
if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
@@ -183,3 +187,18 @@ export function readyFundsPrompt(priv, { ready = true, keptBands = null, autopla
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
};
}
/**
* The shop bar follows the player's ready state (GitHub #138, the owner's decision of 2026-10-07): pressing 准备就绪 folds
* the bar — the board is set, the fight is what to look at —, cancelling it unfolds the bar again. Read from the own CONFIRMED
* state (m.private.ready — a refused 准备 folds nothing) and only a change inside one prep counts: the first state seen (a
* reconnect in the middle of a prep, the prep's start), the new round's reset to not ready and anything outside a prep are
* not a press. The player can still fold / unfold by hand in between.
* @param {{ round: any, ready: boolean }|null|undefined} prev the own prep state seen last (null: none — outside a prep)
* @param {{ round: any, ready: boolean }|null|undefined} cur the own prep state now (null outside a prep)
* @returns {'fold'|'unfold'|null}
*/
export function readyShopFold(prev, cur) {
if (!isObj(prev) || !isObj(cur) || prev.round !== cur.round || !!prev.ready === !!cur.ready) return null;
return cur.ready ? 'fold' : 'unfold';
}
+27 -5
View File
@@ -140,8 +140,8 @@ export function captureHotkey(e) {
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
* text field has the focus.
* own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
* facingEnter). Nothing while Ctrl / ⌘ / Alt is held or a text field has the focus.
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
* @param {any} [keys] the player's map (settings `keys`)
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
@@ -164,14 +164,36 @@ export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
*/
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
/**
* What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
* any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
* wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
* page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
* @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
* @returns {'cancel'|'commit'|'swallow'|null}
*/
export function facingEnter(e, chosen) {
if (e?.key !== 'Enter') return null;
const t = e.target;
const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
if (closest('.fwheel__cancel')) return 'cancel';
if (closest(CONTROL)) return 'swallow';
return chosen ? 'commit' : 'swallow';
}
/**
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
* @param {{ kind?: string }|null|undefined} detail
*/
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184, a stage device: #228) closes on the
// next press of the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that
// opened them)
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain' || detail?.kind === 'device';
/**
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
+189 -3
View File
@@ -1,9 +1,11 @@
// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). Re-exported from
// ../gameLogic.js.
// ui/gameLogic/terrain.js — the tips of a tap on the ground: special terrain tiles (GitHub issue #184: 特殊地形的单击信息
// 提示) and, in the same place, stage devices (阻隔工事 / “双眼皮” / 射击台 / 气流: GitHub #228, PR #229 by @rickylxw —
// deviceInfo / deviceTipAt below). Re-exported from ../gameLogic.js.
//
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
// with their numbers as params.
// with their numbers as params. A device's NAME is a data text (data/stages.json `devices[].name`, localized by the
// data overlay: "Barricade", "'Double Lids'" …), not a msgid.
import { isObj } from './shared.js';
import { t, N_ } from '../../../../shared/i18n.js';
@@ -123,3 +125,187 @@ export function terrainInfo(stage, row, col) {
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
}
// ---- stage device tips (GitHub #228, PR #229 by @rickylxw: the stage DEVICES, not only the tiles) ---------------------
/** The device roles a tap explains; the rest is invisible (盟约寒风, the 沼泽 / 涨潮 controllers) or not fielded this season. */
const DEVICE_TIP_ROLES = Object.freeze(['crate', 'turret', 'platform', 'blower']);
/** Roles the battle spawns as device UNITS (Battle._spawnStageDevices, devices.js spawnTurret): in a battle a tap finds them by unit. */
const DEVICE_UNIT_ROLES = Object.freeze(['crate', 'turret']);
const TIP_TAG = N_('场地装置');
const DIR_NAME = Object.freeze({ UP: N_('上'), DOWN: N_('下'), LEFT: N_('左'), RIGHT: N_('右') });
const round1 = (v) => Math.round(v * 10) / 10;
/**
* What tapping a stage device says — the same contract as TERRAIN_TIPS, but `lines` read the device's own stage entry
* (data/stages.json `stage.devices[]`: stats / skill blackboard / dir / rangeTiles), the very numbers the sim runs
* (`sim/content/devices.js`, `Battle._spawnStageDevices`), so a tip can never disagree with the battle. The prose is ours
* (docs/PLAYING.md wording; PRTS 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 and the sim's behaviour). `stats` is the
* stat grid under the lines (the turret's), `facts` what the tile allows.
*/
const DEVICE_TIPS = Object.freeze({
crate: {
lines: (st, d) => [
t('挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进'),
t('生命 {hp} 点,被摧毁后从场上消失', { hp: param(d.stats?.maxHp, 100) }),
],
facts: () => [t(BUILDABILITY.NONE)], // board.js buildDeployMap: an active crate takes its tile off the deploy map
},
turret: {
lines: (st, d) => {
const bb = isObj(d.skill?.bb) ? d.skill.bb : {};
// devices.js spawnTurret: the same blackboard keys and the same `持续N秒` read from the skill text
const dur = /持续(\d+(?:\.\d+)?)秒/.exec(String(d.skill?.desc ?? ''));
const aspdMax = param(bb.max_attack_speed, Infinity);
const fragMax = param(bb.max_damage_scale, Infinity);
return [
t('自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱'),
t('按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)', {
aspd: param(bb.attack_speed_per_stack, 1),
aspdMax: Number.isFinite(aspdMax) ? aspdMax : '∞',
frag: round1(param(bb.damage_scale_per_stack, 0.001) * 100),
fragMax: Number.isFinite(fragMax) ? round1((fragMax - 1) * 100) : '∞',
}),
dur ? t('脆弱持续 {sec} 秒', { sec: Number(dur[1]) }) : null,
];
},
stats: (st, d) => {
const s = isObj(d.stats) ? d.stats : {};
const out = [{ k: t('生命上限'), v: param(s.maxHp, 100) }];
if (param(s.atk, 0)) out.push({ k: t('攻击'), v: param(s.atk, 0) });
if (param(s.def, 0)) out.push({ k: t('防御'), v: param(s.def, 0) });
const sec = param(s.bat, 0) > 0 ? param(s.bat, 0) * 100 / (param(s.aspd, 100) > 0 ? param(s.aspd, 100) : 100) : 0;
if (sec) out.push({ k: t('攻击间隔'), v: `${round1(sec)}s` });
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
platform: {
lines: () => [
t('高台位装置:地面敌人不能走上这一格'),
t('远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人'),
],
facts: () => [t(BUILDABILITY.RANGED)], // board.js: an active platform makes its tile a 'ranged' deploy tile
},
blower: {
lines: (st, d) => {
// devices.js buildTerrain: the device's own skill blackboard, else stage.special.blower.bb; rangeTiles = its own tile + the lane
const bb = isObj(d.skill?.bb) ? d.skill.bb : (isObj(st?.blower?.bb) ? st.blower.bb : {});
const dir = DIR_NAME[String(d.dir || 'UP').toUpperCase()] || DIR_NAME.UP;
const lane = Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 ? d.rangeTiles.length - 1 : 3;
const out = [t('向前方 {n} 格吹出气流(这一台朝{dir})', { n: lane, dir: t(dir) })];
const eq = param(bb['blower_s_character[equal].atk'], 0);
const op = param(bb['blower_s_character[opposite].atk'], 0);
const mods = [];
if (eq) mods.push(t('朝向与风向相同的攻击力 {v}', { v: pctText(eq) }));
if (op) mods.push(t('相反的 {v}', { v: pctText(op) }));
if (mods.length) out.push(t('部署在气流里的干员:{mods}', { mods }));
const eqM = param(bb['blower_s_enemy[equal].move_speed'], 0);
const opM = param(bb['blower_s_enemy[opposite].move_speed'], 0);
const em = [];
if (eqM) em.push(t('顺风移动速度 ×{v}', { v: round2(1 + eqM) }));
if (opM) em.push(t('逆风 ×{v}', { v: round2(1 + opM) }));
if (em.length) out.push(t('在气流里移动的敌人:{mods}', { mods: em }));
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
});
const round2 = (v) => Math.round(v * 100) / 100;
/** A stage device entry is drawn: `active` when it carries it, else not `hidden` (render/tiles.js _stageDevices, board3d/layout.js stageDevices). */
const deviceDrawn = (d) => (typeof d.active === 'boolean' ? d.active : !d.hidden);
/** The card of stage device `d` standing at (row, col) — `tip` is DEVICE_TIPS[d.role]. */
function deviceCard(stage, d, row, col) {
const tip = DEVICE_TIPS[d.role];
const lines = tip.lines(stage.special, d).filter((s) => typeof s === 'string' && s);
const stats = typeof tip.stats === 'function' ? tip.stats(stage.special, d) : null;
const name = typeof d.name === 'string' && d.name ? d.name : String(d.role);
return {
key: d.role, name, tag: t(TIP_TAG), row, col, lines, facts: tip.facts(stage.special, d).filter(Boolean),
...(stats && stats.length ? { stats } : {}),
};
}
/**
* The tip a tap on the tile (row, col) opens when a stage DEVICE stands on it (GitHub #228, PR #229), or null: an ordinary
* tile — or a device this client does not draw (`active` false: a turret that stays dormant until 机械援助 / a 机变 card
* switches it on, a crate such a card removed) — falls through to `terrainInfo`. Visibility mirrors what the renderers draw
* (`render/tiles.js _stageDevices`, `board3d/layout.js stageDevices`): `active` when set, else not `hidden`. The stage is
* the shown field's (already the player's own `effectiveStage`, so a card-removed crate is not in the way). The device's
* name is its data text (localized by the data overlay); the lines are in the current language (t()).
* @param {{ devices?: any[], special?: any, rows?: string[], tiles?: Record<string, any> } | null | undefined} stage
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
* @param {number} col
* @param {{ roles?: readonly string[] }} [opts] `roles`: only these roles answer (default: the four of DEVICE_TIP_ROLES)
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[], stats?:{k:string,v:string|number}[] } | null}
*/
export function deviceInfo(stage, row, col, { roles = DEVICE_TIP_ROLES } = {}) {
const list = Array.isArray(stage?.devices) ? stage.devices : null;
if (!list || !Number.isInteger(row) || !Number.isInteger(col)) return null;
for (const d of list) {
if (!isObj(d) || !Array.isArray(d.pos) || d.pos[0] !== row || d.pos[1] !== col) continue;
if (!roles.includes(d.role) || !DEVICE_TIPS[d.role] || !deviceDrawn(d)) continue;
return deviceCard(stage, d, row, col);
}
return null;
}
/**
* The tip of a tap on (row, col) while a BATTLE shows the field — the live counterpart of `deviceInfo`: crates and “双眼皮”
* turrets are device UNITS there (kind 'device', one per drawn stage device, `defId` = the device key), so they are
* found by the unit standing on the tile — a turret outside the battle's rect stands on another tile than its stage `pos`
* (devices.js deviceTile) — and what the card says comes from the stage entry of that key. The platforms and blowers are no
* units: they answer by their stage `pos`, as in the prep.
* A destroyed device (HP gone, or no longer in the snapshots once its DIE animation is over) is not there any more: the
* tap belongs to the tile under the wreck (null here, so it falls through to `terrainInfo`). Before the first snapshot of the
* battle has arrived (`snap` empty) a unit counts as standing.
* The result is the same card as `deviceInfo`'s, with `unitId` (the unit, for its live HP: the screen reads `b.snap`
* through it, like a unit card's `snapHp`).
* @param {object|null|undefined} stage the shown field's stage
* @param {number} row @param {number} col
* @param {{ units?: any[]|null, snap?: Map<number, any[]>|null }} battle the field's unit infos (m.field.units) and the
* latest snapshot tuples by unit id ([id, x, y, hp, maxHp, …])
*/
export function deviceTipAt(stage, row, col, { units = null, snap = null } = {}) {
if (!Number.isInteger(row) || !Number.isInteger(col)) return null;
const u = (Array.isArray(units) ? units : []).find((x) => isObj(x) && x.kind === 'device' && Math.round(Number(x.x)) === col && Math.round(Number(x.y)) === row);
if (!u) return deviceInfo(stage, row, col, { roles: DEVICE_TIP_ROLES.filter((r) => !DEVICE_UNIT_ROLES.includes(r)) });
if (snap && snap.size > 0) {
const tuple = snap.get(u.id);
if (!Array.isArray(tuple) || !(tuple[3] > 0)) return null;
}
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const same = list.filter((d) => isObj(d) && d.key === u.defId && DEVICE_UNIT_ROLES.includes(d.role));
const d = same.find((x) => Array.isArray(x.pos) && x.pos[0] === row && x.pos[1] === col) || same[0];
return d ? { ...deviceCard(stage, d, row, col), unitId: u.id } : null;
}
/**
* Fold the device units a battle shows into `map` (UnitInfo by unit id): the field meta's `units` (what stands when the field
* is entered) and the 'spawn' event tuples `['spawn', UnitInfo]` after it — the stage crates and the “双眼皮” turrets come
* into being with the battle's first step (`battleStart`), after the meta of a battle shown from its beginning was taken.
* Only kind 'device' is kept; `deviceTipAt` reads the map's values.
* @param {Map<number, any>} map
* @param {{ units?: any[]|null, events?: any[]|null }} [from]
*/
export function noteDeviceUnits(map, { units = null, events = null } = {}) {
for (const u of Array.isArray(units) ? units : []) if (isObj(u) && u.kind === 'device' && u.id != null) map.set(u.id, u);
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;
}
+61 -13
View File
@@ -23,9 +23,9 @@
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
import { useRef } from '../../vendor/hooks.module.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js';
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
@@ -36,6 +36,45 @@ import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26):
* the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide
* completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency;
* a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed
* seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken.
* @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props
*/
export function SpectatorPill({ spectators, myId, isHost, onRemove }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(null);
const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : [];
// the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator)
useEffect(() => { if (!list.length) setOpen(false); }, [list.length]);
if (!list.length) return null;
const remove = async (playerId) => {
if (!onRemove) return;
setBusy(playerId);
try { await onRemove(playerId); } finally { setBusy(null); }
};
return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')}
onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}>
<span class="specpill__num num">${list.length}</span>
<//>
${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)}
actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<ul class="spec__roster" data-testid="spectator-roster">
${list.map((s) => html`<li key=${s.playerId} class=${cx('spec__row', s.playerId === myId && 'is-me', s.connected === false && 'is-offline')}>
<${Icon} name=${s.connected === false ? 'wifiOff' : 'eye'} class="spec__ico" />
<span class="spec__name">${s.name || t('博士')}</span>
${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${isHost && onRemove ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === s.playerId} data-testid="spectator-remove"
onClick=${() => remove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</li>`)}
</ul>
<p class="spec__hint t-lo">${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}</p>
<//>` : null}`;
}
/**
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
@@ -49,7 +88,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
const frac = bossFrac(boss);
return html`<div class="capsule capsule--boss" role="status">
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.resolved ?? hud.killed ?? 0}</b>/${hud.total}</span>` : null}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : t('敌方领袖')}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? t('敌方领袖') : bossPctText(frac)}</span>
@@ -60,7 +99,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
return html`<div class=${cx('capsule', 'capsule--combat', phase === PHASE.UNITE && 'capsule--unite')} role="status">
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
<span class="capsule__kills num"><b>${hud?.resolved ?? hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">${t('联防')}</span>` : null}
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
</div>`;
@@ -207,24 +246,27 @@ export function tempReadyReason(priv) {
}
/**
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
* Ready toggle (PREP only). Disabled while the temp row holds pieces or a personal choice (教鞭) is open — the reason shows
* under it (not only on hover): "临时整备区 N 个单位待处理" (user playtest #3 item 3), "请先完成教鞭选择".
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready;
const temp = tempInfo(priv);
const reason = !ready ? tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true }) : null;
const choice = !!priv?.personalChoice;
const reason = !ready ? (choice ? shopBlockReason('ready', { priv, editable: true }) : tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true })) : null;
const why = !ready && (choice || temp.count > 0);
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${why ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" /><span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span></span>` : null}
${why ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" />${choice ? html`<span>${reason}</span>`
: html`<span>${tParts('临时整备区 {n} 个单位待处理', { n: html`<b class="num">${temp.count}</b>`, count: temp.count })}</span>`}</span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">${t('已就绪')} <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
@@ -306,13 +348,16 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
* live?: { pending: number, unite: boolean, left?: number|null } | null,
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
config = null, frozenAt = null, pause = null, live = null, spectator = false,
spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
@@ -338,7 +383,10 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
<div class="gtop__left">
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
<div class="gtop__meta">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<div class="gtop__net">
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
<${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
</div>
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
</div>
</div>
+21 -5
View File
@@ -23,6 +23,7 @@ import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { updateRotateHintPwaI18n } from './device.js';
import { html } from './components.js';
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
@@ -39,6 +40,8 @@ const uiLoads = new Map();
let indexLoad = null;
/** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */
let indexOk = false;
/** Only the latest boot or explicit selection may apply its loaded language. */
let langRequest = 0;
const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`;
const defaultFetch = (...a) => globalThis.fetch(...a);
@@ -158,6 +161,7 @@ function applyDocument(lang) {
doc.documentElement.dataset.lang = lang;
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
updateRotateHintPwaI18n(doc);
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
@@ -194,20 +198,23 @@ function applyLang(lang) {
* @returns {Promise<string>} the language in effect
*/
export async function initLang() {
const request = ++langRequest;
wire();
applyDocument(getLang());
const TIMEOUT = Symbol('timeout');
const boot = (async () => {
const listed = await loadLangIndex();
const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed });
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
if (fromUrl) stripLangParam();
if (request !== langRequest) return null;
if (fromUrl) savePref(PREF_KEY, lang);
if (lang === DEFAULT_LANG) return null;
return (await loadLangChain(lang)) ? lang : null;
})();
const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]);
if (got === TIMEOUT) {
boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (got) applyLang(got);
boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (request === langRequest && got) applyLang(got);
return getLang();
}
@@ -218,10 +225,12 @@ export async function initLang() {
* @returns {Promise<string>} the language in effect
*/
export async function switchLang(lang) {
const request = ++langRequest;
wire();
const want = normalizeLang(lang) || DEFAULT_LANG;
savePref(PREF_KEY, want);
if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang();
if (request !== langRequest) return getLang();
applyLang(want);
return getLang();
}
@@ -290,10 +299,17 @@ export function machineTranslationNote() {
export function LangToggle({ class: cls }) {
const lang = useLang();
const menu = langMenuModel(useLangs(), lang);
const pick = (code) => { if (code && code !== getLang()) switchLang(code); };
const pick = async (code, select = null) => {
if (!code) return;
const switched = switchLang(code);
const request = langRequest;
await switched;
// A failed current request keeps the displayed language; stale requests must not reset a newer pending pick.
if (select && request === langRequest) select.value = getLang();
};
if (menu.kind === 'select') {
return html`<label class=${`set-seg lang-toggle lang-select${cls ? ` ${cls}` : ''}`} data-testid="lang-toggle">
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value)}>
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value, e.currentTarget)}>
${menu.items.map((it) => html`<option key=${it.code} value=${it.code} lang=${it.htmlLang} title=${it.title || undefined} selected=${it.on}>${it.label}</option>`)}
</select>
</label>`;
+131 -15
View File
@@ -8,8 +8,13 @@
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
// them only the default skill / module is offered. The option rules are shared with the server
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
// 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08): `ops` = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` —
// per operator, so the normal, elite and 自选 forms share them; only values that differ from the defaults (潜能 6,
// 精英2 Lv.60) are kept; stored beside the entries (`sp.pref.loadout` = { v: 1, entries, ops }; an older stored loadout
// has none = the defaults) and sent with `room.loadout { entries, ops }` (shared/protocol.js checkLoadoutOps).
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { loadoutOptions, checkLoadout, checkLoadoutOps, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
export { MODULE_NONE };
@@ -56,8 +61,95 @@ export function parseStored(raw) {
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
/**
* Parse the stored per-operator 潜能 / 练度 (0.2.2; any junk → {}): structurally valid entries only, defaults dropped.
* An older stored loadout (no `ops`) gives {} — every operator at the defaults.
* @param {any} raw `{ v, entries, ops }`
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function parseStoredOps(raw) {
const src = isObj(raw) && isObj(raw.ops) ? raw.ops : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isPotential(e.potential) && e.potential !== POTENTIAL_DEFAULT) x.potential = e.potential;
if (isCultivate(e.cultivate) && e.cultivate !== CULTIVATE_DEFAULT) x.cultivate = e.cultivate;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage (`ops`: the per-operator 潜能 / 练度, 0.2.2). */
export const toStored = (entries, ops = {}) => ({ v: LOADOUT_VERSION, entries: entries || {}, ops: ops || {} });
// ---- 潜能 / 练度 (0.2.2) ------------------------------------------------------------------------------------------------
/**
* The effective 潜能 / 练度 of operator `charId` under the stored settings (the defaults for a missing entry or field).
* @param {Record<string, any>|null|undefined} ops @param {string|null|undefined} charId
* @returns {{ potential: number, cultivate: number, changed: boolean }}
*/
export function opsOf(ops, charId) {
const e = charId && isObj(ops) && Object.hasOwn(ops, charId) && isObj(ops[charId]) ? ops[charId] : null;
const potential = e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT;
const cultivate = e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT;
return { potential, cultivate, changed: potential !== POTENTIAL_DEFAULT || cultivate !== CULTIVATE_DEFAULT };
}
/**
* Set (part of) one operator's 潜能 / 练度; an entry equal to the defaults is removed. Returns a new map.
* @param {Record<string, any>} ops @param {string} charId @param {{ potential?: number, cultivate?: number }} patch
*/
export function setOps(ops, charId, patch) {
if (typeof charId !== 'string' || !charId) return ops;
const cur = opsOf(ops, charId);
const potential = patch && isPotential(patch.potential) ? patch.potential : cur.potential;
const cultivate = patch && isCultivate(patch.cultivate) ? patch.cultivate : cur.cultivate;
const out = { ...(ops || {}) };
delete out[charId];
const e = {};
if (potential !== POTENTIAL_DEFAULT) e.potential = potential;
if (cultivate !== CULTIVATE_DEFAULT) e.cultivate = cultivate;
if (Object.keys(e).length) out[charId] = e;
return out;
}
/** Remove one operator's 潜能 / 练度 (恢复默认). */
export function resetOps(ops, charId) {
if (!ops || !Object.hasOwn(ops, charId)) return ops;
const out = { ...ops };
delete out[charId];
return out;
}
/**
* The settings to send (`room.loadout.ops`): every stored entry the server would keep for the loaded data (an operator
* of the 干员调配 roster or the 自选 owned pool, `isOperator` — shared/protocol.js cultivationCharIds), the rest dropped one
* by one; defaults dropped.
* @param {Record<string, any>} ops @param {(charId: string) => boolean} isOperator
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function sanitizeOps(ops, isOperator) {
const out = {};
for (const [id, e] of Object.entries(ops || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
const one = {};
if (isPotential(e?.potential)) one.potential = e.potential;
if (isCultivate(e?.cultivate)) one.cultivate = e.cultivate;
if (!Object.keys(one).length) continue;
const res = checkLoadoutOps({ [id]: one }, isOperator);
if (res.ok && res.ops[id]) {
const x = {};
if (res.ops[id].potential !== POTENTIAL_DEFAULT) x.potential = res.ops[id].potential;
if (res.ops[id].cultivate !== CULTIVATE_DEFAULT) x.cultivate = res.ops[id].cultivate;
out[id] = x;
}
}
return out;
}
// ---- export / import ----------------------------------------------------------------------------------------------
@@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
/**
* Portable payload of a loadout, as downloaded / copied by 导出.
* Portable payload of a loadout, as downloaded / copied by 导出 — with the per-operator 潜能 / 练度 (`ops`, 0.2.2; an
* older build reading it ignores the field).
* @param {Record<string, any>} entries `room.loadout.entries`
* @param {{ now?: number }} [o]
* @param {{ now?: number, ops?: Record<string, any> }} [o]
*/
export function exportPayload(entries, { now = Date.now() } = {}) {
export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) {
const clean = {};
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
const cleanOps = {};
for (const [id, e] of Object.entries(ops || {})) if (isObj(e)) cleanOps[id] = { ...e };
return {
kind: LOADOUT_EXPORT_KIND,
v: LOADOUT_VERSION,
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
count: Object.keys(clean).length,
entries: clean,
ops: cleanOps,
};
}
@@ -97,8 +193,10 @@ export function serializeExport(entries, opts) {
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
* A payload with `ops` (0.2.2) brings the per-operator 潜能 / 练度 too (`ops` in the result — absent when the payload has
* none: an older export leaves the current settings alone); a payload of settings only imports.
* @param {any} input payload object or serialised text
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
* @returns {{ ok: true, entries: Record<string, any>, ops?: Record<string, any> } | { ok: false, error: string }}
*/
export function parseImport(input) {
let raw = input;
@@ -115,8 +213,9 @@ export function parseImport(input) {
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
const entries = parseStored(raw);
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
return { ok: true, entries };
const ops = isObj(raw.ops) ? parseStoredOps(raw) : null;
if (!Object.keys(entries).length && !(ops && Object.keys(ops).length)) return { ok: false, error: t('里面没有有效的调配条目') };
return ops ? { ok: true, entries, ops } : { ok: true, entries };
}
// ---- options & choices ---------------------------------------------------------------------------------------------
@@ -281,8 +380,9 @@ export function rosterOf(list) {
* @param {Record<string, any>} entries stored loadout (for changedOnly)
* @param {(id: string) => any} getChess
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
* @param {Record<string, any>|null} [ops] the per-operator 潜能 / 练度 (changedOnly counts them too)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null, ops = null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
@@ -290,7 +390,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
if (f.changedOnly) {
const golden = c.goldenId ? getChess(c.goldenId) : null;
if (!effectiveChoice(entries, c, golden).changed) return false;
if (!effectiveChoice(entries, c, golden).changed && !opsOf(ops, c.charId).changed) return false;
}
if (q) {
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
@@ -302,12 +402,14 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
}
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
* retired / hidden chess is never sent nor applied). */
export function changedCount(entries, getChess) {
* retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */
export function changedCount(entries, getChess, ops = null, roster = null) {
const ids = new Set(Object.keys(entries || {}));
if (ops && Object.keys(ops).length && Array.isArray(roster)) for (const c of roster) if (opsOf(ops, c.charId).changed) ids.add(c.chessId);
let n = 0;
for (const id of Object.keys(entries || {})) {
for (const id of ids) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
if (isLoadoutSlot(base) && base.chessId === id && (effectiveChoice(entries, base, golden).changed || opsOf(ops, base.charId).changed)) n++;
}
return n;
}
@@ -386,3 +488,17 @@ export function skillTags(rec) {
passive,
};
}
/** Compact skill preview; SP and duration come from the selected form's generated record. */
export function quickSkillTags(rec) {
const tags = skillTags(rec);
if (!rec) return { ...tags, recovery: '—', duration: '—' };
// ON_DEPLOY covers constant passives too. A finite duration distinguishes the deploy-and-expire skills;
// some older records carry it only in the blackboard. Never infer it from a localized description.
const seconds = Number(rec.duration) > 0 ? Number(rec.duration) : tags.passive ? Number(rec.bb?.duration) : 0;
const deployment = tags.passive && seconds > 0;
const recovery = deployment ? '—' : tags.passive ? t('被动') : tags.spKind === 'atk' ? t('攻回') : tags.spKind === 'def' ? t('受回') : t('自回');
const duration = rec.durationType === 'AMMO' ? t('弹药') : seconds > 0 ? `${seconds}s`
: tags.passive ? t('常驻') : Number(rec.duration) < 0 ? '∞' : t('瞬发');
return { ...tags, sp: deployment ? t('部署触发') : tags.sp, recovery, duration };
}
Loaded 100 of 577 files, more files were not shown because too many files have changed in this diff. Show more