mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 13:17:15 -05:00
Match page banner preload and animate
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
changelog/2026-09-19-match-stage-banner-transition.md
Normal file
5
changelog/2026-09-19-match-stage-banner-transition.md
Normal 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
|
||||
Reference in New Issue
Block a user