fix: 高延迟下连按「刷新」只发送一次请求(GitHub #501)
This commit is contained in:
1 parent
bc50cac01f
commit
289a454fa1
10 files changed
+186
-8
No files matched your search
@@ -707,6 +707,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
||||
"解冻": "Unfreeze",
|
||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||
"正在刷新…": "Refreshing…",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"与你在同一战场": "On your field",
|
||||
|
||||
@@ -708,6 +708,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
||||
"解冻": "解除",
|
||||
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
||||
"正在刷新…": "更新中…",
|
||||
"免费 ×{free}": "無料 ×{free}",
|
||||
"查看 {name} 的战场": "{name}の戦場を見る",
|
||||
"与你在同一战场": "あなたと同じ戦場",
|
||||
|
||||
@@ -704,6 +704,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
||||
"解冻": "동결 해제",
|
||||
"刷新商店 · {key}": "상점 갱신 · {key}",
|
||||
"正在刷新…": "갱신 중…",
|
||||
"免费 ×{free}": "무료 ×{free}",
|
||||
"查看 {name} 的战场": "{name}의 전장 보기",
|
||||
"与你在同一战场": "같은 전장",
|
||||
|
||||
@@ -706,6 +706,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
||||
"解冻": "解凍",
|
||||
"刷新商店 · {key}": "更新商店 · {key}",
|
||||
"正在刷新…": "正在更新…",
|
||||
"免费 ×{free}": "免費 ×{free}",
|
||||
"查看 {name} 的战场": "查看 {name} 的戰場",
|
||||
"与你在同一战场": "與你在同一戰場",
|
||||
|
||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { actions, onRefreshBusy } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
@@ -183,6 +183,10 @@ function MatchScreen() {
|
||||
// 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);
|
||||
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
|
||||
// single-flights the intent; the R hotkey is dropped by the same guard)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false);
|
||||
useEffect(() => onRefreshBusy(setRefreshBusy), []);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -1468,7 +1472,7 @@ function MatchScreen() {
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} 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, tap: ++cardTap.current })}
|
||||
|
||||
@@ -20,6 +20,21 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
|
||||
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
||||
|
||||
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
||||
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
||||
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
||||
// call actions.refresh) until the reply lands. ui/shopBar.js subscribes to onRefreshBusy for the dim.
|
||||
let refreshPending = false;
|
||||
const refreshBusyListeners = new Set();
|
||||
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
||||
|
||||
/**
|
||||
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
||||
* @param {(busy: boolean) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
|
||||
|
||||
/**
|
||||
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||
* @param {string} t
|
||||
@@ -55,7 +70,13 @@ export const actions = {
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
refresh: () => act('g.refresh'),
|
||||
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
||||
refresh: () => {
|
||||
if (refreshPending) return Promise.resolve(false);
|
||||
refreshPending = true;
|
||||
emitRefreshBusy();
|
||||
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
||||
},
|
||||
freeze: () => act('g.freeze'),
|
||||
levelUp: () => act('g.levelUp'),
|
||||
sell: (uid) => act('g.sell', { uid }),
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
||||
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
|
||||
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
|
||||
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
|
||||
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
|
||||
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
|
||||
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
|
||||
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
|
||||
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
|
||||
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
|
||||
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||
</button>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||
<span>${t('刷新')}</span>
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||
<kbd>${hk.refresh}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user