Newer
Older
PixelPaintWar / apps / client / src / scenes / result / ResultScene.tsx
/**
 * ResultScene
 * ゲーム終了後の順位一覧を表示する結果画面コンポーネント
 * 順位,チーム名,塗り率の3項目をテーブル形式で描画する
 */
import type { GameResultPayload } from "@repo/shared";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import { config } from "../../config";

type Props = {
  result: GameResultPayload | null;
  onBackToTitle: () => void;
};

type GameResultWithFinalMap = GameResultPayload & {
  finalGridColors?: number[];
};

const formatPaintRate = (value: number): string => `${value.toFixed(1)}%`;

const ROW_GRID_TEMPLATE = "120px 1fr 180px";

const ROOT_STYLE: CSSProperties = {
  width: "100vw",
  height: "100dvh",
  position: "relative",
  overflow: "hidden",
  background: "#111",
  color: "white",
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  padding: "24px",
  boxSizing: "border-box",
};

const TITLE_STYLE: CSSProperties = {
  margin: "0 0 20px 0",
  fontSize: "clamp(1.8rem, 4.4vw, 2.6rem)",
  fontFamily: "'Yu Mincho', 'Hiragino Mincho ProN', serif",
  letterSpacing: "0.14em",
  fontWeight: 800,
  textShadow: "0 4px 14px rgba(0, 0, 0, 0.5)",
  animation: "titleGleam 3.6s ease-in-out infinite",
};

const getTitleTextStyle = (winnerColor: string): CSSProperties => ({
  display: "inline-block",
  background: `linear-gradient(120deg, #FFF8D9 0%, #FFD95A 42%, ${winnerColor} 100%)`,
  WebkitBackgroundClip: "text",
  backgroundClip: "text",
  color: "transparent",
  WebkitTextFillColor: "transparent",
  WebkitTextStroke: "1px rgba(255, 255, 255, 0.22)",
  filter: "drop-shadow(0 0 8px rgba(255, 220, 120, 0.35))",
});

const CONTENT_STYLE: CSSProperties = {
  width: "100%",
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  position: "relative",
  zIndex: 1,
};

const EFFECT_LAYER_BASE_STYLE: CSSProperties = {
  position: "absolute",
  inset: 0,
  pointerEvents: "none",
};

const MAP_BACKGROUND_LAYER_STYLE: CSSProperties = {
  ...EFFECT_LAYER_BASE_STYLE,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 0,
  opacity: 0.9,
};

const BACKGROUND_DARK_OVERLAY_STYLE: CSSProperties = {
  ...EFFECT_LAYER_BASE_STYLE,
  background: "rgba(0, 0, 0, 0.62)",
};

const CONFETTI_LAYER_STYLE: CSSProperties = {
  ...EFFECT_LAYER_BASE_STYLE,
  overflow: "hidden",
  zIndex: 0,
};

const TAP_GUIDE_STYLE: CSSProperties = {
  marginTop: "6px",
  fontSize: "clamp(1rem, 2.8vw, 1.35rem)",
  letterSpacing: "0.14em",
  fontWeight: 700,
  color: "rgba(255, 255, 255, 0.92)",
  textShadow: "0 2px 10px rgba(0, 0, 0, 0.6)",
  animation: "tapPromptPulse 1.6s ease-in-out infinite",
};

const TABLE_STYLE: CSSProperties = {
  width: "100%",
  maxWidth: "720px",
  border: "1px solid rgba(255, 255, 255, 0.18)",
  borderRadius: "8px",
  overflow: "hidden",
  backdropFilter: "blur(2px)",
  background: "rgba(16, 16, 16, 0.62)",
};

const RANKING_SCROLL_BODY_STYLE: CSSProperties = {
  maxHeight: "min(52dvh, 420px)",
  overflowY: "auto",
};

const HEADER_ROW_STYLE: CSSProperties = {
  display: "grid",
  gridTemplateColumns: ROW_GRID_TEMPLATE,
  background: "#222",
  padding: "12px 16px",
  fontWeight: "bold",
};

const RIGHT_ALIGN_STYLE: CSSProperties = { textAlign: "right" };

const RATE_STYLE: CSSProperties = {
  textAlign: "right",
  fontVariantNumeric: "tabular-nums",
};

