feedback7: merge fb7-ui-backlog — #229 device tooltips, #238 settings button in lobby / room, #138 准备 folds the shop, #120 in-match spectator roster (cap stays 2), PR #308 capsule counts (+ the shared-field progress fix), PR #333 empty new shop slot on 调度中心 upgrade
- Conflicts: the stats entry (merged fb7-stats) and the settings button both sit in the lobby / room top bars — both kept;
public/js/ui/settings.js (#321 diagnostics, voiceLang, SettingsButton); docs/PLAYING.md table rows resolved line by
line against the merge base (#368, #354, #333 edits kept); docs/SIM.md / design/network.md snapshot field lists
(resolved + ammo / wolves / neg / stand / standCut); design/client.md Screens line (#368's AI switch kept).
- Grok review: MERGE AFTER FIXES — the shared-field capsule finding fixed on the branch (1a880699).
- Golden: roster 23, bonds 2, fields 11, matches 18, diy 11 — #308 totals / snapshots and #333's shop RNG (union OK);
broad node --test 5473 (5472 pass, 0 fail, 1 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
commit
dc609ea13f
71 files changed
+3016
-806
No files matched your search
@@ -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); }
|
||||
|
||||
@@ -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: .19rem; color: var(--text-hi); }
|
||||
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
|
||||
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
||||
.gtop__center {
|
||||
--bk-c: var(--mint-700);
|
||||
@@ -501,6 +516,10 @@
|
||||
@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); }
|
||||
.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; }
|
||||
|
||||
+40
-7
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -706,6 +725,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 +808,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 +822,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));
|
||||
@@ -685,6 +685,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",
|
||||
@@ -781,6 +782,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.",
|
||||
@@ -916,6 +918,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\".",
|
||||
|
||||
@@ -686,6 +686,7 @@
|
||||
" · 未装备模组": " · モジュール未装備",
|
||||
"已购买": "購入済み",
|
||||
"已招募": "獲得済み",
|
||||
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
|
||||
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
|
||||
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
|
||||
"已满级": "最大レベル",
|
||||
@@ -782,6 +783,7 @@
|
||||
"观战席": "観戦席",
|
||||
"移出观战者 {name}": "観戦者{name}を除外",
|
||||
"移出该观战者": "この観戦者を除外",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
|
||||
"入座": "着席",
|
||||
"移出同盟": "同盟から除外",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
||||
@@ -916,6 +918,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}つをすべてクリアしますか?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
" · 未装备模组": " · 모듈 미장착",
|
||||
"已购买": "구매 완료",
|
||||
"已招募": "구매 완료",
|
||||
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
|
||||
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
|
||||
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
|
||||
"已满级": "최대 레벨",
|
||||
@@ -778,6 +779,7 @@
|
||||
"观战席": "관전석",
|
||||
"移出观战者 {name}": "관전자 {name} 내보내기",
|
||||
"移出该观战者": "이 관전자 내보내기",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
|
||||
"入座": "착석",
|
||||
"移出同盟": "동맹에서 내보내기",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
||||
@@ -912,6 +914,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}개를 모두 비우시겠습니까?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "파일이 너무 큽니다. [내보내기]로 다운로드한 자유 편성 파일을 선택해 주세요.",
|
||||
|
||||
@@ -684,6 +684,7 @@
|
||||
" · 未装备模组": " · 未裝備模組",
|
||||
"已购买": "已購買",
|
||||
"已招募": "已招募",
|
||||
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
|
||||
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
|
||||
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
|
||||
"已满级": "已滿級",
|
||||
@@ -780,6 +781,7 @@
|
||||
"观战席": "觀戰席",
|
||||
"移出观战者 {name}": "移出觀戰者 {name}",
|
||||
"移出该观战者": "移出該觀戰者",
|
||||
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
|
||||
"入座": "入座",
|
||||
"移出同盟": "移出同盟",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
||||
@@ -915,6 +917,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} 個自選名額?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,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) {
|
||||
@@ -292,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;
|
||||
@@ -433,6 +441,10 @@ 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);
|
||||
|
||||
@@ -96,10 +96,10 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
battleOverSfx, uniteResultBox, battleResultBox,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
terrainInfo,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
|
||||
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';
|
||||
@@ -146,6 +146,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 +159,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);
|
||||
@@ -289,6 +298,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 || {} };
|
||||
@@ -338,6 +359,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.
|
||||
@@ -375,6 +399,9 @@ 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 pressSel = useRef(null); // the selected piece when the current field press began
|
||||
@@ -405,7 +432,7 @@ 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;
|
||||
@@ -414,6 +441,7 @@ function MatchScreen() {
|
||||
view.enterBattle(field);
|
||||
const early = evBufRef.current.get(field.fieldId);
|
||||
evBufRef.current.delete(field.fieldId);
|
||||
deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
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);
|
||||
@@ -501,6 +529,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
|
||||
@@ -633,7 +662,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);
|
||||
@@ -697,6 +726,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);
|
||||
@@ -931,13 +961,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 });
|
||||
@@ -1110,6 +1151,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
|
||||
@@ -1325,7 +1373,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"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame
|
||||
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 { LoadoutButton } from './loadout.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||
@@ -313,6 +314,7 @@ export function LobbyScreen() {
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -308,6 +309,7 @@ export function RoomScreen() {
|
||||
<${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">
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -711,9 +711,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 +753,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 */
|
||||
@@ -855,6 +888,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
${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>`;
|
||||
}
|
||||
|
||||
@@ -72,4 +72,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' }),
|
||||
};
|
||||
@@ -33,9 +33,9 @@ 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 { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
|
||||
export { normalizeDraft, normalizeSp } 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';
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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';
|
||||
@@ -183,3 +184,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';
|
||||
}
|
||||
@@ -169,9 +169,10 @@ export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || a
|
||||
* 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
|
||||
|
||||
@@ -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,174 @@ 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;
|
||||
}
|
||||
+52
-7
@@ -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>`;
|
||||
@@ -306,13 +345,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 +380,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>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
||||
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
||||
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
|
||||
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded).
|
||||
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
|
||||
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
|
||||
// match have their own gear.
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
@@ -247,3 +250,16 @@ export function SettingsModal({ open, onClose }) {
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
|
||||
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
|
||||
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
|
||||
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props
|
||||
*/
|
||||
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label}<//>
|
||||
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
|
||||
}
|
||||
+13
-1
@@ -183,6 +183,17 @@ function SoldCard({ item = false }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js
|
||||
* _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round
|
||||
* start fills it). Never 已招募 / SOLD OUT: nothing was bought there.
|
||||
*/
|
||||
function EmptyCard({ item = false }) {
|
||||
// `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it
|
||||
return html`<div class=${cx('scard', 'scard--sold', 'scard--empty', item && 'scard--item')} role="img" aria-label=${t('空栏位:刷新或下回合开始时补满')}
|
||||
title=${t('空栏位:刷新或下回合开始时补满')}></div>`;
|
||||
}
|
||||
|
||||
function LevelCard({ shop, reason, armed = false, onTap }) {
|
||||
const lv = shop?.level ?? 1;
|
||||
const max = lv >= (shop?.maxLevel ?? 6);
|
||||
@@ -356,7 +367,8 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
|
||||
: html`<div class="shopbar__cards">
|
||||
${chessSlots.map(({ s, i }) => {
|
||||
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
|
||||
if (!s) return html`<${EmptyCard} key=${`e${i}`} />`;
|
||||
if (s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
|
||||
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
|
||||
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
|
||||
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
|
||||
|
||||
Reference in new issue
Block a user