feat: reroll 一键重刷

This commit is contained in:
Siornya committed 2026-10-08 19:38:53 +08:00
1 parent 3eced7bdba
commit 816d9dffcc
21 files changed
+709 -27

No files matched your search

+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>`;
+50 -10
View File
@@ -15,15 +15,16 @@
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag, Modal, TierChip } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite, RichText } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
import { data } from '../data.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { enemyIconUrl, titleIconUrl, uiUrl, chessPortraitUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
import { sentText } from '../ui/lang.js';
import { useDocClass } from '../ui/device.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -56,11 +57,50 @@ export function resultBonds(bonds, bondRec = () => null) {
* 「替补」 mark — the title names the chess it fielded for.
*/
export function LineupThumb({ u, gd }) {
const chess = u.kind === 'token' ? null : gd.chess(u.id);
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
title=${si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : undefined} />`;
const [open, setOpen] = useState(false);
const token = u.kind === 'token';
const chess = token ? null : gd.chess(u.id);
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
const rec = dr || si || chess;
const name = rec?.name || (token ? gd.token(u.id)?.name : null) || u.id;
const title = si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : name;
const items = (Array.isArray(u.items) ? u.items : []).map((it) => typeof it === 'string' ? it : it?.id)
.filter((id) => typeof id === 'string' && id).slice(0, 2);
const slots = [0, 1];
return html`<div class="runit">
<button type="button" class=${cx('runit__open', u.golden && 'is-golden')} title=${title} aria-label=${`${title} · ${t('装备')} ${items.length}/2`}
aria-haspopup="dialog" aria-expanded=${open} onClick=${() => setOpen(true)}>
<span class="runit__art"><${Img} src=${chessPortraitUrl(gd.m, rec)} alt=${name} class="runit__portrait"
fallback=${html`<${UnitThumb} kind=${token ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" showTier=${false} rec=${dr || si} title=${title} />`} />
${!token && (u.tier || rec?.tier) ? html`<${TierChip} tier=${u.tier || rec.tier} golden=${!!u.golden} size="sm" class="runit__tier" />` : null}
${si ? html`<span class="runit__standin">${t('替补')}</span>` : null}
</span>
${!token ? html`<span class="runit__items">${slots.map((slot) => items[slot]
? html`<${UnitThumb} key=${slot} kind="item" id=${items[slot]} size="xs" showTier=${false} title=${gd.item(items[slot])?.name || items[slot]} />`
: html`<span key=${slot} class="runit__empty" aria-hidden="true">—</span>`)}</span>` : null}
</button>
${open ? html`<${Modal} open=${true} title=${title} micro=${t('装备')} class="result-unit" width="min(6.8rem, 94vw)"
onClose=${() => setOpen(false)} actions=${html`<${Button} onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<div class="result-unit__body">
<div class="result-unit__portrait"><${Img} src=${chessPortraitUrl(gd.m, rec)}
fallback=${html`<${UnitThumb} kind=${token ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} rec=${dr || si} size="lg" />`} />
${u.golden ? html`<span class="result-unit__elite">${t('精锐')}</span>` : null}</div>
<section class="result-unit__equipment" aria-label=${t('装备')}>
<h3>${t('装备')} <span class="num">${items.length}/2</span></h3>
${items.length ? items.map((id, i) => {
const item = gd.item(id);
return html`<div key=${i} class="result-unit__item">
<${UnitThumb} kind="item" id=${id} size="sm" />
<div><h4>${item?.name || id}${item?.isGolden ? html`<span class="result-unit__elite">${t('进阶')}</span>` : null}</h4>
<${RichText} as="p" text=${item?.descRaw || item?.desc || ''} />
</div>
</div>`;
}) : html`<p class="t-dim">—</p>`}
</section>
</div>
<//>` : null}
</div>`;
}
function PlayerCard({ p, myId, titles, best, solo = false }) {
@@ -105,6 +145,7 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
/** RESULT screen. */
export function ResultScreen() {
useDocClass('sp-result');
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
@@ -161,4 +202,3 @@ export function ResultScreen() {
</main>
</div>`;
}
+4 -1
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 }),
+49
View File
@@ -0,0 +1,49 @@
// A server-owned unanimous vote. Reconnects render the current vote from m.public.
import { useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button } from './components.js';
import { actions } from './gameActions.js';
import { useStore } from '../store.js';
import { t } from '../../../shared/i18n.js';
export function SetupReroll({ pub }) {
const myId = useStore((s) => s.me.playerId);
const hostId = useStore((s) => s.room?.hostId);
const online = useStore((s) => s.connection.status === 'online');
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const vote = pub.rerollVote;
const host = myId === hostId;
const players = pub.players || [];
const me = players.find((p) => p.playerId === myId && p.status !== 'left');
const allConnected = players.every((p) => p.isBot || p.status === 'left' || p.connected);
const voter = !!vote?.voters.includes(myId);
const agreed = !!vote?.agreed.includes(myId);
const send = async (fn) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
try { await fn(); } finally { pending.current = false; setBusy(false); }
};
if (!vote && (!host || !me)) return null;
return html`<section class="brief-reroll" aria-label=${t('重刷本局')} aria-live="polite">
<div class="brief-reroll__text">
<strong>${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}</strong>
<span>${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}</span>
${vote ? html`<div class="brief-reroll__players">${vote.voters.map((id) => {
const p = players.find((p) => p.playerId === id);
return html`<span key=${id} class=${vote.agreed.includes(id) ? 'is-agreed' : ''}>${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}</span>`;
})}</div>` : null}
</div>
<div class="brief-reroll__actions">
${!vote ? html`<${Button} icon="refresh" size="lg" loading=${busy} disabled=${!online || !allConnected}
title=${allConnected ? t('全体真人玩家一致同意后刷新,随机结果可能重复。') : t('请等待所有玩家连接后再发起投票')}
onClick=${() => send(() => actions.rerollSetup(pub.setupRevision ?? 0))}>${t('重刷本局')}<//>` : null}
${vote && voter ? html`<${Button} variant="primary" loading=${busy} disabled=${!online || agreed}
onClick=${() => send(() => actions.rerollVote(vote.id, true))}>${agreed ? t('已同意') : t('同意重刷')}<//>
<${Button} variant="danger" disabled=${busy || !online}
onClick=${() => send(() => actions.rerollVote(vote.id, false))}>${t('拒绝重刷')}<//>` : null}
${vote && host && me ? html`<${Button} disabled=${busy || !online}
onClick=${() => send(() => actions.cancelReroll(vote.id))}>${t('取消投票')}<//>` : null}
</div>
</section>`;
}