const RANK_BASE_STYLE: CSSProperties = {
  fontFamily: "serif",
  fontWeight: 800,
  letterSpacing: "0.04em",
  fontVariantNumeric: "tabular-nums",
  fontSize: "1.15rem",
  textShadow: "0 2px 8px rgba(0, 0, 0, 0.42)",
};

const getRankStyle = (rank: number): CSSProperties => {
  if (rank === 1) {
    return {
      ...RANK_BASE_STYLE,
      color: "#FFD95A",
      textShadow:
        "0 0 10px rgba(255, 217, 90, 0.5), 0 2px 8px rgba(0, 0, 0, 0.42)",
    };
  }

  if (rank === 2) {
    return {
      ...RANK_BASE_STYLE,
      color: "#E2E8F0",
      textShadow:
        "0 0 8px rgba(226, 232, 240, 0.35), 0 2px 8px rgba(0, 0, 0, 0.42)",
    };
  }

  if (rank === 3) {
    return {
      ...RANK_BASE_STYLE,
      color: "#E7A977",
      textShadow:
        "0 0 8px rgba(231, 169, 119, 0.35), 0 2px 8px rgba(0, 0, 0, 0.42)",
    };
  }

  return {
    ...RANK_BASE_STYLE,
    color: "#F5F5F5",
  };
};

const TEAM_CELL_STYLE: CSSProperties = {
  display: "inline-flex",
  alignItems: "center",
  gap: "8px",
};

const getTeamColorDotStyle = (color: string): CSSProperties => ({
  width: "10px",
  height: "10px",
  borderRadius: "9999px",
  background: color,
  border: "1px solid rgba(255, 255, 255, 0.35)",
  flexShrink: 0,
});

const getBodyRowStyle = (index: number): CSSProperties => ({
  display: "grid",
  gridTemplateColumns: ROW_GRID_TEMPLATE,
  padding: "12px 16px",
  borderTop: "1px solid #333",
  background: index % 2 === 0 ? "#171717" : "#1d1d1d",
});

const CONFETTI_COUNT = 36;

const getConfettiStyle = (
  index: number,
  winnerColor: string,
): CSSProperties => {
  const leftPercent = (index * 19 + 7) % 100;
  const size = 6 + (index % 5);
  const durationSec = 6 + (index % 6) * 0.55;
  const delaySec = -((index % 9) * 0.8);
  const rotateStartDeg = (index * 37) % 360;

  return {
    position: "absolute",
    top: "-12%",
    left: `${leftPercent}%`,
    width: `${size}px`,
    height: `${Math.round(size * 1.8)}px`,
    background: winnerColor,
    borderRadius: "2px",
    opacity: 0.9,
    transform: `rotate(${rotateStartDeg}deg)`,
    animation: `confettiFall ${durationSec}s linear ${delaySec}s infinite, confettiSway ${2.4 + (index % 4) * 0.35}s ease-in-out ${delaySec}s infinite`,
    boxShadow: "0 0 8px rgba(255, 255, 255, 0.18)",
  };
};

const getMapWrapperStyle = (cols: number, rows: number): CSSProperties => ({
  width: `min(94vw, calc(92dvh * ${cols / rows}))`,
  maxHeight: "92dvh",
  aspectRatio: `${cols} / ${rows}`,
  display: "grid",
  gridTemplateColumns: `repeat(${cols}, 1fr)`,
  border: "1px solid rgba(255, 255, 255, 0.14)",
});

const getMapCellStyle = (teamId: number): CSSProperties => ({
  background: config.GAME_CONFIG.TEAM_COLORS[teamId] ?? "#1b1b1b",
});

