Match page banner preload and animate

This commit is contained in:
Kalle
2026-09-19 12:25:29 +03:00
parent 8184613e91
commit 53748efbad
6 changed files with 170 additions and 19 deletions

View File

@@ -38,6 +38,82 @@
justify-self: flex-end;
}
/* only the new image scales (from 1.05): a snapshot smaller than the frame shows its edge */
::view-transition-group(:global(.stage-banner-swap)) {
animation-duration: 0s;
}
::view-transition-image-pair(:global(.stage-banner-swap)) {
clip-path: inset(0 round var(--radius-box));
}
::view-transition-old(:global(.stage-banner-swap)) {
mix-blend-mode: plus-lighter;
animation: fade-out 400ms ease both;
}
::view-transition-new(:global(.stage-banner-swap)) {
mix-blend-mode: plus-lighter;
animation: settle-in 400ms ease both;
}
/* map name and pick info: old fades out first, new rises in once the image has settled */
::view-transition-group(:global(.stage-banner-text)) {
animation: none;
}
/* snapshots are stretched to the group's (new) width by default */
::view-transition-old(:global(.stage-banner-text)),
::view-transition-new(:global(.stage-banner-text)) {
inline-size: auto;
block-size: auto;
}
/* right-aligned, so a width change must keep the end edge in place */
::view-transition-old(match-stage-banner-info),
::view-transition-new(match-stage-banner-info) {
inset-inline-start: auto;
inset-inline-end: 0;
}
::view-transition-old(:global(.stage-banner-text)) {
animation: fade-out 150ms ease both;
}
::view-transition-new(:global(.stage-banner-text)) {
animation: rise-in 250ms cubic-bezier(0.22, 1, 0.36, 1) 400ms both;
}
/* unchanged parts; the old snapshot is hidden as stacking it would brighten the 70% opacity labels */
::view-transition-group(:global(.stage-banner-static)),
::view-transition-new(:global(.stage-banner-static)) {
animation: none;
}
::view-transition-old(:global(.stage-banner-static)) {
display: none;
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes settle-in {
from {
opacity: 0;
transform: scale(1.05);
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
.map {
grid-area: map;
display: flex;

View File

@@ -1,5 +1,7 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
import { ViewTransition } from "react";
import { preload } from "react-dom";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import {
@@ -47,32 +49,76 @@ export function MatchBanner({
const { t } = useTranslation(["game-misc"]);
return (
<div
className={styles.banner}
style={{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
}}
data-testid="stage-banner"
// keyed so a map change makes a share pair; default="none" so other
// re-renders (score reports, timer ticks) start no transition
<ViewTransition
key={`${mode}-${stageId}`}
name="match-stage-banner"
share="stage-banner-swap"
default="none"
>
<div
className={clsx(styles.map, styles.thickText)}
data-testid={`banner-map-${mode}-${stageId}`}
className={styles.banner}
style={{
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
}}
data-testid="stage-banner"
>
<ModeImage mode={mode} size={24} />
{t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)}
</div>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
<ViewTransition
name="match-stage-banner-map"
share="stage-banner-text reduced-motion-safe"
default="none"
>
<div
className={clsx(styles.map, styles.thickText)}
data-testid={`banner-map-${mode}-${stageId}`}
>
<ModeImage mode={mode} size={24} />
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
{t(`game-misc:STAGE_${stageId}`)}
</div>
</ViewTransition>
<ViewTransition
name="match-stage-banner-info"
share="stage-banner-text reduced-motion-safe"
default="none"
>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
</ViewTransition>
{joinPool ? (
<JoinInfo pool={joinPool} pass={joinPass} host={host} />
) : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
</div>
{joinPool ? (
<ViewTransition
name="match-stage-banner-join"
share="stage-banner-static"
default="none"
>
<JoinInfo pool={joinPool} pass={joinPass} host={host} />
</ViewTransition>
) : null}
{screenLegal !== undefined ? (
<ViewTransition
name="match-stage-banner-notice"
share="stage-banner-static"
default="none"
>
<ScreenNotice screenLegal={screenLegal} />
</ViewTransition>
) : null}
</div>
</ViewTransition>
);
}
/** Warms the cache with the remaining maps' banners so the transition to the next one has its image. */
export function preloadStageBanners(stageIds: StageId[]) {
for (const stageId of new Set(stageIds)) {
preload(stageBannerImageUrl(stageId), {
as: "image",
fetchPriority: "low",
});
}
}
export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) {
return (
<div className={clsx(styles.banner, styles.multiBanner)}>

View File

@@ -9,6 +9,7 @@ import {
MatchBannerContainer,
MatchBannerInfoBadge,
MultiMatchBanner,
preloadStageBanners,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
@@ -80,6 +81,12 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const currentMap = data.match.currentMap;
invariant(currentMap);
preloadStageBanners(
data.match.mapList
.filter((map) => map.winnerGroupId === null)
.map((map) => map.stageId),
);
const isParticipant = Boolean(
SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,

View File

@@ -17,6 +17,7 @@ import {
MatchBannerContainer,
MatchBannerInfoBadge,
MultiMatchBanner,
preloadStageBanners,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
@@ -54,8 +55,18 @@ export function TournamentMatchBanner({
joinPool,
joinPass,
teams,
scoreSum,
} = useMatch();
if (!data.matchIsOver) {
preloadStageBanners(
(data.mapList ?? [])
.filter((map) => !map.bannedByTournamentTeamId)
.slice(scoreSum)
.map((map) => map.stageId),
);
}
const [teamOne, teamTwo] = teams;
const hostingTeam =
teamOne && teamTwo ? resolveHostingTeam([teamOne, teamTwo]) : null;

View File

@@ -542,5 +542,11 @@
::view-transition-new(*) {
animation: none;
}
/* opt-in for boundaries whose animation is a plain fade */
::view-transition-old(*.reduced-motion-safe),
::view-transition-new(*.reduced-motion-safe) {
animation: revert-layer;
}
}
}

View File

@@ -0,0 +1,5 @@
---
navItem: [sendouq, medal]
type: feature
---
Match page stage banner now crossfades to the next map instead of popping, with upcoming maps' images loaded ahead of time