/** 最終結果データを受け取り,順位一覧を表示する */
export const ResultScene = ({ result, onBackToTitle }: Props) => {
  const [isRankingVisible, setIsRankingVisible] = useState(false);

  useEffect(() => {
    setIsRankingVisible(false);
  }, [result]);

  if (!result) {
    return (
      <div style={{ color: "white", padding: 40 }}>結果を読み込み中...</div>
    );
  }

  const winnerTeamId =
    result.rankings.find((row) => row.rank === 1)?.teamId ??
    result.rankings[0]?.teamId;
  const winnerColor =
    config.GAME_CONFIG.TEAM_COLORS[winnerTeamId ?? -1] ?? "#888888";
  const resultWithFinalMap = result as GameResultWithFinalMap;
  const gridCols = config.GAME_CONFIG.GRID_COLS;
  const gridRows = config.GAME_CONFIG.GRID_ROWS;
  const totalCells = gridCols * gridRows;
  const finalGridColors = Array.from({ length: totalCells }, (_, index) => {
    const teamId = resultWithFinalMap.finalGridColors?.[index];
    return typeof teamId === "number" ? teamId : -1;
  });

  return (
    <div
      style={{
        ...ROOT_STYLE,
        cursor: isRankingVisible ? "default" : "pointer",
      }}
      onClick={() => {
        if (isRankingVisible) {
          return;
        }

        setIsRankingVisible(true);
      }}
    >
      <style>
        {`@keyframes confettiFall {
            0% { transform: translate3d(0, -8vh, 0) rotate(0deg); opacity: 0; }
            8% { opacity: 0.95; }
            100% { transform: translate3d(0, 115vh, 0) rotate(540deg); opacity: 0.95; }
          }
          @keyframes confettiSway {
            0%, 100% { margin-left: -8px; }
            50% { margin-left: 8px; }
          }
          @keyframes tapPromptPulse {
            0%, 100% { opacity: 0.5; transform: translateY(0); }
            50% { opacity: 1; transform: translateY(-3px); }
          }
          @keyframes titleGleam {
            0%, 100% { transform: scale(1); filter: brightness(1); }
            50% { transform: scale(1.03); filter: brightness(1.1); }
          }`}
      </style>
      <div style={MAP_BACKGROUND_LAYER_STYLE}>
        <div style={getMapWrapperStyle(gridCols, gridRows)}>
          {finalGridColors.map((teamId, index) => (
            <span
              key={`result-map-cell-${index}`}
              style={getMapCellStyle(teamId)}
            />
          ))}
        </div>
      </div>
      <div style={CONFETTI_LAYER_STYLE}>
        {Array.from({ length: CONFETTI_COUNT }, (_, index) => (
          <span
            key={`confetti-${index}`}
            style={getConfettiStyle(index, winnerColor)}
          />
        ))}
      </div>
      <div style={BACKGROUND_DARK_OVERLAY_STYLE} />

      <div style={CONTENT_STYLE}>
        {isRankingVisible && (
          <div
            style={{
              width: "100%",
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              gap: "10px",
              marginBottom: "10px",
            }}
          >
            <button
              onClick={(event) => {
                event.stopPropagation();
                onBackToTitle();
              }}
              style={{
                padding: "10px 14px",
                fontSize: "0.95rem",
                cursor: "pointer",
                borderRadius: "8px",
                border: "1px solid rgba(255,255,255,0.45)",
                background: "rgba(0,0,0,0.55)",
                color: "white",
                fontWeight: 700,
              }}
            >
              タイトルへ戻る
            </button>

            <button
              onClick={(event) => {
                event.stopPropagation();
                setIsRankingVisible(false);
              }}
              style={{
                padding: "10px 14px",
                fontSize: "0.95rem",
                cursor: "pointer",
                borderRadius: "8px",
                border: "1px solid rgba(255,255,255,0.45)",
                background: "rgba(0,0,0,0.55)",
                color: "white",
                fontWeight: 700,
              }}
            >
              最終マップを見る
            </button>
          </div>
        )}

        <h2 style={TITLE_STYLE}>
          <span style={getTitleTextStyle(winnerColor)}>結果発表</span>
        </h2>

        {!isRankingVisible && <div style={TAP_GUIDE_STYLE}>Tap To Result</div>}

        {isRankingVisible && (
          <div style={TABLE_STYLE}>
            <div style={HEADER_ROW_STYLE}>
              <span>順位</span>
              <span>チーム名</span>
              <span style={RIGHT_ALIGN_STYLE}>塗り率</span>
            </div>

            <div style={RANKING_SCROLL_BODY_STYLE}>
              {result.rankings.map((row, index) => (
                <div
                  key={`${row.teamId}-${index}`}
                  style={getBodyRowStyle(index)}
                >
                  <span style={getRankStyle(row.rank)}>{row.rank}位</span>
                  <span style={TEAM_CELL_STYLE}>
                    <span
                      style={getTeamColorDotStyle(
                        config.GAME_CONFIG.TEAM_COLORS[row.teamId] ?? "#888888",
                      )}
                    />
                    <span>{row.teamName}</span>
                  </span>
                  <span style={RATE_STYLE}>
                    {formatPaintRate(row.paintRate)}
                  </span>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
};